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:
co-authored by
Claude Opus 5.5
parent
642def66bd
commit
f45cb56aa0
@@ -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))
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user