feat: Laufzeit-Einstellungen und Webhook-Benachrichtigung

Einstellungen in der Tabelle settings; generischer Webhook mit Vorlage
(JSON-sicher), Modus bei jedem Fehler oder nach N Fehlern in Folge,
optionale Entwarnung, Versand im Hintergrund.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-10-10 19:15:48 +02:00
co-authored by Claude Opus 5.5
parent 642def66bd
commit f45cb56aa0
2 changed files with 207 additions and 0 deletions
+128
View File
@@ -0,0 +1,128 @@
"""Benachrichtigung per generischem Webhook.
Der Body ist eine Vorlage mit ``$platzhaltern`` (``string.Template``):
``$event``, ``$title``, ``$message``, ``$rule``, ``$time``, ``$host``.
Ist der Content-Type JSON, werden die Werte JSON-maskiert eingesetzt – so
funktionieren auch eigene JSON-Formate (z. B. Gotify ``{"title": "$title", …}``).
Für ntfy reicht ``text/plain`` mit ``$message`` und der Topic-URL.
Versand im Hintergrund-Thread, damit ein langsamer Empfänger nie einen Lauf bremst.
"""
from __future__ import annotations
import json
import socket
import threading
from collections.abc import Callable
from dataclasses import dataclass
from datetime import UTC, datetime
from string import Template
import httpx
from sqlalchemy.orm import Session, sessionmaker
from netmover.logging_setup import event, get_logger
from netmover.settings_store import AppSettings, NotifyMode, load_settings
log = get_logger(__name__)
@dataclass(frozen=True)
class Notification:
event: str # z. B. "rule.failed", "rule.recovered", "mount.error"
title: str
message: str
rule: str = ""
def render(template: str, content_type: str, n: Notification, now: datetime | None = None) -> str:
values = {
"event": n.event,
"title": n.title,
"message": n.message,
"rule": n.rule,
"time": (now or datetime.now(UTC)).isoformat(timespec="seconds"),
"host": socket.gethostname(),
}
if "json" in content_type.lower():
values = {k: json.dumps(v, ensure_ascii=False)[1:-1] for k, v in values.items()}
return Template(template).safe_substitute(values)
Sender = Callable[[str, str, str], None] # (url, content_type, body)
def http_sender(url: str, content_type: str, body: str) -> None:
resp = httpx.post(
url, content=body.encode(), headers={"Content-Type": content_type}, timeout=10
)
resp.raise_for_status()
class Notifier:
def __init__(
self,
session_factory: sessionmaker[Session],
*,
sender: Sender = http_sender,
background: bool = True,
) -> None:
self.session_factory = session_factory
self._sender = sender
self._background = background
def settings(self) -> AppSettings:
return load_settings(self.session_factory)
def send(self, n: Notification, settings: AppSettings | None = None) -> None:
cfg = settings or self.settings()
if cfg.notify_mode is NotifyMode.OFF or not cfg.webhook_url:
return
body = render(cfg.webhook_template, cfg.webhook_content_type, n)
def deliver() -> None:
try:
self._sender(cfg.webhook_url, cfg.webhook_content_type, body)
event(log, "notify.sent", kind=n.event, rule=n.rule)
except Exception as exc: # Benachrichtigung darf nie den Dienst stören
# URL nicht protokollieren – sie enthält oft ein Token.
event(log, "notify.failed", level=30, kind=n.event, error=type(exc).__name__)
if self._background:
threading.Thread(target=deliver, name="nm-notify", daemon=True).start()
else:
deliver()
def rule_result(self, rule: str, failed: bool, consecutive: int, message: str) -> None:
"""Entscheidet nach Modus, ob ein Regelergebnis gemeldet wird."""
cfg = self.settings()
if cfg.notify_mode is NotifyMode.OFF:
return
threshold = 1 if cfg.notify_mode is NotifyMode.EVERY else max(1, cfg.notify_after)
if failed:
if cfg.notify_mode is NotifyMode.EVERY or consecutive == threshold:
suffix = f" ({consecutive}× in Folge)" if consecutive > 1 else ""
self.send(
Notification(
"rule.failed",
f"NetMover: Regel „{rule}“ fehlgeschlagen{suffix}",
message,
rule,
),
cfg,
)
elif cfg.notify_recovery and consecutive >= threshold:
self.send(
Notification(
"rule.recovered",
f"NetMover: Regel „{rule}“ läuft wieder",
f"Nach {consecutive} fehlgeschlagenen Läufen wieder erfolgreich.",
rule,
),
cfg,
)
def mount_event(self, kind: str, mount: str, message: str) -> None:
"""Passt zur ``Notifier``-Signatur des :class:`MountManager`."""
self.send(Notification(kind, f"NetMover: Anbindung „{mount}“ gestört", message))
+79
View File
@@ -0,0 +1,79 @@
"""Laufzeit-Einstellungen in der Tabelle ``settings`` (in der Oberfläche änderbar)."""
from __future__ import annotations
import json
from dataclasses import asdict, dataclass, fields
from enum import StrEnum
from sqlalchemy import select
from sqlalchemy.orm import Session, sessionmaker
from netmover.db import session_scope
from netmover.models import Setting
class NotifyMode(StrEnum):
OFF = "off"
EVERY = "every" # bei jedem fehlgeschlagenen Lauf
AFTER_N = "after_n" # erst nach N fehlgeschlagenen Läufen in Folge
DEFAULT_TEMPLATE = (
'{"event": "$event", "title": "$title", "message": "$message", '
'"rule": "$rule", "time": "$time", "host": "$host"}'
)
@dataclass
class AppSettings:
retention_days: int = 30
webhook_url: str = ""
notify_mode: NotifyMode = NotifyMode.OFF
notify_after: int = 3
notify_recovery: bool = True
webhook_template: str = DEFAULT_TEMPLATE
webhook_content_type: str = "application/json"
_PREFIX = "app."
def load_settings(factory: sessionmaker[Session]) -> AppSettings:
with session_scope(factory) as s:
rows = {
r.key[len(_PREFIX) :]: r.value
for r in s.scalars(select(Setting).where(Setting.key.like(f"{_PREFIX}%")))
}
result = AppSettings()
for f in fields(AppSettings):
if f.name in rows:
raw = json.loads(rows[f.name])
setattr(result, f.name, NotifyMode(raw) if f.name == "notify_mode" else raw)
return result
def save_settings(factory: sessionmaker[Session], settings: AppSettings) -> None:
with session_scope(factory) as s:
for key, value in asdict(settings).items():
row = s.get(Setting, _PREFIX + key)
encoded = json.dumps(value.value if isinstance(value, StrEnum) else value)
if row is None:
s.add(Setting(key=_PREFIX + key, value=encoded))
else:
row.value = encoded
def get_value(factory: sessionmaker[Session], key: str) -> str | None:
with session_scope(factory) as s:
row = s.get(Setting, key)
return row.value if row else None
def set_value(factory: sessionmaker[Session], key: str, value: str) -> None:
with session_scope(factory) as s:
row = s.get(Setting, key)
if row is None:
s.add(Setting(key=key, value=value))
else:
row.value = value