CI / ruff · mypy · pytest (pull_request) Successful in 4m49s
- детекторы по состоянию после каждого раунда снапшотов: краш, OOM, цикл рестартов, unhealthy, перегрузка ≥ 1 мин, мало места, Docker недоступен; пороги в config/alerts.yaml - AlertService: ключ kind+service, повтор в 10 мин — счётчик в исходном сообщении, «✅ восстановлено» ответом; разовые события (🛡) со счётчиком - рассылка всем активным (🛡 — только админам), темп ~25/с, RetryAfter, заблокировавший бота пропускается; alert_deliveries для правок и ответов - «🚨 Алерты»: активные + 7 дней по 10, карточка; кнопки «📜 Логи», «🩺 Статус» под алертом открывают новое сообщение, а не затирают алерт - 🛡 атаки через агента идут через общий сервис алертов - убраны заглушки «Раздел в разработке» — у всех разделов свои роутеры Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
65 lines
2.2 KiB
Python
65 lines
2.2 KiB
Python
"""Glue: after each snapshot round run the detectors and sync open alerts (hooked into `snapshot_loop`)."""
|
|
|
|
import os
|
|
from collections.abc import Callable
|
|
from datetime import datetime, timedelta
|
|
from pathlib import Path
|
|
|
|
from app.alerts.detectors import (
|
|
MONITORED,
|
|
AlertsConfig,
|
|
Condition,
|
|
container_conditions,
|
|
disk_condition,
|
|
docker_condition,
|
|
)
|
|
from app.alerts.service import AlertService
|
|
from app.db.models import AlertKind, utcnow
|
|
from app.runtime.snapshots import Observation, SnapshotStore
|
|
|
|
WITHOUT_DOCKER = frozenset({AlertKind.DEPENDENCY_DOWN, AlertKind.DISK_LOW})
|
|
|
|
|
|
def free_disk_pct(path: Path) -> float | None:
|
|
try:
|
|
st = os.statvfs(path)
|
|
except OSError:
|
|
return None
|
|
return 100.0 * st.f_bavail / st.f_blocks if st.f_blocks else None
|
|
|
|
|
|
class AlertMonitor:
|
|
def __init__(
|
|
self,
|
|
alerts: AlertService,
|
|
store: SnapshotStore,
|
|
cfg: AlertsConfig,
|
|
disk: Callable[[Path], float | None] = free_disk_pct,
|
|
clock: Callable[[], datetime] = utcnow,
|
|
) -> None:
|
|
self._alerts = alerts
|
|
self._store = store
|
|
self._cfg = cfg
|
|
self._disk = disk
|
|
self._clock = clock
|
|
self._docker_down_since: datetime | None = None
|
|
|
|
@property
|
|
def _history(self) -> timedelta:
|
|
return timedelta(seconds=max(self._cfg.restart_loop_window, self._cfg.overload_for) + 60)
|
|
|
|
async def on_round(self, observations: list[Observation] | None) -> None:
|
|
now = self._clock()
|
|
conditions: list[Condition] = disk_condition(self._disk(self._cfg.disk_path), self._cfg)
|
|
if observations is None:
|
|
# Docker is unreachable: container alerts can't be judged — leave them as they are
|
|
self._docker_down_since = self._docker_down_since or now
|
|
conditions += docker_condition(self._docker_down_since, self._cfg, now)
|
|
await self._alerts.sync(conditions, WITHOUT_DOCKER, now)
|
|
return
|
|
self._docker_down_since = None
|
|
for o in observations:
|
|
history = await self._store.since(o.details.name, now - self._history)
|
|
conditions += container_conditions(o, history, self._cfg, now)
|
|
await self._alerts.sync(conditions, MONITORED, now)
|