feat: Phase 6b rules with delay, delta format, reminders, zpool default rule
test / pytest (3.11) (push) Waiting to run
test / pytest (3.13) (push) Waiting to run

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
patrickandClaude Sonnet 5.5 committed 2026-11-06 02:38:10 +01:00
1 parent 9c4f7366eb
commit 5278530467
58 files changed
+1781 -230

No files matched your search

+11 -2
View File
@@ -6,13 +6,13 @@ Vollständiger Plan: `/home/sysops/.claude/plans/ja-alle-bernehmen-eager-lark.md
## Arbeitsregeln
- Auf diesem Rechner wird nichts installiert (kein venv, kein pip, keine Pakete). Code nur schreiben, Ausführung und Tests passieren im Ziel-LXC.
- Stand: Phase 1 bis 6 als Code, Phase 7 als Dateien geschrieben, nichts ausgeführt oder getestet (siehe README-Statustabelle).
- Stand: Phase 1 bis 6 und 6b als Code, Phase 7 als Dateien geschrieben, nichts ausgeführt oder getestet (siehe README-Statustabelle).
- Code-Kommentare nur bei echtem WHY.
## Festgelegte Entscheidungen
- Plattform: Debian-LXC, unprivileged, 1 Core, 1 GB RAM, Python 3, venv im LXC, systemd, Caddy (HTTPS, interne CA). Kein Docker.
- Checkmk 2.5 ist die Zielversion (Live-System). Alle REST-Annahmen (Spaltennamen für Services/Hosts, Downtime-Endpunkt, Antwortformat) wurden nach dem Stand von 2.3 formuliert und müssen im ersten Lauf gegen 2.5 geprüft werden (OpenAPI-Doku der Instanz unter `/<site>/check_mk/api/1.0/ui/`, Integrationstests Phase 8). Nur REST-API (Automation-User, Lese-Rolle). Kein Webhook, kein Notification-Script, kein eingehender Alarm-Port.
- Poller: 30 s (UI einstellbar), nur Nicht-OK, Zustandsvergleich in SQLite, nur Hard-States, Mindestdauer einstellbar (Default 0 s), Flapping gebündelt.
- Poller: 30 s (UI einstellbar), nur Nicht-OK, Zustandsvergleich in SQLite, nur Hard-States, Verzögerung einstellbar (Default 15 min, Regeln überschreiben, Phase 6b), Flapping gebündelt.
- Ollama extern `office.sysops.de:11434`. Zwei Modell-Einstellungen (Alarmtext, Agent), Start `qwen3`, Hermes zum Vergleich.
- Chat: Adapter-Interface. Telegram (Long-Polling) und Matrix (direkt über die Client-Server-API mit `httpx`, bewusst nicht `matrix-nio`: kein libolm/E2EE nötig; eigener Homeserver, unverschlüsselter Raum, Bot-User mit Access-Token). Parallel, einzeln abschaltbar, Ziel pro Routing-Regel.
- Alarm-Flow: Rohalarm sofort, KI-Analyse per Edit nachgereicht (Telegram editMessage, Matrix m.replace). Max 8 Zeilen DE. Timeout 60 s, 2 Retries, sonst "KI nicht verfügbar". Bündelung pro Host 60 s, max 10 Prompts/min, Recovery ohne KI. Downtime: kein Push, Verlauf "unterdrückt". UI-Stummschaltung mit Ablauf.
@@ -74,6 +74,14 @@ tests/
- UI: `/prompts/{name}` (alle), `/prompts/{name}/edit|draft|versions/{n}/activate|test` (Admin), `/settings/retention` (generische Settings-Section mit `extra_template`) und `POST /retention/run`.
- Offene Annahmen: Ollama-Retries im Testbereich wie im Betrieb (Request kann bis zu 3 x Timeout dauern); der Setup-Assistent zeigt Aufbewahrung wie Alarmierung als "erledigt", weil kein Pflichtfeld existiert.
## Phase 6b Regeln, Verzögerung, Delta, Erinnerungen (Code geschrieben, ungetestet)
- Migration Index 6: `service_states` + `problem_since`, `reported_at` (Zeitpunkt der letzten Meldung, bei Altzeilen = Migrationszeit), `reported_since` (Beginn des gemeldeten Zustands), `last_reminder_ts` (wird vom Poller-Upsert nie überschrieben); `routes` + `delay_minutes` (NULL = Standard), `output_contains`, `ai_enabled`, `ai_hint`, `reminder_hours` (NULL/0 = keine), `loud`, `use_default_targets`; neue Tabelle `app_state` (key/value, Marker `seed.rule.zfs_zpool`). Bis Index 6 belegt, 6c nimmt Index 7.
- Settings: `alerts.default_delay_minutes` (15, 0-1440), `alerts.recovery_enabled` (an), `alerts.reminder_hours` (0, 0-168). `poll.min_duration` ist entfernt, der Altwert in `settings` bleibt unbeachtet liegen.
- `app/routing/`: `rules.py` (Route, `matches`, `matching_rules` mit stop_on_match, `resolve_targets` inkl. use_default_targets), `decisions.py` (`resolve_delay`, `delay_provider`, `ai_allowed`, `resolve_loud`, `ai_hints` max. 1200, `reminder_interval`), `seed.py` (Zpool-Regel einmalig, Fehler nur geloggt, Aufruf im Lifespan), `limits.py`. Erinnerungen matchen wie Probleme (Art `reminder` nutzt `problem` der Regel).
- Poller: `compute_events(previous, current, now, delay_seconds)` mit Callable pro (Host, Service, Status, Output); Regeln pro Zyklus frisch (`Poller(..., rules=routes.enabled_routes)`). `Event` hat `previous_state`, `previous_duration`, `since_ts`, `problem_since_ts`, `ai_enabled`, `ai_hint`; Art `reminder`.
- Pipeline: `alerts/gate.py` (`ReportGate`: merkt pro Service, ob das Problem gesendet wurde, Fallback aus `alerts` per `report_counts`; Recovery-Schalter und "Recovery nur nach gemeldetem Problem", Gründe `recovery_aus`, `nicht_gemeldet`), Gruppenschlüssel (Host, Ziele, leise); Adapter `send/edit(..., silent=False)`, `Deliverer` übergibt `silent` nur wenn gesetzt. `alerts/delta.py` (Dauerformat, Delta-Texte), `alerts/report_status.py` (Dashboard).
- `app/reminders/`: `due_reminders` (rein) und `ReminderWorker` (60 s, Zeitpunkt wird vor dem Senden gespeichert, im Lifespan gestartet).
## Weiterer Weg (entschieden)
Reihenfolge: Testbasis, erster Lauf im Test-LXC (`pytest`), Korrekturrunde, Phase 6, Phase 6b, Phase 6c, Phase 8, Abnahme, Parallelbetrieb, Tag `v0.1.0`. Verfeinerungen passieren am Live-System, nicht vorab.
- Testbasis: `pytest` manuell im Test-LXC (Dev-venv mit `requirements-dev.txt`), dazu `.gitea/workflows/test.yml` für einen späteren Gitea-Actions-Runner. Logging als Zeilenlog nach journald, Level über `CHECKBOT_LOG_LEVEL` (Default INFO), nie Secrets in Logs (Test für die Adapter). Zeitzone über `CHECKBOT_TZ` im Setup-Skript, Default `Europe/Berlin`.
@@ -95,6 +103,7 @@ Reihenfolge: Testbasis, erster Lauf im Test-LXC (`pytest`), Korrekturrunde, Phas
- Phase 9 (nach der Abnahme, optional, in der UI abschaltbar): Statusberichte nach Zeitplan. Das Modell schreibt aus dem aktuellen Zustand und den Änderungen seit dem letzten Bericht: Zähler (DOWN-Hosts, CRIT, WARN, UNKNOWN), neu, weiter offen, erholt, bestätigt, in Downtime. Der Zeitplan ist in der UI einstellbar: mehrere Uhrzeiten, Wochentage, Zeitzone des LXC, Ziele, Inhalt voll oder kurz. Dazu ein Button "Bericht jetzt senden" und der Befehl `/report`. Der Bericht merkt sich den Stand des letzten Berichts (Vergleich "seit 18:05"). Verfeinerung am Live-System.
- Delta in der Alarmnachricht (Phase 6b): `Event` bekommt Vorzustand und Dauer. Alarmzeile zeigt Übergang und Dauer (`WARN → CRIT, seit 18:05 (vorher WARN seit 2 h)`), Recovery die Gesamtdauer (`wieder OK nach 3 h 12 min (zuletzt CRIT)`), Erinnerungen `weiter CRIT seit 4 h`.
- Recovery-Meldungen sind in der UI schaltbar: globaler Schalter `alerts.recovery_enabled` (Default an) in der Section "Alarmierung" und pro Regel über die Ereignisarten (`kinds` enthält `recovery` oder nicht). Greift zusätzlich zur Regel "Recovery nur, wenn das Problem vorher gemeldet wurde". Ist Recovery aus, bleibt sie im Verlauf sichtbar, wird aber nicht gesendet.
- Verzögerung (Phase 6b): Section "Alarmierung" bekommt `alerts.default_delay_minutes` (Werkseinstellung 15; ersetzt `poll.min_duration`). Jede Regel kann die Verzögerung überschreiben, auch mit 0. Passen mehrere Regeln, gilt die kleinste. Sie zählt ab der letzten Statusänderung, eine Verschlechterung startet sie neu; wird das Problem vorher OK, gibt es weder Alarm noch Recovery. Das Dashboard zeigt wartende Probleme ("wartet noch 8 min"). Mute und Downtime wirken auch während der Wartezeit. Host-Alarme und der Checkmk-Ausfall (Phase 6c) kommen immer sofort, Host-Verzögerung `alerts.host_delay_minutes` Standard 0. Die vorinstallierte Regel "ZFS Zpool" (`*zpool*`) hat Verzögerung 0, damit gilt ab Werk: Zpool sofort, alles andere nach 15 min.
- Aktionen: Der Bot führt nie Shell-Befehle aus (weder lokal noch per SSH). Webhook pro Regel (POST JSON an Allowlist-URLs, Audit) kommt erst nach der Abnahme.
- Phase 8: Integrationstests mit Marker `integration` (`pytest -m integration`), Zugangsdaten per Umgebung, nur lesend; Downtime nur mit ausdrücklichem Flag und gegen einen Testhost. Testhost `checkbot-test` in Checkmk mit schaltbarem lokalem Check plus halbautomatisches `tests/e2e/run.py`.
- Abnahme (`docs/ACCEPTANCE.md`): CRIT in Telegram und Element, KI-Analyse binnen 90 s, Recovery ohne KI, Downtime und `mute` unterdrücken, Ollama aus ergibt Rohalarm mit "KI nicht verfügbar", Neustart ohne Doppelalarme, Backup und Restore real getestet, `/health` im Checkmk grün, Baseline-Zusammenfassung beim Erstlauf, Host-down unterdrückt Service-Alarme, Meldung bei Checkmk-Ausfall, 24 h Dauerlauf ohne Fehler im Journal.
+14 -4
View File
@@ -8,20 +8,29 @@ Bot für Checkmk. Er liest den Zustand per REST-API, lässt Probleme von einem e
### Alarme melden
- Fragt Checkmk alle 30 s ab (einstellbar) und erkennt neue Probleme, Statuswechsel (WARN/CRIT/UNKNOWN), Recoveries und Flapping.
- Meldet nur Hard-States, optional erst nach einer Mindestdauer.
- Meldet nur Hard-States und erst nach einer Verzögerung (siehe unten).
- Respektiert Downtimes: Services in Downtime lösen keine Nachricht aus und erscheinen im Verlauf als "unterdrückt".
- Bündelt Alarme eines Hosts zu einer Nachricht und begrenzt den Durchsatz, damit eine Alarmflut den Chat und Ollama nicht überrollt.
- Fällt Ollama aus, kommt der Rohalarm trotzdem sofort. Die Analyse wird nachgereicht oder als "KI nicht verfügbar" markiert.
### Verzögerung, Delta, Erinnerungen und Recovery
- Verzögerung: Ein Problem wird erst gemeldet, wenn sein jetziger Zustand seit der Standard-Verzögerung (Alarmierung, Werkseinstellung 15 min, 0 = sofort) besteht; eine Regel kann das überschreiben, auch mit 0. Die Zeit zählt ab der letzten Statusänderung in Checkmk, eine Verschlechterung startet sie neu. Wird das Problem vorher wieder OK, gibt es weder Alarm noch Recovery. Mute und Downtime wirken auch in der Wartezeit; was bei Ablauf der Verzögerung unterdrückt ist, wird nicht nachgeholt. Die frühere Einstellung `poll.min_duration` gibt es nicht mehr, ein vorhandener Altwert wird ignoriert.
- Delta in der Nachricht: `WARN → CRIT, seit 18:05 (vorher WARN seit 2 h)`, bei Recovery `wieder OK nach 3 h 12 min (zuletzt CRIT)`, bei Erinnerungen `weiter CRIT seit 4 h: <Output>`. Zeiten in der lokalen Zeit des Prozesses.
- Erinnerungen: nur für offene CRIT-Probleme, die gemeldet wurden und weiter CRIT sind, nicht bei Mute, Downtime oder Flapping. Intervall aus den passenden Regeln (kleinstes), sonst die Standard-Erinnerung in Stunden (Alarmierung, Werkseinstellung 0 = aus). Gemessen ab der letzten Meldung bzw. Erinnerung, der Zeitpunkt ist in der Datenbank gespeichert (kein Doppelversand nach Neustart). Erinnerungen laufen ohne KI durch dasselbe Routing wie Alarme und stehen im Verlauf als Art `reminder`.
- Recovery: Schalter "Recovery-Meldungen senden" (Alarmierung, Werkseinstellung an) und pro Regel über die Alarmarten. Eine Recovery wird nur gesendet, wenn das Problem vorher tatsächlich gemeldet wurde; sonst (oder bei ausgeschaltetem Schalter) steht sie unterdrückt im Verlauf.
### KI-Analyse
- Pro Problem kurze Analyse auf Deutsch (max. 8 Zeilen): Zusammenfassung, wahrscheinliche Ursache, nächster Prüfschritt.
- Kontext: Host, Service, Plugin-Output, letzte Statuswechsel, Downtime-Info.
- Ein KI-Hinweis der Regel steht als Abschnitt "Zusatzkontext des Administrators" im Nutzer-Prompt vor dem Datenblock (bereinigt, gekürzt, ohne Begrenzer). Der feste Schutzhinweis im System-Prompt bleibt unverändert.
- Plugin-Output gilt als nicht vertrauenswürdig. Er wird abgegrenzt, gekürzt und bereinigt, und die KI kann nichts auslösen.
### Chat (Telegram und Element)
- Beide Backends parallel, einzeln abschaltbar.
- Matrix läuft direkt über die Client-Server-API (`httpx`, kein `matrix-nio`, keine E2EE-Abhängigkeiten). Der Alarmraum muss unverschlüsselt sein, der Bot-User braucht einen Access-Token und muss dem Raum beigetreten sein. Die Analyse kommt per `m.replace`-Edit.
- Routing (UI `/routing`): Regeln mit Host-/Service-Glob, Mindest-Schweregrad, Alarmarten und Zielen (Backend plus Chat-ID bzw. Raum-ID). Regeln gelten von oben nach unten, jede passende fügt ihre Ziele hinzu, optional mit Stopp nach Treffer. Passt keine Regel, gehen Alarme an die Standardziele aller aktivierten Backends. Alarme eines Hosts mit gleicher Zielmenge werden zu einer Nachricht gebündelt.
- Regeln (UI `/routing`, in der Navigation "Regeln"): Host-/Service-Glob, optional "Ausgabe enthält" (Teilstring, keine Regex), Mindest-Schweregrad, Alarmarten und Ziele (Backend plus Chat-ID bzw. Raum-ID). Regeln gelten von oben nach unten, jede passende fügt ihre Ziele hinzu, optional mit Stopp nach Treffer. "Standardziele" in einer Regel fügt die Standardziele aller aktivierten Backends zu den expliziten Zielen hinzu. Eine passende Regel ganz ohne Ziele und ohne Standardziele sendet bewusst nichts. Passt keine Regel, gehen Alarme an die Standardziele. Alarme eines Hosts mit gleicher Zielmenge und gleicher Lautstärke werden zu einer Nachricht gebündelt.
- Weitere Regeloptionen: Verzögerung in Minuten (leer = Standard), KI an/aus, KI-Hinweis (max. 800 Zeichen), Erinnerung bei CRIT in Stunden (leer oder 0 = keine) und laut/leise (leise: Telegram ohne Benachrichtigung, Matrix als `m.notice`). Mehrere passende Regeln: die kleinste Verzögerung und das kleinste Erinnerungsintervall gelten, KI ist aus, sobald eine passende Regel sie ausschaltet, laut gilt, sobald eine passende Regel laut ist, KI-Hinweise werden in Regelreihenfolge zusammengefügt (insgesamt max. 1200 Zeichen).
- Vorinstallierte Regel "ZFS Zpool" (einmalig beim ersten Start angelegt, danach editier- und löschbar, eine gelöschte Regel kommt nicht zurück): Service `*zpool*` ab WARN, Standardziele, Verzögerung 0, KI mit Hinweis auf typische Prüfschritte, Erinnerung bei CRIT alle 4 h.
- Stummschaltung (UI `/mutes`): Host-/Service-Glob mit Ablauf (30 min bis 24 h oder freie Minuten) und Grund. Passende Probleme, Flapping und Recoveries werden nicht gemeldet und im Verlauf als unterdrückt (Stummschaltung) geführt. Abgelaufene Stummschaltungen räumt der Bot selbst auf.
- Der Versand läuft über eine Warteschlange (max. 1000 Events, bei Überlauf wird das älteste verworfen), ein langsames Chat-Backend bremst den Poller nicht.
- Rohalarm zuerst, die Analyse ersetzt oder ergänzt die Nachricht per Edit.
@@ -70,9 +79,9 @@ Bot für Checkmk. Er liest den Zustand per REST-API, lässt Probleme von einem e
### Web-UI
- Anmeldung mit lokalen Nutzern (Argon2), optional TOTP. Rollen `admin` und `viewer`.
- Einstellungen mit Verbindungstest: Checkmk, Ollama, Telegram, Matrix, Checkmk Downtime. Dazu die Bereiche Alarmierung (Abfrageintervall, Mindestdauer, Bündelungsfenster, KI-Analysen pro Minute) und Aufbewahrung.
- Einstellungen mit Verbindungstest: Checkmk, Ollama, Telegram, Matrix, Checkmk Downtime. Dazu die Bereiche Alarmierung (Abfrageintervall, Standard-Verzögerung, Recovery-Schalter, Standard-Erinnerung, Bündelungsfenster, KI-Analysen pro Minute) und Aufbewahrung.
- Setup-Assistent beim ersten Start, Dashboard mit Live-Status, Alarmverlauf, Audit-Log, Benutzerverwaltung.
- Routing und Stummschaltungen (Admins bearbeiten, Viewer sehen).
- Regeln und Stummschaltungen (Admins bearbeiten, Viewer sehen). Das Dashboard zeigt bei offenen Problemen den Meldestatus: "gemeldet", "wartet noch N min" oder "unterdrückt (Grund)".
- Prompts (UI `/prompts`), siehe unten. Aufbewahrung (UI `/settings/retention`), siehe unten.
- Tokens und Secrets liegen verschlüsselt in der Datenbank und werden in der UI nie wieder angezeigt.
@@ -99,6 +108,7 @@ Web-UI (FastAPI, Jinja2) ---- SQLite (Einstellungen, Verlauf, Audit, Nutzer)
| Phase 4: Matrix-Adapter, Routing, Stummschaltung in der UI | Code geschrieben, ungetestet |
| Phase 5: Befehle, `mute`/`downtime`, Agent-Loop, Listener, Agent-Ansicht | Code geschrieben, ungetestet |
| Phase 6: Prompt-Versionierung mit Rollback und Testbereich, Retention | Code geschrieben, ungetestet |
| Phase 6b: Regeln (Ausgabefilter, Verzögerung, KI-Hinweis, Erinnerung, laut/leise), Delta, Erinnerungen, Recovery-Schalter, Zpool-Standardregel | Code geschrieben, ungetestet |
| Phase 7: Deployment (`setup-lxc.sh`, Caddy, systemd, Backup, Checkmk-Local-Check) | Dateien geschrieben, nicht auf einem System ausprobiert |
| Phase 8: Integrationstests | geplant |
+4 -2
View File
@@ -3,15 +3,17 @@ from .bundler import HostBundler
from .delivery import Deliverer
from .extras import AlertActions, Extras
from .formatter import Analysis, format_message
from .gate import ReportGate
from .pipeline import AlertPipeline
from .prompts import (DATA_NOTICE as ALERT_DATA_NOTICE, DEFAULT_ALERT_SYSTEM_PROMPT, build_messages,
build_system_prompt, build_user_prompt, sanitize)
from .ratelimit import SlidingWindowLimiter
from .report_status import ReportStatus, report_status
from .repository import AlertRepository
__all__ = [
"ALERT_DATA_NOTICE", "DEFAULT_ALERT_SYSTEM_PROMPT", "alert_model", "build_messages",
"AlertActions", "AlertAnalyzer", "AlertPipeline", "AlertRepository", "Analysis", "AnalysisResult", "Deliverer", "Extras",
"HostBundler", "SlidingWindowLimiter", "build_system_prompt", "build_user_prompt", "format_message",
"sanitize",
"HostBundler", "ReportGate", "ReportStatus", "SlidingWindowLimiter", "build_system_prompt",
"build_user_prompt", "format_message", "report_status", "sanitize",
]
+1 -1
View File
@@ -59,7 +59,7 @@ class AlertAnalyzer:
async def _ask(self, client: OllamaClient, event: Event) -> AnalysisResult:
history = await asyncio.to_thread(self._history, event.host, event.service, RECENT_CHANGES_DEFAULT)
editable = await asyncio.to_thread(self._editable_prompt)
messages = build_messages(editable, event, history)
messages = build_messages(editable, event, history, event.ai_hint)
model = alert_model(self._store)
try:
reply = await client.chat(model, messages)
+17 -13
View File
@@ -18,24 +18,26 @@ class Deliverer:
self._adapters = adapters
self._timeout = timeout
async def send(self, text: str, targets: Sequence[Target],
extras: ExtrasProvider | None = None) -> list[MessageRef]:
results = await asyncio.gather(*(self._send_one(target, text, extras) for target in targets))
async def send(self, text: str, targets: Sequence[Target], extras: ExtrasProvider | None = None,
silent: bool = False) -> list[MessageRef]:
results = await asyncio.gather(*(self._send_one(target, text, extras, silent) for target in targets))
return [ref for ref in results if ref is not None]
async def edit(self, refs: Sequence[MessageRef], text: str, extras: ExtrasProvider | None = None) -> None:
await asyncio.gather(*(self._edit_one(ref, text, extras) for ref in refs))
async def edit(self, refs: Sequence[MessageRef], text: str, extras: ExtrasProvider | None = None,
silent: bool = False) -> None:
await asyncio.gather(*(self._edit_one(ref, text, extras, silent) for ref in refs))
async def _send_one(self, target: Target, text: str, extras: ExtrasProvider | None) -> MessageRef | None:
full, options = _apply(text, extras(target.backend) if extras else None)
async def _send_one(self, target: Target, text: str, extras: ExtrasProvider | None,
silent: bool) -> MessageRef | None:
full, options = _apply(text, extras(target.backend) if extras else None, silent)
try:
return await self._call(target.backend, lambda adapter: adapter.send(target.target, full, **options))
except Exception as exc:
log.warning("Senden über %s fehlgeschlagen: %s", target.backend, _brief(exc))
return None
async def _edit_one(self, ref: MessageRef, text: str, extras: ExtrasProvider | None) -> None:
full, options = _apply(text, extras(ref.backend) if extras else None)
async def _edit_one(self, ref: MessageRef, text: str, extras: ExtrasProvider | None, silent: bool) -> None:
full, options = _apply(text, extras(ref.backend) if extras else None, silent)
try:
await self._call(ref.backend, lambda adapter: adapter.edit(ref, full, **options))
except Exception as exc:
@@ -58,11 +60,13 @@ class Deliverer:
return await asyncio.wait_for(action(fresh), self._timeout)
def _apply(text: str, extra: Extras | None) -> tuple[str, dict]:
def _apply(text: str, extra: Extras | None, silent: bool = False) -> tuple[str, dict]:
options: dict = {"silent": True} if silent else {}
if extra is None:
return text, {}
full = f"{text}\n{extra.hint}" if extra.hint else text
return full, ({"buttons": extra.buttons} if extra.buttons else {})
return text, options
if extra.buttons:
options["buttons"] = extra.buttons
return (f"{text}\n{extra.hint}" if extra.hint else text), options
def _brief(exc: Exception) -> str:
+62
View File
@@ -0,0 +1,62 @@
from datetime import datetime
from ..models import KIND_PROBLEM, Event, state_name
MINUTE = 60
HOUR = 3600
DAY = 86400
CLOCK_FORMAT = "%H:%M"
DATE_CLOCK_FORMAT = "%d.%m. %H:%M"
ARROW = "→"
LESS_THAN_MINUTE = "unter 1 min"
def format_duration(seconds: int) -> str:
seconds = max(int(seconds), 0)
if seconds < MINUTE:
return LESS_THAN_MINUTE
if seconds < HOUR:
return f"{seconds // MINUTE} min"
if seconds < DAY:
hours, minutes = seconds // HOUR, (seconds % HOUR) // MINUTE
return f"{hours} h {minutes} min" if minutes else f"{hours} h"
days, rest = divmod(seconds, DAY)
hours = rest // HOUR
return f"{days} d {hours} h" if hours else f"{days} d"
def format_clock(ts: int, now: int) -> str:
moment = datetime.fromtimestamp(ts)
same_day = moment.date() == datetime.fromtimestamp(now).date()
return moment.strftime(CLOCK_FORMAT if same_day else DATE_CLOCK_FORMAT)
def state_label(event: Event) -> str:
if event.previous_state is not None and event.previous_state != event.state and event.kind == KIND_PROBLEM:
return f"{state_name(event.previous_state)} {ARROW} {state_name(event.state)}"
return state_name(event.state)
def since_text(event: Event) -> str:
if event.since_ts is None:
return ""
text = f"seit {format_clock(event.since_ts, event.ts)}"
if event.previous_state is not None and event.previous_duration is not None:
text += f" (vorher {state_name(event.previous_state)} seit {format_duration(event.previous_duration)})"
return text
def recovery_text(event: Event) -> str:
text = "wieder OK"
if event.problem_since_ts is not None:
text += f" nach {format_duration(event.ts - event.problem_since_ts)}"
if event.previous_state is not None:
text += f" (zuletzt {state_name(event.previous_state)})"
return text
def reminder_text(event: Event) -> str:
text = f"weiter {state_name(event.state)}"
if event.since_ts is not None:
text += f" seit {format_duration(event.ts - event.since_ts)}"
return text
+9 -5
View File
@@ -3,7 +3,8 @@ from dataclasses import dataclass
from html import escape
from typing import Mapping, Sequence
from ..models import KIND_FLAPPING, KIND_RECOVERY, Event, state_name
from ..models import KIND_FLAPPING, KIND_RECOVERY, KIND_REMINDER, Event, state_name
from .delta import reminder_text, recovery_text, since_text, state_label
TELEGRAM_LIMIT = 4096
OUTPUT_MAX = 300
@@ -11,7 +12,6 @@ AI_MAX_LINES = 8
AI_LINE_MAX = 300
ELLIPSIS = "…"
AI_UNAVAILABLE = "KI nicht verfügbar"
RECOVERY_TEXT = "wieder OK"
THINK_RE = re.compile(r"<think>.*?</think>", re.DOTALL | re.IGNORECASE)
@@ -34,15 +34,19 @@ def clip_ai_text(text: str) -> str:
def _label(event: Event) -> str:
if event.kind == KIND_FLAPPING:
return f"FLAPPING ({state_name(event.state)})"
return state_name(event.state)
return state_label(event)
def _event_line(event: Event) -> str:
since = since_text(event) if event.kind not in (KIND_RECOVERY, KIND_REMINDER, KIND_FLAPPING) else ""
if event.kind == KIND_RECOVERY:
detail = RECOVERY_TEXT
detail = recovery_text(event)
elif event.kind == KIND_REMINDER:
detail = f"{reminder_text(event)}: {clip(event.output, OUTPUT_MAX)}"
else:
detail = clip(event.output, OUTPUT_MAX)
return f"<b>{escape(_label(event))}</b> {escape(event.service)}: {escape(detail)}"
head = f"<b>{escape(_label(event))}</b> {escape(event.service)}"
return f"{head}, {escape(since)}: {escape(detail)}" if since else f"{head}: {escape(detail)}"
def _analysis_block(event: Event, analysis: Analysis, single: bool) -> str:
+50
View File
@@ -0,0 +1,50 @@
import asyncio
from dataclasses import replace
from typing import Callable
from ..models import (KIND_FLAPPING, KIND_PROBLEM, KIND_RECOVERY, KIND_REMINDER, SUPPRESS_NOT_REPORTED,
SUPPRESS_RECOVERY_OFF, Event, Key)
REPORTED_KINDS = (KIND_PROBLEM, KIND_FLAPPING, KIND_REMINDER)
ReportCounts = Callable[[str, str], tuple[int, int]]
class ReportGate:
"""Merkt pro Service, ob das laufende Problem tatsächlich gesendet wurde (Grundlage für Recovery)."""
def __init__(self, counts: ReportCounts):
self._counts = counts
self._reported: dict[Key, bool] = {}
async def apply(self, event: Event, recovery_enabled: bool) -> Event:
if event.kind != KIND_RECOVERY or event.suppressed:
return event
if not recovery_enabled:
return replace(event, suppressed=True, suppress_reason=SUPPRESS_RECOVERY_OFF)
if not await self._was_reported((event.host, event.service)):
return replace(event, suppressed=True, suppress_reason=SUPPRESS_NOT_REPORTED)
return event
async def track(self, event: Event, sent: bool) -> None:
key = (event.host, event.service)
if event.kind == KIND_RECOVERY:
self._reported.pop(key, None)
elif event.kind in REPORTED_KINDS:
before = await self._sent_before(key)
self._reported[key] = before or sent
async def _sent_before(self, key: Key) -> bool:
known = self._reported.get(key)
if known is not None:
return known
_total, sent = await asyncio.to_thread(self._counts, *key)
return sent > 0
async def _was_reported(self, key: Key) -> bool:
known = self._reported.get(key)
if known is not None:
return known
total, sent = await asyncio.to_thread(self._counts, *key)
# Ohne jede Historie (z. B. durch Retention gelöscht) wird die Recovery nicht verschluckt.
return total == 0 or sent > 0
+26 -14
View File
@@ -2,18 +2,19 @@ import asyncio
import logging
import time
import uuid
from dataclasses import dataclass
from dataclasses import dataclass, replace
from typing import Callable, Protocol
from ..chat import MessageRef, Target
from ..models import AI_DONE, AI_FAILED, AI_SKIPPED, Event
from ..mutes import Mute, apply_mutes
from ..routing import Route, resolve_targets
from ..routing import Route, ai_allowed, ai_hints, resolve_loud, resolve_targets
from .analyzer import AlertAnalyzer
from .bundler import HostBundler
from .delivery import AdapterProvider, Deliverer
from .extras import AlertActions
from .formatter import Analysis, format_message
from .gate import ReportGate
from .repository import AlertRepository
log = logging.getLogger(__name__)
@@ -23,6 +24,8 @@ PRUNE_INTERVAL_SECONDS = 60.0
QUEUE_MAX = 1000
DRAIN_TIMEOUT_SECONDS = 15.0
GroupKey = tuple[str, tuple[Target, ...], bool]
class RouteSource(Protocol):
def enabled_routes(self) -> list[Route]: ...
@@ -39,13 +42,15 @@ class Dispatch:
events: list[Event]
alert_ids: list[int]
refs: list[MessageRef]
silent: bool = False
class AlertPipeline:
def __init__(self, repo: AlertRepository, adapters: AdapterProvider, analyzer: AlertAnalyzer,
bundle_seconds: Callable[[], int], clock: Callable[[], float] = time.time, *,
routes: RouteSource | None = None, mutes: MuteSource | None = None,
actions: AlertActions | None = None, queue_size: int = QUEUE_MAX):
actions: AlertActions | None = None, queue_size: int = QUEUE_MAX,
recovery_enabled: Callable[[], bool] = lambda: True):
self._repo = repo
self._adapters = adapters
self._deliverer = Deliverer(adapters)
@@ -55,6 +60,8 @@ class AlertPipeline:
self._routes = routes
self._mutes = mutes
self._actions = actions
self._recovery_enabled = recovery_enabled
self._gate = ReportGate(repo.report_counts)
self._bundler = HostBundler()
self._queue: asyncio.Queue[Event] = asyncio.Queue(maxsize=queue_size)
self._tasks: set[asyncio.Task] = set()
@@ -93,12 +100,12 @@ class AlertPipeline:
log.exception("Gebündelte Alarme konnten beim Beenden nicht gesendet werden")
async def flush_due(self) -> None:
for (_host, targets), batch in self._bundler.due(self._clock(), self._bundle_seconds()):
await self._dispatch(batch, targets)
for (_host, targets, silent), batch in self._bundler.due(self._clock(), self._bundle_seconds()):
await self._dispatch(batch, targets, silent)
async def flush_all(self) -> None:
for (_host, targets), batch in self._bundler.take_all():
await self._dispatch(batch, targets)
for (_host, targets, silent), batch in self._bundler.take_all():
await self._dispatch(batch, targets, silent)
async def wait_idle(self) -> None:
while self._tasks:
@@ -156,12 +163,17 @@ class AlertPipeline:
mutes = await asyncio.to_thread(self._mutes.active, now) if self._mutes else []
routes = await asyncio.to_thread(self._routes.enabled_routes) if self._routes else []
defaults = [Target(a.name, a.default_target) for a in await self._adapters()]
groups: dict[tuple[str, tuple[Target, ...]], list[Event]] = {}
recovery_enabled = self._recovery_enabled()
groups: dict[GroupKey, list[Event]] = {}
stored_only: list[Event] = []
for event in apply_mutes(events, mutes, now):
event = await self._gate.apply(event, recovery_enabled)
event = replace(event, ai_enabled=ai_allowed(routes, event), ai_hint=ai_hints(routes, event))
targets = () if event.suppressed else tuple(sorted(resolve_targets(routes, event, defaults)))
await self._gate.track(event, bool(targets))
if targets:
groups.setdefault((event.host, targets), []).append(event)
key = (event.host, targets, not resolve_loud(routes, event))
groups.setdefault(key, []).append(event)
else:
stored_only.append(event)
if stored_only:
@@ -170,7 +182,7 @@ class AlertPipeline:
for key, group in groups.items():
batch = self._bundler.submit(key, group, now, window)
if batch:
await self._dispatch(batch, key[1])
await self._dispatch(batch, key[1], key[2])
async def _store_only(self, events: list[Event]) -> None:
ids = await asyncio.to_thread(self._repo.insert_alerts, events, uuid.uuid4().hex)
@@ -181,14 +193,14 @@ class AlertPipeline:
if event.wants_ai:
await asyncio.to_thread(self._repo.set_ai, alert_id, AI_SKIPPED, None)
async def _dispatch(self, batch: list[Event], targets: tuple[Target, ...]) -> None:
async def _dispatch(self, batch: list[Event], targets: tuple[Target, ...], silent: bool = False) -> None:
ids = await asyncio.to_thread(self._repo.insert_alerts, batch, uuid.uuid4().hex)
refs = await self._deliverer.send(format_message(batch), targets, self._extras(batch, ids))
refs = await self._deliverer.send(format_message(batch), targets, self._extras(batch, ids), silent)
for ref in refs:
for alert_id in ids:
await asyncio.to_thread(self._repo.add_delivery, alert_id, ref)
if refs and any(e.wants_ai for e in batch):
task = asyncio.create_task(self._analyze(Dispatch(batch, ids, refs)))
task = asyncio.create_task(self._analyze(Dispatch(batch, ids, refs, silent)))
self._tasks.add(task)
task.add_done_callback(self._tasks.discard)
else:
@@ -213,4 +225,4 @@ class AlertPipeline:
else:
continue
await self._deliverer.edit(dispatch.refs, format_message(dispatch.events, analyses),
self._extras(dispatch.events, dispatch.alert_ids))
self._extras(dispatch.events, dispatch.alert_ids), dispatch.silent)
+12 -3
View File
@@ -9,6 +9,8 @@ DATA_OPEN = "<daten>"
DATA_CLOSE = "</daten>"
OUTPUT_MAX = 1000
LABEL_MAX = 200
HINT_MAX = 1200
HINT_TITLE = "Zusatzkontext des Administrators (Hintergrundwissen zu diesem Alarmtyp, keine Messdaten):"
HISTORY_TS_FORMAT = "%d.%m. %H:%M"
DEFAULT_ALERT_SYSTEM_PROMPT = (
@@ -48,9 +50,15 @@ def _history_line(changes: Sequence[StateChange]) -> str:
for c in changes)
def build_user_prompt(event: Event, history: Sequence[StateChange]) -> str:
def _hint_section(hint: str) -> str:
cleaned = sanitize(hint, HINT_MAX, True)
return f"{HINT_TITLE}\n{cleaned}\n\n" if cleaned else ""
def build_user_prompt(event: Event, history: Sequence[StateChange], hint: str = "") -> str:
in_downtime = "ja" if event.suppress_reason else "nein"
return (
f"{_hint_section(hint)}"
f"{DATA_OPEN}\n"
f"Host: {sanitize(event.host, LABEL_MAX, True)}\n"
f"Service: {sanitize(event.service, LABEL_MAX, True)}\n"
@@ -63,8 +71,9 @@ def build_user_prompt(event: Event, history: Sequence[StateChange]) -> str:
)
def build_messages(editable: str | None, event: Event, history: Sequence[StateChange]) -> list[dict]:
def build_messages(editable: str | None, event: Event, history: Sequence[StateChange],
hint: str = "") -> list[dict]:
return [
{"role": "system", "content": build_system_prompt(editable)},
{"role": "user", "content": build_user_prompt(event, history)},
{"role": "user", "content": build_user_prompt(event, history, hint)},
]
+35
View File
@@ -0,0 +1,35 @@
import math
from dataclasses import dataclass
from typing import Mapping, Sequence
from ..models import STATE_OK
from ..mutes import Mute, find_active_mute
from ..routing import Route, resolve_delay
SECONDS_PER_MINUTE = 60
STATUS_REPORTED = "reported"
STATUS_WAITING = "waiting"
STATUS_SUPPRESSED = "suppressed"
STATUS_DUE = "due"
@dataclass(frozen=True)
class ReportStatus:
kind: str
label: str
def report_status(row: Mapping, rules: Sequence[Route], mutes: Sequence[Mute], default_minutes: int,
now: float) -> ReportStatus:
if row["in_downtime"]:
return ReportStatus(STATUS_SUPPRESSED, "unterdrückt (Downtime)")
if find_active_mute(mutes, row["host"], row["service"], now):
return ReportStatus(STATUS_SUPPRESSED, "unterdrückt (Stummschaltung)")
if row["flap_reported"] or (row["reported_state"] != STATE_OK and row["reported_state"] == row["state"]):
return ReportStatus(STATUS_REPORTED, "gemeldet")
delay = resolve_delay(rules, row["host"], row["service"], row["state"], row["output"], default_minutes)
remaining = delay * SECONDS_PER_MINUTE - (now - row["last_state_change"])
if remaining <= 0:
return ReportStatus(STATUS_DUE, "wird beim nächsten Abruf gemeldet")
return ReportStatus(STATUS_WAITING, f"wartet noch {math.ceil(remaining / SECONDS_PER_MINUTE)} min")
+13 -2
View File
@@ -1,6 +1,7 @@
from ..chat import MessageRef
from ..db import Database
from ..models import AI_PENDING, AI_SKIPPED, STATE_OK, Event
from ..models import (AI_PENDING, AI_SKIPPED, KIND_FLAPPING, KIND_PROBLEM, KIND_RECOVERY, KIND_REMINDER, STATE_OK,
Event)
RECENT_LIMIT = 200
DAY_SECONDS = 86400
@@ -45,6 +46,15 @@ class AlertRepository:
rows = self._db.query("SELECT host, service FROM alerts WHERE id=?", (alert_id,))
return (rows[0]["host"], rows[0]["service"]) if rows else None
def report_counts(self, host: str, service: str) -> tuple[int, int]:
rows = self._db.query(
"SELECT COUNT(*) AS total, COALESCE(SUM(CASE WHEN suppressed=0 THEN 1 ELSE 0 END), 0) AS sent "
"FROM alerts WHERE host=? AND service=? AND kind IN (?,?,?) AND id > "
"COALESCE((SELECT MAX(id) FROM alerts WHERE host=? AND service=? AND kind=?), 0)",
(host, service, KIND_PROBLEM, KIND_FLAPPING, KIND_REMINDER, host, service, KIND_RECOVERY),
)
return rows[0]["total"], rows[0]["sent"]
def recent(self, limit: int = RECENT_LIMIT) -> list[dict]:
rows = self._db.query("SELECT * FROM alerts ORDER BY ts DESC, id DESC LIMIT ?", (limit,))
return [dict(row) for row in rows]
@@ -58,7 +68,8 @@ class AlertRepository:
def open_problems(self) -> list[dict]:
rows = self._db.query(
"SELECT host, service, state, output, last_state_change, in_downtime, flapping "
"SELECT host, service, state, output, last_state_change, in_downtime, flapping, reported_state, "
"flap_reported "
"FROM service_states WHERE state!=? ORDER BY state DESC, host, service", (STATE_OK,),
)
return [dict(row) for row in rows]
+2 -2
View File
@@ -52,9 +52,9 @@ class ChatAdapter(Protocol):
name: str
default_target: str
async def send(self, target: str, text: str, buttons: Buttons = ()) -> MessageRef: ...
async def send(self, target: str, text: str, buttons: Buttons = (), silent: bool = False) -> MessageRef: ...
async def edit(self, ref: MessageRef, text: str, buttons: Buttons = ()) -> None: ...
async def edit(self, ref: MessageRef, text: str, buttons: Buttons = (), silent: bool = False) -> None: ...
async def check(self) -> str: ...
+8 -7
View File
@@ -11,6 +11,7 @@ BACKEND_MATRIX = "matrix"
API_PREFIX = "/_matrix/client/v3"
HTML_FORMAT = "org.matrix.custom.html"
MSGTYPE_TEXT = "m.text"
MSGTYPE_NOTICE = "m.notice"
REL_REPLACE = "m.replace"
EDIT_PREFIX = "* "
DEFAULT_TIMEOUT = 15.0
@@ -28,9 +29,9 @@ def _enc(value: str) -> str:
return quote(value, safe="")
def _content(text: str) -> dict:
def _content(text: str, silent: bool = False) -> dict:
body, formatted = to_matrix(text)
return {"msgtype": MSGTYPE_TEXT, "body": body, "format": HTML_FORMAT, "formatted_body": formatted}
return {"msgtype": MSGTYPE_NOTICE if silent else MSGTYPE_TEXT, "body": body, "format": HTML_FORMAT, "formatted_body": formatted}
class MatrixAdapter:
@@ -78,13 +79,13 @@ class MatrixAdapter:
raise MatrixError("Antwort ohne event_id")
return str(event_id)
async def send(self, target: str, text: str, buttons: Buttons = ()) -> MessageRef:
return MessageRef(self.name, target, await self._put_message(target, _content(text)))
async def send(self, target: str, text: str, buttons: Buttons = (), silent: bool = False) -> MessageRef:
return MessageRef(self.name, target, await self._put_message(target, _content(text, silent)))
async def edit(self, ref: MessageRef, text: str, buttons: Buttons = ()) -> None:
new_content = _content(text)
async def edit(self, ref: MessageRef, text: str, buttons: Buttons = (), silent: bool = False) -> None:
new_content = _content(text, silent)
content = {
"msgtype": MSGTYPE_TEXT,
"msgtype": new_content["msgtype"],
"body": EDIT_PREFIX + new_content["body"],
"format": HTML_FORMAT,
"formatted_body": EDIT_PREFIX + new_content["formatted_body"],
+4 -2
View File
@@ -59,8 +59,10 @@ class TelegramAdapter:
raise TelegramError("Telegram nicht erreichbar (Rate-Limit)")
async def send(self, target: str, text: str, buttons: Buttons = (), reply_to: str | None = None,
html: bool = True) -> MessageRef:
html: bool = True, silent: bool = False) -> MessageRef:
payload: dict = {"chat_id": target, "text": text, "disable_web_page_preview": True}
if silent:
payload["disable_notification"] = True
if html:
payload["parse_mode"] = PARSE_MODE
if buttons:
@@ -70,7 +72,7 @@ class TelegramAdapter:
result = await self._call("sendMessage", payload)
return MessageRef(self.name, str(target), str(result["message_id"]))
async def edit(self, ref: MessageRef, text: str, buttons: Buttons = ()) -> None:
async def edit(self, ref: MessageRef, text: str, buttons: Buttons = (), silent: bool = False) -> None:
payload: dict = {"chat_id": ref.target, "message_id": int(ref.message_id), "text": text,
"parse_mode": PARSE_MODE, "disable_web_page_preview": True}
if buttons:
+12 -3
View File
@@ -9,6 +9,10 @@ API_PATH = "/check_mk/api/1.0"
SERVICES_PATH = "/domain-types/service/collections/all"
VERSION_PATH = "/version"
DOWNTIMES_PATH = "/domain-types/downtime/collections/all"
DOWNTIME_TYPE_PARAM = "downtime_type"
DOWNTIME_TYPE_HOST = "host"
DOWNTIME_TYPE_SERVICE = "service"
DOWNTIME_TYPES = (DOWNTIME_TYPE_HOST, DOWNTIME_TYPE_SERVICE)
DEFAULT_TIMEOUT = 15.0
STATE_OK = 0
@@ -148,9 +152,14 @@ class CheckmkClient:
return await self._services(_equals("host_name", host))
async def downtimes(self, host: str | None = None) -> list[Downtime]:
params = {"host_name": host} if host else None
body = await self._request("GET", DOWNTIMES_PATH, params=params)
return [_parse_downtime(item["extensions"]) for item in body.get("value", [])]
downtimes: list[Downtime] = []
for downtime_type in DOWNTIME_TYPES:
body = await self._request("GET", DOWNTIMES_PATH, params={DOWNTIME_TYPE_PARAM: downtime_type})
downtimes += [_parse_downtime(item["extensions"]) for item in body.get("value", [])]
if host:
wanted = host.casefold()
downtimes = [d for d in downtimes if d.host.casefold() == wanted]
return downtimes
async def aclose(self) -> None:
await self._client.aclose()
+7 -2
View File
@@ -39,16 +39,21 @@ ADAPTER_SETTING_KEYS = (
)
POLL_INTERVAL = "poll.interval"
POLL_MIN_DURATION = "poll.min_duration"
ALERTS_BUNDLE_SECONDS = "alerts.bundle_seconds"
ALERTS_AI_PER_MINUTE = "alerts.ai_per_minute"
ALERTS_DEFAULT_DELAY_MINUTES = "alerts.default_delay_minutes"
ALERTS_RECOVERY_ENABLED = "alerts.recovery_enabled"
ALERTS_REMINDER_HOURS = "alerts.reminder_hours"
PROMPT_ALERT_SYSTEM = "prompt.alert_system"
DEFAULT_POLL_INTERVAL = 30
MIN_POLL_INTERVAL = 5
DEFAULT_POLL_MIN_DURATION = 0
DEFAULT_BUNDLE_SECONDS = 60
DEFAULT_AI_PER_MINUTE = 10
DEFAULT_DELAY_MINUTES = 15
MAX_DELAY_MINUTES = 1440
DEFAULT_REMINDER_HOURS = 0
MAX_REMINDER_HOURS = 168
DEFAULT_TELEGRAM_API_BASE = "https://api.telegram.org"
CHECKMK_DT_ENABLED = "checkmk_dt.enabled"
+19
View File
@@ -147,6 +147,25 @@ MIGRATIONS = [
);
CREATE INDEX state_log_ts ON state_log(ts);
""",
"""
ALTER TABLE service_states ADD COLUMN problem_since INTEGER;
ALTER TABLE service_states ADD COLUMN reported_at INTEGER NOT NULL DEFAULT 0;
ALTER TABLE service_states ADD COLUMN reported_since INTEGER NOT NULL DEFAULT 0;
ALTER TABLE service_states ADD COLUMN last_reminder_ts INTEGER NOT NULL DEFAULT 0;
UPDATE service_states SET reported_at = CAST(strftime('%s', 'now') AS INTEGER),
reported_since = last_state_change WHERE reported_state != 0;
ALTER TABLE routes ADD COLUMN delay_minutes INTEGER;
ALTER TABLE routes ADD COLUMN output_contains TEXT NOT NULL DEFAULT '';
ALTER TABLE routes ADD COLUMN ai_enabled INTEGER NOT NULL DEFAULT 1;
ALTER TABLE routes ADD COLUMN ai_hint TEXT NOT NULL DEFAULT '';
ALTER TABLE routes ADD COLUMN reminder_hours INTEGER;
ALTER TABLE routes ADD COLUMN loud INTEGER NOT NULL DEFAULT 1;
ALTER TABLE routes ADD COLUMN use_default_targets INTEGER NOT NULL DEFAULT 0;
CREATE TABLE app_state (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
""",
]
+15 -1
View File
@@ -3,12 +3,16 @@ from dataclasses import dataclass
KIND_PROBLEM = "problem"
KIND_RECOVERY = "recovery"
KIND_FLAPPING = "flapping"
KIND_REMINDER = "reminder"
STATE_OK = 0
STATE_CRIT = 2
STATE_NAMES = {0: "OK", 1: "WARN", 2: "CRIT", 3: "UNKNOWN"}
SUPPRESS_DOWNTIME = "downtime"
SUPPRESS_MUTE = "mute"
SUPPRESS_RECOVERY_OFF = "recovery_aus"
SUPPRESS_NOT_REPORTED = "nicht_gemeldet"
AI_PENDING = "pending"
AI_DONE = "done"
@@ -32,10 +36,16 @@ class Event:
output: str = ""
suppressed: bool = False
suppress_reason: str | None = None
previous_state: int | None = None
previous_duration: int | None = None
since_ts: int | None = None
problem_since_ts: int | None = None
ai_enabled: bool = True
ai_hint: str = ""
@property
def wants_ai(self) -> bool:
return self.kind == KIND_PROBLEM and not self.suppressed
return self.kind == KIND_PROBLEM and not self.suppressed and self.ai_enabled
@dataclass(frozen=True)
@@ -49,6 +59,10 @@ class StateRow:
flapping: bool
reported_state: int
flap_reported: bool
problem_since: int | None = None
reported_at: int = 0
reported_since: int = 0
last_reminder_ts: int = 0
@property
def key(self) -> Key:
+50 -25
View File
@@ -1,38 +1,60 @@
from typing import Mapping, Sequence
from dataclasses import replace
from typing import Callable, Mapping, Sequence
from ..checkmk import ServiceState
from ..models import (KIND_FLAPPING, KIND_PROBLEM, KIND_RECOVERY, STATE_OK, SUPPRESS_DOWNTIME, Event,
Key, StateRow)
from ..models import (KIND_FLAPPING, KIND_PROBLEM, KIND_RECOVERY, STATE_OK, SUPPRESS_DOWNTIME, Event, Key,
StateRow)
FLAP_QUIET_SECONDS = 600
def _event(now: int, kind: str, host: str, service: str, state: int, output: str,
in_downtime: bool) -> Event:
return Event(ts=now, host=host, service=service, kind=kind, state=state, output=output,
suppressed=in_downtime, suppress_reason=SUPPRESS_DOWNTIME if in_downtime else None)
DelayProvider = Callable[[str, str, int, str], int]
def _row(cur: ServiceState, reported_state: int, flap_reported: bool) -> StateRow:
return StateRow(cur.host, cur.service, cur.state, cur.output, cur.last_state_change,
cur.in_downtime, cur.flapping, reported_state, flap_reported)
def no_delay(host: str, service: str, state: int, output: str) -> int:
return 0
def _problem_since(cur: ServiceState, prev: StateRow | None, now: int) -> int:
if prev is not None and prev.state != STATE_OK:
return prev.problem_since or prev.last_state_change
return cur.last_state_change or now
def _row(cur: ServiceState, prev: StateRow | None, now: int, reported_state: int, flap_reported: bool,
reported: tuple[int, int] | None = None) -> StateRow:
reported_at, reported_since = reported or ((prev.reported_at, prev.reported_since) if prev else (0, 0))
return StateRow(cur.host, cur.service, cur.state, cur.output, cur.last_state_change, cur.in_downtime,
cur.flapping, reported_state, flap_reported, _problem_since(cur, prev, now),
reported_at, reported_since, prev.last_reminder_ts if prev else 0)
def _event(now: int, kind: str, cur: ServiceState, row: StateRow, **extra) -> Event:
return Event(ts=now, host=cur.host, service=cur.service, kind=kind, state=cur.state, output=cur.output,
suppressed=cur.in_downtime, suppress_reason=SUPPRESS_DOWNTIME if cur.in_downtime else None,
since_ts=cur.last_state_change, problem_since_ts=row.problem_since, **extra)
def _problem_event(now: int, cur: ServiceState, prev: StateRow | None, row: StateRow) -> Event:
if prev is None or prev.reported_state == STATE_OK:
return _event(now, KIND_PROBLEM, cur, row)
duration = max(cur.last_state_change - prev.reported_since, 0) if prev.reported_since else None
return _event(now, KIND_PROBLEM, cur, row, previous_state=prev.reported_state, previous_duration=duration)
def _handle_current(cur: ServiceState, prev: StateRow | None, now: int,
min_duration: int) -> tuple[StateRow | None, Event | None]:
delay_seconds: DelayProvider) -> tuple[StateRow | None, Event | None]:
if not cur.hard:
return prev, None
reported = prev.reported_state if prev else STATE_OK
flap_reported = prev.flap_reported if prev else False
if cur.flapping:
if flap_reported:
return _row(cur, reported, True), None
event = _event(now, KIND_FLAPPING, cur.host, cur.service, cur.state, cur.output, cur.in_downtime)
return _row(cur, reported, True), event
if now - cur.last_state_change < min_duration or cur.state == reported:
return _row(cur, reported, False), None
event = _event(now, KIND_PROBLEM, cur.host, cur.service, cur.state, cur.output, cur.in_downtime)
return _row(cur, cur.state, False), event
row = _row(cur, prev, now, reported, True)
return row, (None if flap_reported else _event(now, KIND_FLAPPING, cur, row))
delay = delay_seconds(cur.host, cur.service, cur.state, cur.output)
if now - cur.last_state_change < delay or cur.state == reported:
return _row(cur, prev, now, reported, False), None
row = _row(cur, prev, now, cur.state, False, (now, cur.last_state_change))
return row, _problem_event(now, cur, prev, row)
def _handle_gone(prev: StateRow, now: int) -> tuple[StateRow | None, Event | None]:
@@ -41,24 +63,27 @@ def _handle_gone(prev: StateRow, now: int) -> tuple[StateRow | None, Event | Non
return None, None
if prev.state == STATE_OK:
return prev, None
held = StateRow(prev.host, prev.service, STATE_OK, "", now, False, True,
prev.reported_state, True)
held = replace(prev, state=STATE_OK, output="", last_state_change=now, in_downtime=False,
flapping=True, flap_reported=True)
return held, None
if prev.reported_state == STATE_OK:
return None, None
event = _event(now, KIND_RECOVERY, prev.host, prev.service, STATE_OK, "", prev.in_downtime)
event = Event(ts=now, host=prev.host, service=prev.service, kind=KIND_RECOVERY, state=STATE_OK,
suppressed=prev.in_downtime, suppress_reason=SUPPRESS_DOWNTIME if prev.in_downtime else None,
previous_state=prev.reported_state,
problem_since_ts=prev.problem_since or prev.last_state_change)
return None, event
def compute_events(previous: Mapping[Key, StateRow], current: Sequence[ServiceState], now: int,
min_duration: int = 0) -> tuple[list[Event], list[StateRow]]:
delay_seconds: DelayProvider = no_delay) -> tuple[list[Event], list[StateRow]]:
events: list[Event] = []
rows: dict[Key, StateRow] = {}
seen: set[Key] = set()
for cur in current:
key = (cur.host, cur.service)
seen.add(key)
row, event = _handle_current(cur, previous.get(key), now, min_duration)
row, event = _handle_current(cur, previous.get(key), now, delay_seconds)
if row is not None:
rows[key] = row
if event is not None:
+7 -2
View File
@@ -5,6 +5,7 @@ from typing import Awaitable, Callable
from ..checkmk import CheckmkClient
from ..models import Event
from ..routing import Route, delay_provider
from .events import compute_events
from .options import PollOptions, read_poll_options
from .state import StateStore
@@ -15,18 +16,20 @@ log = logging.getLogger(__name__)
ERROR_MAX = 200
EventSink = Callable[[list[Event]], Awaitable[None]]
RuleSource = Callable[[], list[Route]]
class Poller:
def __init__(self, store, state: StateStore, sink: EventSink, status: PollerStatus,
client_factory: Callable[[], CheckmkClient | None] | None = None,
clock: Callable[[], float] = time.time):
clock: Callable[[], float] = time.time, rules: RuleSource | None = None):
self._store = store
self._state = state
self._sink = sink
self.status = status
self._client_factory = client_factory or (lambda: CheckmkClient.from_settings(store))
self._clock = clock
self._rules = rules or (lambda: [])
async def run(self) -> None:
while True:
@@ -55,7 +58,9 @@ class Poller:
await client.aclose()
now = int(self._clock())
previous = await asyncio.to_thread(self._state.load)
events, rows = compute_events(previous, current, now, options.min_duration)
rules = await asyncio.to_thread(self._rules)
delay = delay_provider(rules, options.default_delay_minutes)
events, rows = compute_events(previous, current, now, delay)
await asyncio.to_thread(self._state.apply, previous, rows, now)
self.status.last_success = self._clock()
self.status.last_error = None
+8 -4
View File
@@ -1,17 +1,21 @@
from dataclasses import dataclass
from ..constants import (DEFAULT_POLL_INTERVAL, DEFAULT_POLL_MIN_DURATION, MIN_POLL_INTERVAL,
POLL_INTERVAL, POLL_MIN_DURATION)
from ..constants import (ALERTS_DEFAULT_DELAY_MINUTES, DEFAULT_DELAY_MINUTES, DEFAULT_POLL_INTERVAL,
MAX_DELAY_MINUTES, MIN_POLL_INTERVAL, POLL_INTERVAL)
@dataclass(frozen=True)
class PollOptions:
interval: int
min_duration: int
default_delay_minutes: int
def read_default_delay(store) -> int:
return min(store.get_int(ALERTS_DEFAULT_DELAY_MINUTES, DEFAULT_DELAY_MINUTES), MAX_DELAY_MINUTES)
def read_poll_options(store) -> PollOptions:
return PollOptions(
interval=store.get_int(POLL_INTERVAL, DEFAULT_POLL_INTERVAL, MIN_POLL_INTERVAL),
min_duration=store.get_int(POLL_MIN_DURATION, DEFAULT_POLL_MIN_DURATION),
default_delay_minutes=read_default_delay(store),
)
+13 -4
View File
@@ -5,13 +5,16 @@ from ..models import STATE_OK, Key, StateRow
RECENT_CHANGES_DEFAULT = 5
# last_reminder_ts bleibt beim Update unangetastet, damit der Erinnerungs-Worker nicht überschrieben wird.
UPSERT_SQL = (
"INSERT INTO service_states(host, service, state, output, last_state_change, in_downtime, "
"flapping, reported_state, flap_reported) VALUES (?,?,?,?,?,?,?,?,?) "
"flapping, reported_state, flap_reported, problem_since, reported_at, reported_since, "
"last_reminder_ts) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) "
"ON CONFLICT(host, service) DO UPDATE SET state=excluded.state, output=excluded.output, "
"last_state_change=excluded.last_state_change, in_downtime=excluded.in_downtime, "
"flapping=excluded.flapping, reported_state=excluded.reported_state, "
"flap_reported=excluded.flap_reported"
"flap_reported=excluded.flap_reported, problem_since=excluded.problem_since, "
"reported_at=excluded.reported_at, reported_since=excluded.reported_since"
)
LOG_SQL = "INSERT INTO state_log(host, service, state, ts) VALUES (?,?,?,?)"
@@ -25,7 +28,8 @@ class StateChange:
def _from_db(row) -> StateRow:
return StateRow(row["host"], row["service"], row["state"], row["output"], row["last_state_change"],
bool(row["in_downtime"]), bool(row["flapping"]), row["reported_state"],
bool(row["flap_reported"]))
bool(row["flap_reported"]), row["problem_since"], row["reported_at"],
row["reported_since"], row["last_reminder_ts"])
class StateStore:
@@ -53,10 +57,15 @@ class StateStore:
statements.append((LOG_SQL, (row.host, row.service, row.state, row.last_state_change or now)))
statements.append((UPSERT_SQL, (row.host, row.service, row.state, row.output,
row.last_state_change, int(row.in_downtime), int(row.flapping),
row.reported_state, int(row.flap_reported))))
row.reported_state, int(row.flap_reported), row.problem_since,
row.reported_at, row.reported_since, row.last_reminder_ts)))
if statements:
self._db.execute_many(statements)
def mark_reminded(self, host: str, service: str, ts: int) -> None:
self._db.execute("UPDATE service_states SET last_reminder_ts=? WHERE host=? AND service=?",
(ts, host, service))
def recent_changes(self, host: str, service: str, limit: int = RECENT_CHANGES_DEFAULT) -> list[StateChange]:
rows = self._db.query(
"SELECT ts, state FROM state_log WHERE host=? AND service=? ORDER BY ts DESC, id DESC LIMIT ?",
+4
View File
@@ -0,0 +1,4 @@
from .due import due_reminders
from .worker import ReminderWorker
__all__ = ["ReminderWorker", "due_reminders"]
+25
View File
@@ -0,0 +1,25 @@
from typing import Iterable, Sequence
from ..models import KIND_PROBLEM, KIND_REMINDER, STATE_CRIT, Event, StateRow
from ..mutes import Mute, find_active_mute
from ..routing import Route, reminder_interval
SECONDS_PER_HOUR = 3600
def due_reminders(rows: Iterable[StateRow], rules: Sequence[Route], mutes: Sequence[Mute],
default_hours: int, now: int) -> list[Event]:
events = []
for row in rows:
if row.state != STATE_CRIT or row.reported_state != STATE_CRIT or row.in_downtime or row.flapping:
continue
if find_active_mute(mutes, row.host, row.service, now):
continue
probe = Event(now, row.host, row.service, KIND_PROBLEM, row.state, row.output)
hours = reminder_interval(rules, probe) or default_hours
if hours <= 0 or now - max(row.last_reminder_ts, row.reported_at) < hours * SECONDS_PER_HOUR:
continue
events.append(Event(now, row.host, row.service, KIND_REMINDER, row.state, row.output,
since_ts=row.last_state_change, problem_since_ts=row.problem_since))
return events
+53
View File
@@ -0,0 +1,53 @@
import asyncio
import logging
import time
from typing import Awaitable, Callable
from ..models import Event, Key, StateRow
from ..mutes import Mute
from ..routing import Route
from .due import due_reminders
log = logging.getLogger(__name__)
CHECK_INTERVAL_SECONDS = 60.0
class ReminderWorker:
def __init__(self, load_state: Callable[[], dict[Key, StateRow]], rules: Callable[[], list[Route]],
mutes: Callable[[float], list[Mute]], default_hours: Callable[[], int],
sink: Callable[[list[Event]], Awaitable[None]], mark: Callable[[str, str, int], None],
clock: Callable[[], float] = time.time, interval: float = CHECK_INTERVAL_SECONDS,
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep):
self._load = load_state
self._rules = rules
self._mutes = mutes
self._default_hours = default_hours
self._sink = sink
self._mark = mark
self._clock = clock
self._interval = interval
self._sleep = sleep
async def tick(self) -> int:
now = int(self._clock())
rows = await asyncio.to_thread(self._load)
rules = await asyncio.to_thread(self._rules)
mutes = await asyncio.to_thread(self._mutes, now)
events = due_reminders(rows.values(), rules, mutes, self._default_hours(), now)
for event in events:
# Zeitpunkt zuerst festhalten: lieber eine Erinnerung zu wenig als eine doppelte nach Neustart.
await asyncio.to_thread(self._mark, event.host, event.service, now)
if events:
await self._sink(events)
return len(events)
async def run(self) -> None:
while True:
try:
await self.tick()
except asyncio.CancelledError:
raise
except Exception:
log.exception("Erinnerungen: Prüfung fehlgeschlagen")
await self._sleep(self._interval)
+7 -2
View File
@@ -1,9 +1,14 @@
from .decisions import ai_allowed, ai_hints, delay_provider, reminder_interval, resolve_delay, resolve_loud
from .limits import DEFAULT_MIN_STATE
from .repository import RouteRepository
from .rules import ALL_KINDS, DEFAULT_MIN_STATE, Route, resolve_targets, route_matches
from .rules import ALL_KINDS, Route, matches, matching_rules, resolve_targets, route_matches
from .seed import ZPOOL_MARKER, seed_default_rules, zpool_rule
from .service import DIRECTION_DOWN, DIRECTION_UP, RouteError, RouteService
from .validation import BACKENDS, RouteInput, validate_route
__all__ = [
"ALL_KINDS", "BACKENDS", "DEFAULT_MIN_STATE", "DIRECTION_DOWN", "DIRECTION_UP", "Route", "RouteError",
"RouteInput", "RouteRepository", "RouteService", "resolve_targets", "route_matches", "validate_route",
"RouteInput", "RouteRepository", "RouteService", "ZPOOL_MARKER", "ai_allowed", "ai_hints",
"delay_provider", "matches", "matching_rules", "reminder_interval", "resolve_delay", "resolve_loud",
"resolve_targets", "route_matches", "seed_default_rules", "validate_route", "zpool_rule",
]
+43
View File
@@ -0,0 +1,43 @@
from typing import Callable, Sequence
from ..models import KIND_PROBLEM, Event
from .limits import AI_HINTS_TOTAL_MAX
from .rules import Route, matching_rules
SECONDS_PER_MINUTE = 60
def _problem_probe(host: str, service: str, state: int, output: str) -> Event:
return Event(0, host, service, KIND_PROBLEM, state, output)
def resolve_delay(rules: Sequence[Route], host: str, service: str, state: int, output: str,
default_minutes: int) -> int:
found = matching_rules(rules, _problem_probe(host, service, state, output))
delays = [r.delay_minutes for r in found if r.delay_minutes is not None]
return min(delays) if delays else default_minutes
def delay_provider(rules: Sequence[Route], default_minutes: int) -> Callable[[str, str, int, str], int]:
def seconds(host: str, service: str, state: int, output: str) -> int:
return resolve_delay(rules, host, service, state, output, default_minutes) * SECONDS_PER_MINUTE
return seconds
def ai_allowed(rules: Sequence[Route], event: Event) -> bool:
return all(rule.ai_enabled for rule in matching_rules(rules, event))
def resolve_loud(rules: Sequence[Route], event: Event) -> bool:
found = matching_rules(rules, event)
return not found or any(rule.loud for rule in found)
def ai_hints(rules: Sequence[Route], event: Event) -> str:
hints = [rule.ai_hint.strip() for rule in matching_rules(rules, event) if rule.ai_hint.strip()]
return "\n".join(hints)[:AI_HINTS_TOTAL_MAX]
def reminder_interval(rules: Sequence[Route], event: Event) -> int:
hours = [r.reminder_hours for r in matching_rules(rules, event) if r.reminder_hours and r.reminder_hours > 0]
return min(hours) if hours else 0
+9
View File
@@ -0,0 +1,9 @@
NAME_MAX = 80
TARGET_MAX = 200
OUTPUT_CONTAINS_MAX = 200
AI_HINT_MAX = 800
AI_HINTS_TOTAL_MAX = 1200
DELAY_MINUTES_RANGE = (0, 1440)
REMINDER_HOURS_RANGE = (0, 168)
MIN_STATE_RANGE = (0, 3)
DEFAULT_MIN_STATE = 1
+25 -12
View File
@@ -6,21 +6,41 @@ from .rules import Route
from .validation import RouteInput
COLUMNS = ("id, name, position, enabled, match_host, match_service, min_state, kinds, stop_on_match, "
"targets")
"targets, delay_minutes, output_contains, ai_enabled, ai_hint, reminder_hours, loud, "
"use_default_targets")
KINDS_SEP = ","
INSERT_SQL = (
"INSERT INTO routes(name, enabled, match_host, match_service, min_state, kinds, stop_on_match, "
"targets, delay_minutes, output_contains, ai_enabled, ai_hint, reminder_hours, loud, "
"use_default_targets, position) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?, "
"(SELECT COALESCE(MAX(position), 0) + 1 FROM routes))"
)
UPDATE_SQL = (
"UPDATE routes SET name=?, enabled=?, match_host=?, match_service=?, min_state=?, kinds=?, "
"stop_on_match=?, targets=?, delay_minutes=?, output_contains=?, ai_enabled=?, ai_hint=?, "
"reminder_hours=?, loud=?, use_default_targets=? WHERE id=?"
)
def _route(row) -> Route:
targets = tuple(Target(t["backend"], t["target"]) for t in json.loads(row["targets"]))
kinds = tuple(k for k in row["kinds"].split(KINDS_SEP) if k)
return Route(row["id"], row["name"], row["position"], bool(row["enabled"]), row["match_host"],
row["match_service"], row["min_state"], kinds, bool(row["stop_on_match"]), targets)
row["match_service"], row["min_state"], kinds, bool(row["stop_on_match"]), targets,
row["delay_minutes"], row["output_contains"], bool(row["ai_enabled"]), row["ai_hint"],
row["reminder_hours"], bool(row["loud"]), bool(row["use_default_targets"]))
def _values(data: RouteInput) -> tuple:
targets = json.dumps([t._asdict() for t in data.targets])
return (data.name.strip(), int(data.enabled), data.match_host.strip(), data.match_service.strip(),
data.min_state, KINDS_SEP.join(data.kinds), int(data.stop_on_match), targets)
data.min_state, KINDS_SEP.join(data.kinds), int(data.stop_on_match), targets,
data.delay_minutes, data.output_contains.strip(), int(data.ai_enabled), data.ai_hint.strip(),
data.reminder_hours or None, int(data.loud), int(data.use_default_targets))
def insert_statement(data: RouteInput) -> tuple[str, tuple]:
return INSERT_SQL, _values(data)
class RouteRepository:
@@ -36,17 +56,10 @@ class RouteRepository:
return _route(rows[0]) if rows else None
def insert(self, data: RouteInput) -> int:
return self._db.insert(
"INSERT INTO routes(name, enabled, match_host, match_service, min_state, kinds, stop_on_match, "
"targets, position) VALUES (?,?,?,?,?,?,?,?, (SELECT COALESCE(MAX(position), 0) + 1 FROM routes))",
_values(data),
)
return self._db.insert(*insert_statement(data))
def update(self, route_id: int, data: RouteInput) -> bool:
return self._db.execute(
"UPDATE routes SET name=?, enabled=?, match_host=?, match_service=?, min_state=?, kinds=?, "
"stop_on_match=?, targets=? WHERE id=?", (*_values(data), route_id),
) > 0
return self._db.execute(UPDATE_SQL, (*_values(data), route_id)) > 0
def set_enabled(self, route_id: int, enabled: bool) -> bool:
return self._db.execute("UPDATE routes SET enabled=? WHERE id=?", (int(enabled), route_id)) > 0
+38 -14
View File
@@ -3,11 +3,13 @@ from typing import Sequence
from ..chat.base import Target
from ..globs import glob_matches
from ..models import KIND_FLAPPING, KIND_PROBLEM, KIND_RECOVERY, Event
from ..models import KIND_FLAPPING, KIND_PROBLEM, KIND_RECOVERY, KIND_REMINDER, Event
from .limits import DEFAULT_MIN_STATE, MIN_STATE_RANGE
ALL_KINDS = (KIND_PROBLEM, KIND_RECOVERY, KIND_FLAPPING)
DEFAULT_MIN_STATE = 1
MIN_STATE_RANGE = (0, 3)
__all__ = ["ALL_KINDS", "DEFAULT_MIN_STATE", "MIN_STATE_RANGE", "Route", "matches", "matching_rules",
"resolve_targets", "route_matches"]
@dataclass(frozen=True)
@@ -22,26 +24,48 @@ class Route:
kinds: tuple[str, ...]
stop_on_match: bool
targets: tuple[Target, ...]
delay_minutes: int | None = None
output_contains: str = ""
ai_enabled: bool = True
ai_hint: str = ""
reminder_hours: int | None = None
loud: bool = True
use_default_targets: bool = False
def matches(route: Route, host: str, service: str, kind: str, state: int, output: str) -> bool:
# Erinnerungen folgen den Regeln für Probleme, damit keine eigene Alarmart nötig ist.
kind = KIND_PROBLEM if kind == KIND_REMINDER else kind
if not route.enabled or kind not in route.kinds:
return False
if kind == KIND_PROBLEM and state < route.min_state:
return False
if route.output_contains and route.output_contains.lower() not in output.lower():
return False
return glob_matches(route.match_host, host) and glob_matches(route.match_service, service)
def route_matches(route: Route, event: Event) -> bool:
if not route.enabled or event.kind not in route.kinds:
return False
if event.kind == KIND_PROBLEM and event.state < route.min_state:
return False
return glob_matches(route.match_host, event.host) and glob_matches(route.match_service, event.service)
return matches(route, event.host, event.service, event.kind, event.state, event.output)
def resolve_targets(rules: Sequence[Route], event: Event, defaults: Sequence[Target]) -> list[Target]:
resolved: list[Target] = []
matched = False
def matching_rules(rules: Sequence[Route], event: Event) -> list[Route]:
found: list[Route] = []
for rule in sorted(rules, key=lambda r: (r.position, r.id)):
if not route_matches(rule, event):
continue
matched = True
resolved.extend(t for t in rule.targets if t not in resolved)
found.append(rule)
if rule.stop_on_match:
break
if not matched:
return found
def resolve_targets(rules: Sequence[Route], event: Event, defaults: Sequence[Target]) -> list[Target]:
found = matching_rules(rules, event)
if not found:
return list(dict.fromkeys(defaults))
resolved: list[Target] = []
for rule in found:
extra = defaults if rule.use_default_targets else ()
resolved.extend(t for t in (*rule.targets, *extra) if t not in resolved)
return resolved
+41
View File
@@ -0,0 +1,41 @@
import logging
from .. import audit
from ..db import Database
from ..models import KIND_FLAPPING, KIND_PROBLEM, KIND_RECOVERY
from .repository import insert_statement
from .validation import RouteInput
log = logging.getLogger(__name__)
SEED_ACTOR = "system"
ZPOOL_MARKER = "seed.rule.zfs_zpool"
ZPOOL_REMINDER_HOURS = 4
ZPOOL_HINT = (
"ZFS-Pool-Alarm. Typische Prüfschritte: zpool status -v (betroffenes Gerät, Fehlerzähler "
"READ/WRITE/CKSUM), läuft ein Scrub oder Resilver, SMART-Werte der betroffenen Platte, "
"Kabel/Controller, ob 'zpool clear' genügt oder ein 'zpool replace' nötig ist. Der Meldungstext "
"enthält oft keine Geräte- oder Fehlerzahlen; weise darauf hin, dass sie nicht verifiziert sind."
)
def zpool_rule() -> RouteInput:
return RouteInput(
name="ZFS Zpool", match_host="*", match_service="*zpool*", min_state=1,
kinds=(KIND_PROBLEM, KIND_RECOVERY, KIND_FLAPPING), enabled=True, use_default_targets=True,
delay_minutes=0, ai_enabled=True, reminder_hours=ZPOOL_REMINDER_HOURS, loud=True,
ai_hint=ZPOOL_HINT,
)
def seed_default_rules(db: Database) -> bool:
try:
if db.query("SELECT 1 FROM app_state WHERE key=?", (ZPOOL_MARKER,)):
return False
sql, params = insert_statement(zpool_rule())
db.execute_many([(sql, params), ("INSERT INTO app_state(key, value) VALUES (?, '1')", (ZPOOL_MARKER,))])
audit.record(db, SEED_ACTOR, "route.seed", zpool_rule().name)
return True
except Exception:
log.exception("Standardregel konnte nicht angelegt werden")
return False
+5 -1
View File
@@ -17,9 +17,13 @@ class RouteError(ValueError):
def describe(route: Route | RouteInput) -> str:
targets = ", ".join(f"{t.backend}:{t.target}" for t in route.targets) or "keine"
state = "an" if route.enabled else "aus"
delay = "std" if route.delay_minutes is None else route.delay_minutes
return (f"{route.name} [{state}] host={route.match_host or '*'} service={route.match_service or '*'} "
f"min_state={route.min_state} kinds={','.join(route.kinds)} "
f"stop={int(route.stop_on_match)} -> {targets}")
f"stop={int(route.stop_on_match)} delay={delay} contains={route.output_contains!r} "
f"ai={int(route.ai_enabled)} hint_len={len(route.ai_hint)} "
f"reminder_h={route.reminder_hours or 0} loud={int(route.loud)} "
f"default_targets={int(route.use_default_targets)} -> {targets}")
class RouteService:
+31 -6
View File
@@ -1,13 +1,15 @@
import re
from dataclasses import dataclass, field
from ..chat.base import Target
from ..chat.targets import valid_target
from ..globs import validate_glob
from .rules import ALL_KINDS, DEFAULT_MIN_STATE, MIN_STATE_RANGE
from .limits import (AI_HINT_MAX, DEFAULT_MIN_STATE, DELAY_MINUTES_RANGE, MIN_STATE_RANGE, NAME_MAX,
OUTPUT_CONTAINS_MAX, REMINDER_HOURS_RANGE, TARGET_MAX)
from .rules import ALL_KINDS
NAME_MAX = 80
TARGET_MAX = 200
BACKENDS = ("telegram", "matrix")
CONTROL_RE = re.compile(r"[\x00-\x08\x0b-\x1f\x7f]")
@dataclass(frozen=True)
@@ -20,6 +22,21 @@ class RouteInput:
stop_on_match: bool = False
enabled: bool = True
targets: tuple[Target, ...] = field(default_factory=tuple)
delay_minutes: int | None = None
output_contains: str = ""
ai_enabled: bool = True
ai_hint: str = ""
reminder_hours: int | None = None
loud: bool = True
use_default_targets: bool = False
def _range_error(value: int | None, bounds: tuple[int, int], label: str, unit: str = "") -> list[str]:
low, high = bounds
if value is not None and not low <= value <= high:
suffix = f" {unit}" if unit else ""
return [f"{label} muss zwischen {low} und {high}{suffix} liegen."]
return []
def validate_route(data: RouteInput) -> list[str]:
@@ -29,11 +46,19 @@ def validate_route(data: RouteInput) -> list[str]:
elif len(data.name) > NAME_MAX:
errors.append(f"Name ist zu lang (max. {NAME_MAX} Zeichen).")
errors += validate_glob(data.match_host, "Host-Muster") + validate_glob(data.match_service, "Service-Muster")
low, high = MIN_STATE_RANGE
if not low <= data.min_state <= high:
errors.append(f"Mindest-Schweregrad muss zwischen {low} und {high} liegen.")
errors += _range_error(data.min_state, MIN_STATE_RANGE, "Mindest-Schweregrad")
if not data.kinds or any(kind not in ALL_KINDS for kind in data.kinds):
errors.append("Mindestens eine gültige Alarmart wählen.")
errors += _range_error(data.delay_minutes, DELAY_MINUTES_RANGE, "Verzögerung", "Minuten")
errors += _range_error(data.reminder_hours, REMINDER_HOURS_RANGE, "Erinnerung", "Stunden")
if len(data.output_contains) > OUTPUT_CONTAINS_MAX:
errors.append(f"Der Ausgabe-Text ist zu lang (max. {OUTPUT_CONTAINS_MAX} Zeichen).")
if CONTROL_RE.search(data.output_contains):
errors.append("Der Ausgabe-Text enthält Steuerzeichen.")
if len(data.ai_hint) > AI_HINT_MAX:
errors.append(f"Der KI-Hinweis ist zu lang (max. {AI_HINT_MAX} Zeichen).")
if CONTROL_RE.search(data.ai_hint):
errors.append("Der KI-Hinweis enthält Steuerzeichen.")
for target in data.targets:
if target.backend not in BACKENDS:
errors.append(f"Unbekanntes Backend: {target.backend}")
+17 -4
View File
@@ -8,7 +8,8 @@ from . import audit
from .alerts import AlertActions, AlertAnalyzer, AlertPipeline, AlertRepository, SlidingWindowLimiter
from .chat import AdapterRegistry
from .chatops import ChatOps
from .constants import ALERTS_BUNDLE_SECONDS, DEFAULT_BUNDLE_SECONDS
from .constants import (ALERTS_BUNDLE_SECONDS, ALERTS_RECOVERY_ENABLED, ALERTS_REMINDER_HOURS, DEFAULT_BUNDLE_SECONDS,
DEFAULT_REMINDER_HOURS, FLAG_OFF, FLAG_ON, MAX_REMINDER_HOURS)
from .db import Database
from .agent_runs import configured_secrets
from .mutes import MuteService
@@ -17,11 +18,13 @@ from .poller import Poller, PollerStatus, StateStore
from .prompts import (PROMPT_AGENT, PROMPT_ALERT, PromptRepository, PromptService, PromptTester, UserRateLimiter,
default_registry)
from .retention import RetentionRepository, RetentionRunner, RetentionService, policy_from
from .routing import RouteService
from .reminders import ReminderWorker
from .routing import RouteService, seed_default_rules
class Runtime:
def __init__(self, db: Database, store):
self._db = db
self.status = PollerStatus()
self.alerts = AlertRepository(db)
self.state = StateStore(db)
@@ -39,8 +42,15 @@ class Runtime:
self.alerts, self.registry.current, analyzer,
lambda: store.get_int(ALERTS_BUNDLE_SECONDS, DEFAULT_BUNDLE_SECONDS),
routes=self.routes, mutes=self.mutes, actions=AlertActions(store),
recovery_enabled=lambda: store.get(ALERTS_RECOVERY_ENABLED, FLAG_ON) != FLAG_OFF,
)
self.poller = Poller(store, self.state, self.pipeline.handle, self.status,
rules=self.routes.enabled_routes)
self.reminders = ReminderWorker(
self.state.load, self.routes.enabled_routes, self.mutes.active,
lambda: min(store.get_int(ALERTS_REMINDER_HOURS, DEFAULT_REMINDER_HOURS), MAX_REMINDER_HOURS),
self.pipeline.handle, self.state.mark_reminded,
)
self.poller = Poller(store, self.state, self.pipeline.handle, self.status)
self.chatops = ChatOps(db, store, self.state, self.alerts, self.mutes,
lambda: self.prompts.effective_text(PROMPT_AGENT))
self.prompt_tester = PromptTester(
@@ -49,7 +59,9 @@ class Runtime:
@asynccontextmanager
async def lifespan(self, app: FastAPI):
await asyncio.to_thread(seed_default_rules, self._db)
poller_task = asyncio.create_task(self.poller.run())
reminder_task = asyncio.create_task(self.reminders.run())
pipeline_task = asyncio.create_task(self.pipeline.run())
listener_tasks = [asyncio.create_task(run()) for run in self.chatops.listener_runners()]
retention_task = asyncio.create_task(self.retention_runner.run())
@@ -62,7 +74,8 @@ class Runtime:
retention_task.cancel()
await asyncio.gather(retention_task, return_exceptions=True)
poller_task.cancel()
await asyncio.gather(poller_task, return_exceptions=True)
reminder_task.cancel()
await asyncio.gather(poller_task, reminder_task, return_exceptions=True)
await self.pipeline.finish()
pipeline_task.cancel()
await asyncio.gather(pipeline_task, return_exceptions=True)
+14 -7
View File
@@ -1,18 +1,20 @@
from ..constants import (ALERTS_AI_PER_MINUTE, ALERTS_BUNDLE_SECONDS, DEFAULT_AI_PER_MINUTE,
DEFAULT_BUNDLE_SECONDS, DEFAULT_POLL_INTERVAL, DEFAULT_POLL_MIN_DURATION,
MIN_POLL_INTERVAL, POLL_INTERVAL, POLL_MIN_DURATION)
from ..constants import (ALERTS_AI_PER_MINUTE, ALERTS_BUNDLE_SECONDS, ALERTS_DEFAULT_DELAY_MINUTES,
ALERTS_RECOVERY_ENABLED, ALERTS_REMINDER_HOURS, DEFAULT_AI_PER_MINUTE,
DEFAULT_BUNDLE_SECONDS, DEFAULT_DELAY_MINUTES, DEFAULT_POLL_INTERVAL,
DEFAULT_REMINDER_HOURS, MAX_DELAY_MINUTES, MAX_REMINDER_HOURS, MIN_POLL_INTERVAL,
POLL_INTERVAL)
from .sections import Field, Section
SLUG = "alerting"
MAX_POLL_INTERVAL = 3600
MAX_MIN_DURATION = 3600
MAX_BUNDLE_SECONDS = 3600
MAX_AI_PER_MINUTE = 600
LIMITS = (
(POLL_INTERVAL, "Abfrageintervall", MIN_POLL_INTERVAL, MAX_POLL_INTERVAL),
(POLL_MIN_DURATION, "Mindestdauer", 0, MAX_MIN_DURATION),
(ALERTS_DEFAULT_DELAY_MINUTES, "Standard-Verzögerung", 0, MAX_DELAY_MINUTES),
(ALERTS_REMINDER_HOURS, "Erinnerung", 0, MAX_REMINDER_HOURS),
(ALERTS_BUNDLE_SECONDS, "Bündelungsfenster", 0, MAX_BUNDLE_SECONDS),
(ALERTS_AI_PER_MINUTE, "KI-Analysen pro Minute", 0, MAX_AI_PER_MINUTE),
)
@@ -34,8 +36,13 @@ SECTION = Section(
title="Alarmierung",
fields=(
Field(POLL_INTERVAL, "Abfrageintervall in Sekunden", default=str(DEFAULT_POLL_INTERVAL)),
Field(POLL_MIN_DURATION, "Mindestdauer eines Problems in Sekunden (0 = sofort)",
default=str(DEFAULT_POLL_MIN_DURATION)),
Field(ALERTS_DEFAULT_DELAY_MINUTES,
"Standard-Verzögerung in Minuten (Problem wird erst nach dieser Zeit gemeldet, 0 = sofort; "
"Regeln können das überschreiben)", default=str(DEFAULT_DELAY_MINUTES)),
Field(ALERTS_RECOVERY_ENABLED, "Recovery-Meldungen senden (wieder OK)", kind="checkbox"),
Field(ALERTS_REMINDER_HOURS,
"Standard-Erinnerung für offene CRIT-Probleme in Stunden (0 = aus; Regeln überschreiben)",
default=str(DEFAULT_REMINDER_HOURS)),
Field(ALERTS_BUNDLE_SECONDS, "Bündelungsfenster pro Host in Sekunden",
default=str(DEFAULT_BUNDLE_SECONDS)),
Field(ALERTS_AI_PER_MINUTE, "Max. KI-Analysen pro Minute (0 = keine)",
+3 -1
View File
@@ -5,10 +5,12 @@ from .deps import current_user, render
router = APIRouter()
SUPPRESS_LABELS = {"mute": "Stummschaltung", "downtime": "Downtime", "recovery_aus": "Recovery aus",
"nicht_gemeldet": "Problem war nicht gemeldet"}
AI_LABELS = {"pending": "läuft", "done": "fertig", "failed": "nicht verfügbar", "skipped": "übersprungen"}
@router.get("/alerts")
def alerts_page(request: Request, user: User = Depends(current_user)):
entries = request.app.state.runtime.alerts.recent()
return render(request, "alerts.html", user=user, entries=entries, ai_labels=AI_LABELS)
return render(request, "alerts.html", user=user, entries=entries, ai_labels=AI_LABELS, suppress_labels=SUPPRESS_LABELS)
+2 -1
View File
@@ -16,6 +16,7 @@ from ..ollama import OllamaClient
from ..users import MIN_PASSWORD_LEN, ROLES, User, UserError
from . import alerting_section, downtime_section, matrix_section, retention_section, telegram_section
from .deps import (FLASH_ERROR, FLASH_OK, current_user, flash, redirect, render, require_admin)
from .problem_status import problems_with_status
from .sections import (URL_RE, Field, Section, check, collect, effective, find_section, form_values,
secret_flags, section_done, stored_values)
@@ -135,7 +136,7 @@ async def dashboard(request: Request, user: User = Depends(current_user)):
section_done(store, s) for s in SECTIONS.values() if not s.optional)
return render(request, "dashboard.html", user=user, result=result, setup_open=setup_open,
alerts_24h=runtime.alerts.count_since(int(time.time()) - DAY_SECONDS),
open_problems=runtime.alerts.open_problems())
open_problems=problems_with_status(request))
@router.get("/setup")
+16
View File
@@ -0,0 +1,16 @@
import time
from fastapi import Request
from ..alerts import report_status
from ..poller.options import read_default_delay
def problems_with_status(request: Request) -> list[dict]:
runtime = request.app.state.runtime
now = time.time()
rules = runtime.routes.enabled_routes()
mutes = runtime.mutes.active(now)
default_minutes = read_default_delay(request.app.state.store)
return [{**row, "report": report_status(row, rules, mutes, default_minutes, now)}
for row in runtime.alerts.open_problems()]
+78
View File
@@ -0,0 +1,78 @@
from fastapi import Request
from ..chat import Target, default_targets
from ..models import KIND_FLAPPING, KIND_PROBLEM, KIND_RECOVERY, STATE_NAMES
from ..routing import DEFAULT_MIN_STATE, Route, RouteInput
CHOICE_SEP = "|"
EXTRA_SEP = ":"
KIND_LABELS = {KIND_PROBLEM: "Problem", KIND_RECOVERY: "Recovery", KIND_FLAPPING: "Flapping"}
def _choice_value(target: Target) -> str:
return f"{target.backend}{CHOICE_SEP}{target.target}"
def _optional_int(form, name: str, label: str, errors: list[str]) -> int | None:
raw = str(form.get(name, "")).strip()
if not raw:
return None
try:
return int(raw)
except ValueError:
errors.append(f"{label} muss eine ganze Zahl sein.")
return None
def _parse_targets(form, errors: list[str]) -> tuple[Target, ...]:
targets: list[Target] = []
for value in form.getlist("targets"):
backend, _, target = str(value).partition(CHOICE_SEP)
targets.append(Target(backend, target))
for line in str(form.get("extra_targets", "")).splitlines():
if not line.strip():
continue
backend, sep, target = line.strip().partition(EXTRA_SEP)
if not sep:
errors.append(f"Zielzeile ungültig: {line.strip()[:60]} (erwartet backend:ziel).")
continue
targets.append(Target(backend.strip().lower(), target.strip()))
return tuple(dict.fromkeys(targets))
def parse_form(form) -> tuple[RouteInput, list[str]]:
errors: list[str] = []
min_state = _optional_int(form, "min_state", "Mindest-Schweregrad", errors)
data = RouteInput(
name=str(form.get("name", "")).strip(), match_host=str(form.get("match_host", "")).strip(),
match_service=str(form.get("match_service", "")).strip(),
min_state=DEFAULT_MIN_STATE if min_state is None else min_state,
kinds=tuple(str(k) for k in form.getlist("kinds")), stop_on_match=bool(form.get("stop_on_match")),
enabled=bool(form.get("enabled")), targets=_parse_targets(form, errors),
delay_minutes=_optional_int(form, "delay_minutes", "Verzögerung", errors),
output_contains=str(form.get("output_contains", "")).strip(),
ai_enabled=bool(form.get("ai_enabled")), ai_hint=str(form.get("ai_hint", "")).strip(),
reminder_hours=_optional_int(form, "reminder_hours", "Erinnerung", errors),
loud=bool(form.get("loud")), use_default_targets=bool(form.get("use_default_targets")),
)
return data, errors
def form_context(request: Request, data: RouteInput) -> dict:
choices = default_targets(request.app.state.store)
free = [t for t in data.targets if t not in choices]
return {
"form": data,
"choices": [{"value": _choice_value(t), "label": f"{t.backend}: {t.target}"} for t in choices],
"selected": {_choice_value(t) for t in data.targets if t in choices},
"extra_text": "\n".join(f"{t.backend}{EXTRA_SEP}{t.target}" for t in free),
"state_options": sorted(STATE_NAMES.items()),
"kind_options": KIND_LABELS,
}
def to_input(route: Route) -> RouteInput:
return RouteInput(route.name, route.match_host, route.match_service, route.min_state, route.kinds,
route.stop_on_match, route.enabled, route.targets, route.delay_minutes,
route.output_contains, route.ai_enabled, route.ai_hint, route.reminder_hours,
route.loud, route.use_default_targets)
+6 -60
View File
@@ -1,20 +1,16 @@
from fastapi import APIRouter, Depends, Form, HTTPException, Request
from ..chat import Target, default_targets
from ..constants import FLAG_ON
from ..models import KIND_FLAPPING, KIND_PROBLEM, KIND_RECOVERY, STATE_NAMES
from ..routing import (DEFAULT_MIN_STATE, DIRECTION_DOWN, DIRECTION_UP, Route, RouteError, RouteInput,
RouteService)
from ..models import STATE_NAMES
from ..routing import DIRECTION_DOWN, DIRECTION_UP, Route, RouteError, RouteInput, RouteService
from ..users import User
from .deps import FLASH_ERROR, FLASH_OK, current_user, flash, redirect, render, require_admin
from .routing_form import KIND_LABELS, form_context, parse_form, to_input
router = APIRouter()
PATH = "/routing"
STATUS_INVALID = 422
CHOICE_SEP = "|"
EXTRA_SEP = ":"
KIND_LABELS = {KIND_PROBLEM: "Problem", KIND_RECOVERY: "Recovery", KIND_FLAPPING: "Flapping"}
MSG_NOT_FOUND = "Regel nicht gefunden."
@@ -22,60 +18,10 @@ def _service(request: Request) -> RouteService:
return request.app.state.runtime.routes
def _choice_value(target: Target) -> str:
return f"{target.backend}{CHOICE_SEP}{target.target}"
def parse_form(form) -> tuple[RouteInput, list[str]]:
errors: list[str] = []
try:
min_state = int(str(form.get("min_state", DEFAULT_MIN_STATE)).strip())
except ValueError:
min_state = DEFAULT_MIN_STATE
errors.append("Mindest-Schweregrad muss eine Zahl sein.")
targets: list[Target] = []
for value in form.getlist("targets"):
backend, _, target = str(value).partition(CHOICE_SEP)
targets.append(Target(backend, target))
for line in str(form.get("extra_targets", "")).splitlines():
if not line.strip():
continue
backend, sep, target = line.strip().partition(EXTRA_SEP)
if not sep:
errors.append(f"Zielzeile ungültig: {line.strip()[:60]} (erwartet backend:ziel).")
continue
targets.append(Target(backend.strip().lower(), target.strip()))
data = RouteInput(
name=str(form.get("name", "")).strip(), match_host=str(form.get("match_host", "")).strip(),
match_service=str(form.get("match_service", "")).strip(), min_state=min_state,
kinds=tuple(str(k) for k in form.getlist("kinds")), stop_on_match=bool(form.get("stop_on_match")),
enabled=bool(form.get("enabled")), targets=tuple(dict.fromkeys(targets)),
)
return data, errors
def _form_context(request: Request, data: RouteInput) -> dict:
choices = default_targets(request.app.state.store)
free = [t for t in data.targets if t not in choices]
return {
"form": data,
"choices": [{"value": _choice_value(t), "label": f"{t.backend}: {t.target}"} for t in choices],
"selected": {_choice_value(t) for t in data.targets if t in choices},
"extra_text": "\n".join(f"{t.backend}{EXTRA_SEP}{t.target}" for t in free),
"state_options": sorted(STATE_NAMES.items()),
"kind_options": KIND_LABELS,
}
def _to_input(route: Route) -> RouteInput:
return RouteInput(route.name, route.match_host, route.match_service, route.min_state, route.kinds,
route.stop_on_match, route.enabled, route.targets)
def _list_page(request: Request, user: User, data: RouteInput, status_code: int = 200):
return render(request, "routing.html", user=user, routes=_service(request).list_all(),
state_names=STATE_NAMES, kind_labels=KIND_LABELS, status_code=status_code,
**_form_context(request, data))
**form_context(request, data))
def _get_or_404(request: Request, route_id: int) -> Route:
@@ -109,7 +55,7 @@ async def routing_create(request: Request, admin: User = Depends(require_admin))
def routing_edit_page(request: Request, route_id: int, admin: User = Depends(require_admin)):
route = _get_or_404(request, route_id)
return render(request, "route_edit.html", user=admin, route=route,
**_form_context(request, _to_input(route)))
**form_context(request, to_input(route)))
@router.post(PATH + "/{route_id}")
@@ -124,7 +70,7 @@ async def routing_update(request: Request, route_id: int, admin: User = Depends(
for message in exc.errors:
flash(request, message, FLASH_ERROR)
return render(request, "route_edit.html", user=admin, route=route, status_code=STATUS_INVALID,
**_form_context(request, data))
**form_context(request, data))
flash(request, "Regel gespeichert.", FLASH_OK)
return redirect(PATH)
+15 -1
View File
@@ -10,6 +10,8 @@
<select id="r-state" name="min_state">
{% for value, name in state_options %}<option value="{{ value }}" {% if value == form.min_state %}selected{% endif %}>{{ value }} – {{ name }}</option>{% endfor %}
</select>
<label for="r-contains">Ausgabe enthält (optional, Teilstring ohne Groß-/Kleinschreibung, keine Regex)</label>
<input id="r-contains" name="output_contains" type="text" maxlength="200" value="{{ form.output_contains }}" autocomplete="off" autocapitalize="none">
<fieldset>
<legend>Alarmarten</legend>
{% for kind, label in kind_options.items() %}
@@ -23,9 +25,21 @@
{% else %}
<p class="muted">Kein Chat-Backend mit Standardziel aktiviert.</p>
{% endfor %}
<label class="check"><input type="checkbox" name="use_default_targets" value="1" {% if form.use_default_targets %}checked{% endif %}> Standardziele aller aktivierten Backends zusätzlich verwenden</label>
<label for="r-extra">Weitere Ziele (je Zeile <code>backend:ziel</code>, z. B. <code>matrix:!raum:server</code>)</label>
<textarea id="r-extra" name="extra_targets" rows="3" autocomplete="off" autocapitalize="none">{{ extra_text }}</textarea>
<p class="muted">Ohne Ziele wird bei Treffer nichts gesendet.</p>
<p class="muted">Ohne Ziele und ohne Standardziele wird bei Treffer nichts gesendet.</p>
</fieldset>
<fieldset>
<legend>Verhalten</legend>
<label for="r-delay">Verzögerung in Minuten (leer = Standard aus "Alarmierung", 0 = sofort; bei mehreren Treffern gilt die kleinste)</label>
<input id="r-delay" name="delay_minutes" type="number" min="0" max="1440" step="1" value="{{ form.delay_minutes if form.delay_minutes is not none else '' }}">
<label for="r-reminder">Erinnerung alle N Stunden bei offenem CRIT (leer oder 0 = keine, max. 168)</label>
<input id="r-reminder" name="reminder_hours" type="number" min="0" max="168" step="1" value="{{ form.reminder_hours if form.reminder_hours is not none else '' }}">
<label class="check"><input type="checkbox" name="ai_enabled" value="1" {% if form.ai_enabled %}checked{% endif %}> KI-Analyse für diese Alarme</label>
<label for="r-hint">KI-Hinweis (optional, max. 800 Zeichen; Zusatzkontext für die Analyse, kein Ersatz für den Schutzhinweis)</label>
<textarea id="r-hint" name="ai_hint" rows="4" maxlength="800" autocomplete="off">{{ form.ai_hint }}</textarea>
<label class="check"><input type="checkbox" name="loud" value="1" {% if form.loud %}checked{% endif %}> Laut (aus = leise: Telegram ohne Benachrichtigungston, Matrix als Notice)</label>
</fieldset>
<label class="check"><input type="checkbox" name="stop_on_match" value="1" {% if form.stop_on_match %}checked{% endif %}> Weitere Regeln nach Treffer nicht mehr auswerten</label>
<label class="check"><input type="checkbox" name="enabled" value="1" {% if form.enabled %}checked{% endif %}> Regel aktiv</label>
+1 -1
View File
@@ -14,7 +14,7 @@
<td>{{ a.service }}</td>
<td>{{ a.kind }}</td>
<td><span class="badge {{ a.state | state_class }}">{{ a.state | state_name }}</span></td>
<td>{{ a.output }}{% if a.suppressed %} <span class="badge info">unterdrückt ({{ 'Stummschaltung' if a.suppress_reason == 'mute' else 'Downtime' }})</span>{% endif %}</td>
<td>{{ a.output }}{% if a.suppressed %} <span class="badge info">unterdrückt ({{ suppress_labels.get(a.suppress_reason, a.suppress_reason) }})</span>{% endif %}</td>
<td>
{{ ai_labels.get(a.ai_status, a.ai_status) }}
{% if a.ai_text %}<div class="prewrap">{{ a.ai_text }}</div>{% endif %}
+1 -1
View File
@@ -16,7 +16,7 @@
<nav class="nav" aria-label="Hauptnavigation">
<a href="/">Dashboard</a>
<a href="/alerts">Verlauf</a>
<a href="/routing">Routing</a>
<a href="/routing">Regeln</a>
<a href="/mutes">Stummschaltungen</a>
<a href="/prompts">Prompts</a>
{% if user.is_admin %}
+3 -2
View File
@@ -28,7 +28,7 @@
<h2>Offene Probleme <span class="badge info">{{ alerts_24h }} Alarme in 24 h</span></h2>
<div class="table-wrap">
<table>
<thead><tr><th>Host</th><th>Service</th><th>Status</th><th>Seit</th><th>Output</th></tr></thead>
<thead><tr><th>Host</th><th>Service</th><th>Status</th><th>Seit</th><th>Meldung</th><th>Output</th></tr></thead>
<tbody>
{% for p in open_problems %}
<tr>
@@ -36,12 +36,13 @@
<td>{{ p.service }}</td>
<td><span class="badge {{ p.state | state_class }}">{{ p.state | state_name }}</span></td>
<td>{{ p.last_state_change | fmt_ts }}</td>
<td><span class="badge {{ {'reported': 'ok', 'waiting': 'info', 'suppressed': 'warn', 'due': 'info'}.get(p.report.kind, 'info') }}">{{ p.report.label }}</span></td>
<td>{{ p.output }}
{% if p.in_downtime %}<span class="badge info">Downtime</span>{% endif %}
{% if p.flapping %}<span class="badge warn">flappt</span>{% endif %}</td>
</tr>
{% else %}
<tr><td colspan="5" class="muted">Keine offenen Probleme.</td></tr>
<tr><td colspan="6" class="muted">Keine offenen Probleme.</td></tr>
{% endfor %}
</tbody>
</table>
+13 -6
View File
@@ -1,12 +1,12 @@
{% extends "base.html" %}
{% block title %}Routing{% endblock %}
{% block title %}Regeln{% endblock %}
{% block content %}
<h1>Routing</h1>
<h1>Regeln</h1>
<section class="card">
<p class="muted">Regeln werden von oben nach unten ausgewertet. Jede passende Regel fügt ihre Ziele hinzu. Passt keine Regel, gehen Alarme an die Standardziele aller aktivierten Backends.</p>
<p class="muted">Regeln werden von oben nach unten ausgewertet. Jede passende Regel fügt ihre Ziele hinzu. Regeln steuern Ziele, Verzögerung, KI, Erinnerung und Lautstärke. Passt keine Regel, gehen Alarme an die Standardziele aller aktivierten Backends mit der Standard-Verzögerung.</p>
<div class="table-wrap">
<table>
<thead><tr><th>#</th><th>Name</th><th>Host / Service</th><th>Ab Status</th><th>Arten</th><th>Ziele</th><th>Status</th>{% if user.is_admin %}<th>Aktionen</th>{% endif %}</tr></thead>
<thead><tr><th>#</th><th>Name</th><th>Host / Service</th><th>Ab Status</th><th>Arten</th><th>Ziele</th><th>Optionen</th><th>Status</th>{% if user.is_admin %}<th>Aktionen</th>{% endif %}</tr></thead>
<tbody>
{% for r in routes %}
<tr>
@@ -15,7 +15,14 @@
<td>{{ r.match_host or '*' }} / {{ r.match_service or '*' }}</td>
<td>{{ state_names.get(r.min_state, r.min_state) }}</td>
<td>{% for k in r.kinds %}{{ kind_labels.get(k, k) }}{% if not loop.last %}, {% endif %}{% endfor %}</td>
<td>{% for t in r.targets %}<div>{{ t.backend }}: {{ t.target }}</div>{% else %}<span class="muted">keine</span>{% endfor %}</td>
<td>{% for t in r.targets %}<div>{{ t.backend }}: {{ t.target }}</div>{% else %}{% if not r.use_default_targets %}<span class="muted">keine</span>{% endif %}{% endfor %}{% if r.use_default_targets %}<div>Standardziele</div>{% endif %}</td>
<td>
{% if r.output_contains %}<div>Ausgabe enthält „{{ r.output_contains }}“</div>{% endif %}
<div>Verzögerung: {{ (r.delay_minutes ~ ' min') if r.delay_minutes is not none else 'Standard' }}</div>
<div>KI: {{ 'an' if r.ai_enabled else 'aus' }}{% if r.ai_hint %} (mit Hinweis){% endif %}</div>
{% if r.reminder_hours %}<div>Erinnerung: alle {{ r.reminder_hours }} h</div>{% endif %}
{% if not r.loud %}<div>leise</div>{% endif %}
</td>
<td><span class="badge {{ 'ok' if r.enabled else 'warn' }}">{{ 'aktiv' if r.enabled else 'aus' }}</span></td>
{% if user.is_admin %}
<td>
@@ -30,7 +37,7 @@
{% endif %}
</tr>
{% else %}
<tr><td colspan="8" class="muted">Keine Regeln. Alarme gehen an die Standardziele.</td></tr>
<tr><td colspan="9" class="muted">Keine Regeln. Alarme gehen an die Standardziele.</td></tr>
{% endfor %}
</tbody>
</table>
+58
View File
@@ -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"
+6 -5
View File
@@ -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") == []
+70
View File
@@ -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 "&lt;script&gt;" in text
assert "&lt;s&gt;&amp;" in text and "<h>" not in text
+3 -2
View File
@@ -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)
+191
View File
@@ -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
+3 -3
View File
@@ -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}
+118
View File
@@ -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
+164
View File
@@ -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]
+124
View File
@@ -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) == []
+60
View File
@@ -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,)) == []
+6 -3
View File
@@ -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
+129
View File
@@ -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