Implement Monlet MVP stack and UI updates

This commit is contained in:
Stanislav Rossovskii
2026-05-27 10:01:59 +04:00
parent dcea096327
commit edc51e9c59
145 changed files with 15618 additions and 248 deletions

View File

@@ -0,0 +1,4 @@
from .base import DeliveryResult, Notifier
from .registry import build_registry
__all__ = ["DeliveryResult", "Notifier", "build_registry"]

View 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}")

View 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: ...

View 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)

View 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

View 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

View 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}")

View 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}")