Implement Monlet MVP stack and UI updates
This commit is contained in:
4
server/monlet_server/services/notifiers/__init__.py
Normal file
4
server/monlet_server/services/notifiers/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from .base import DeliveryResult, Notifier
|
||||
from .registry import build_registry
|
||||
|
||||
__all__ = ["DeliveryResult", "Notifier", "build_registry"]
|
||||
45
server/monlet_server/services/notifiers/alertmanager.py
Normal file
45
server/monlet_server/services/notifiers/alertmanager.py
Normal file
@@ -0,0 +1,45 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
|
||||
from ...redaction import redact
|
||||
from .base import DeliveryResult
|
||||
from .http import classify_response
|
||||
|
||||
|
||||
class AlertmanagerNotifier:
|
||||
name = "alertmanager"
|
||||
|
||||
def __init__(self, client: httpx.AsyncClient, url: str) -> None:
|
||||
self._client = client
|
||||
self._url = url.rstrip("/")
|
||||
|
||||
async def deliver(self, event_type: str, payload: dict) -> DeliveryResult:
|
||||
labels = {
|
||||
"alertname": "monlet_incident",
|
||||
"agent_id": str(payload.get("agent_id", "")),
|
||||
"check_id": str(payload.get("check_id", "")),
|
||||
"incident_key": str(payload.get("incident_key", "")),
|
||||
}
|
||||
annotations = {
|
||||
"severity": str(payload.get("severity", "unknown")),
|
||||
"status": str(payload.get("status", "")),
|
||||
"summary": redact(payload.get("summary") or "") or "",
|
||||
}
|
||||
if payload.get("output"):
|
||||
annotations["output"] = (redact(payload["output"]) or "")[:1024]
|
||||
alert = {
|
||||
"labels": labels,
|
||||
"annotations": annotations,
|
||||
"startsAt": payload.get("opened_at") or payload.get("observed_at"),
|
||||
}
|
||||
if event_type == "resolved" and payload.get("resolved_at"):
|
||||
alert["endsAt"] = payload["resolved_at"]
|
||||
try:
|
||||
resp = await self._client.post(f"{self._url}/api/v2/alerts", json=[alert])
|
||||
except httpx.HTTPError as exc:
|
||||
return DeliveryResult(ok=False, permanent=False, error=type(exc).__name__)
|
||||
ok, perm = classify_response(resp.status_code)
|
||||
if ok:
|
||||
return DeliveryResult(ok=True)
|
||||
return DeliveryResult(ok=False, permanent=perm, error=f"http_{resp.status_code}")
|
||||
17
server/monlet_server/services/notifiers/base.py
Normal file
17
server/monlet_server/services/notifiers/base.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DeliveryResult:
|
||||
ok: bool
|
||||
permanent: bool = False
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class Notifier(Protocol):
|
||||
name: str
|
||||
|
||||
async def deliver(self, event_type: str, payload: dict) -> DeliveryResult: ...
|
||||
14
server/monlet_server/services/notifiers/debug.py
Normal file
14
server/monlet_server/services/notifiers/debug.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from ...logging_config import get_logger
|
||||
from ...redaction import redact_dict
|
||||
from .base import DeliveryResult
|
||||
|
||||
|
||||
class DebugNotifier:
|
||||
name = "debug"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._log = get_logger("monlet.notifier.debug")
|
||||
|
||||
async def deliver(self, event_type: str, payload: dict) -> DeliveryResult:
|
||||
self._log.info("notify", event_type=event_type, payload=redact_dict(payload))
|
||||
return DeliveryResult(ok=True)
|
||||
18
server/monlet_server/services/notifiers/http.py
Normal file
18
server/monlet_server/services/notifiers/http.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
def classify_response(status: int) -> tuple[bool, bool]:
|
||||
"""Return (ok, permanent_failure)."""
|
||||
if 200 <= status < 300:
|
||||
return True, False
|
||||
if 400 <= status < 500 and status not in (408, 425, 429):
|
||||
return False, True
|
||||
return False, False
|
||||
|
||||
|
||||
def classify_exception(exc: Exception) -> tuple[bool, bool]:
|
||||
if isinstance(exc, httpx.TimeoutException | httpx.NetworkError):
|
||||
return False, False
|
||||
return False, False
|
||||
31
server/monlet_server/services/notifiers/registry.py
Normal file
31
server/monlet_server/services/notifiers/registry.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
|
||||
from ...settings import Settings
|
||||
from .alertmanager import AlertmanagerNotifier
|
||||
from .base import Notifier
|
||||
from .debug import DebugNotifier
|
||||
from .telegram import TelegramNotifier
|
||||
from .webhook import WebhookNotifier
|
||||
|
||||
|
||||
def build_registry(settings: Settings, client: httpx.AsyncClient) -> dict[str, Notifier]:
|
||||
reg: dict[str, Notifier] = {}
|
||||
if settings.notifier_debug_enabled:
|
||||
reg["debug"] = DebugNotifier()
|
||||
if (
|
||||
settings.notifier_telegram_enabled
|
||||
and settings.notifier_telegram_token
|
||||
and settings.notifier_telegram_chat_id
|
||||
):
|
||||
reg["telegram"] = TelegramNotifier(
|
||||
client, settings.notifier_telegram_token, settings.notifier_telegram_chat_id
|
||||
)
|
||||
if settings.notifier_webhook_enabled and settings.notifier_webhook_url:
|
||||
reg["webhook"] = WebhookNotifier(
|
||||
client, settings.notifier_webhook_url, settings.notifier_webhook_token
|
||||
)
|
||||
if settings.notifier_alertmanager_enabled and settings.notifier_alertmanager_url:
|
||||
reg["alertmanager"] = AlertmanagerNotifier(client, settings.notifier_alertmanager_url)
|
||||
return reg
|
||||
47
server/monlet_server/services/notifiers/telegram.py
Normal file
47
server/monlet_server/services/notifiers/telegram.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
|
||||
from ...redaction import redact
|
||||
from .base import DeliveryResult
|
||||
from .http import classify_response
|
||||
|
||||
|
||||
def _format_message(event_type: str, payload: dict) -> str:
|
||||
icon = "[FIRING]" if event_type == "firing" else "[RESOLVED]"
|
||||
sev = payload.get("severity", "unknown")
|
||||
agent = payload.get("agent_id", "?")
|
||||
check = payload.get("check_id", "?")
|
||||
status = payload.get("status", "?")
|
||||
incident_key = payload.get("incident_key", "?")
|
||||
output = payload.get("output") or ""
|
||||
lines = [
|
||||
f"{icon} {sev} {agent}/{check}",
|
||||
f"status: {status}",
|
||||
f"incident_key: {incident_key}",
|
||||
f"observed_at: {payload.get('observed_at', '?')}",
|
||||
]
|
||||
if output:
|
||||
lines.append(f"output: {output[:300]}")
|
||||
return redact("\n".join(lines)) or ""
|
||||
|
||||
|
||||
class TelegramNotifier:
|
||||
name = "telegram"
|
||||
|
||||
def __init__(self, client: httpx.AsyncClient, token: str, chat_id: str) -> None:
|
||||
self._client = client
|
||||
self._token = token
|
||||
self._chat_id = chat_id
|
||||
|
||||
async def deliver(self, event_type: str, payload: dict) -> DeliveryResult:
|
||||
url = f"https://api.telegram.org/bot{self._token}/sendMessage"
|
||||
body = {"chat_id": self._chat_id, "text": _format_message(event_type, payload)}
|
||||
try:
|
||||
resp = await self._client.post(url, json=body)
|
||||
except httpx.HTTPError as exc:
|
||||
return DeliveryResult(ok=False, permanent=False, error=type(exc).__name__)
|
||||
ok, perm = classify_response(resp.status_code)
|
||||
if ok:
|
||||
return DeliveryResult(ok=True)
|
||||
return DeliveryResult(ok=False, permanent=perm, error=f"http_{resp.status_code}")
|
||||
30
server/monlet_server/services/notifiers/webhook.py
Normal file
30
server/monlet_server/services/notifiers/webhook.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
|
||||
from ...redaction import redact_dict
|
||||
from .base import DeliveryResult
|
||||
from .http import classify_response
|
||||
|
||||
|
||||
class WebhookNotifier:
|
||||
name = "webhook"
|
||||
|
||||
def __init__(self, client: httpx.AsyncClient, url: str, token: str = "") -> None:
|
||||
self._client = client
|
||||
self._url = url
|
||||
self._token = token
|
||||
|
||||
async def deliver(self, event_type: str, payload: dict) -> DeliveryResult:
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if self._token:
|
||||
headers["Authorization"] = f"Bearer {self._token}"
|
||||
body = {"event_type": event_type, "incident": redact_dict(payload)}
|
||||
try:
|
||||
resp = await self._client.post(self._url, json=body, headers=headers)
|
||||
except httpx.HTTPError as exc:
|
||||
return DeliveryResult(ok=False, permanent=False, error=type(exc).__name__)
|
||||
ok, perm = classify_response(resp.status_code)
|
||||
if ok:
|
||||
return DeliveryResult(ok=True)
|
||||
return DeliveryResult(ok=False, permanent=perm, error=f"http_{resp.status_code}")
|
||||
Reference in New Issue
Block a user