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