feat(bot): show actual response on failure, flap threshold, pause/rename/baseline, komodo fleet control and alert intake

This commit is contained in:
hh
2026-07-23 00:07:43 +02:00
parent a46bd92b4c
commit bf5e8d434e
18 changed files with 1279 additions and 89 deletions
+20
View File
@@ -32,6 +32,16 @@ CREATE TABLE IF NOT EXISTS incidents (
CREATE INDEX IF NOT EXISTS idx_incidents_service ON incidents(service_id);
"""
# Колонки, добавленные после первого релиза. SQLite не умеет
# `ADD COLUMN IF NOT EXISTS`, поэтому сверяемся с PRAGMA table_info —
# иначе повторный запуск падал бы на "duplicate column name".
MIGRATIONS: tuple[tuple[str, str, str], ...] = (
("services", "fail_streak", "INTEGER NOT NULL DEFAULT 0"),
("services", "is_paused", "INTEGER NOT NULL DEFAULT 0"),
("services", "last_latency_ms", "INTEGER"),
("services", "last_checked_at", "TEXT"),
)
class Database:
def __init__(self, path: str) -> None:
@@ -46,10 +56,20 @@ class Database:
self._conn.row_factory = aiosqlite.Row
await self._conn.execute("PRAGMA foreign_keys = ON")
await self._conn.executescript(SCHEMA)
await self._migrate()
await self._conn.commit()
self.services = ServiceRepository(self._conn)
self.incidents = IncidentRepository(self._conn)
async def _migrate(self) -> None:
assert self._conn is not None
for table, column, decl in MIGRATIONS:
cursor = await self._conn.execute(f"PRAGMA table_info({table})")
existing = {row["name"] for row in await cursor.fetchall()}
if column in existing:
continue
await self._conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} {decl}")
async def close(self) -> None:
if self._conn is not None:
await self._conn.close()
+14
View File
@@ -20,6 +20,14 @@ class Service:
json_value: str | None
is_up: bool
created_at: str
# Сколько проверок подряд провалилось. Инцидент открывается, только когда
# счётчик дорастает до monitor.failure_threshold, — см. env.py.
fail_streak: int = 0
# Пауза: сервис остаётся в списке, но не пингуется и не будит ночью.
# Нужно на время плановых работ, чтобы не удалять сервис со статистикой.
is_paused: bool = False
last_latency_ms: int | None = None
last_checked_at: str | None = None
@property
def rule(self) -> str:
@@ -29,6 +37,12 @@ class Service:
return f"<code>{self.json_path}</code> = <code>{self.json_value}</code>"
return "тело ответа неизменно"
@property
def mark(self) -> str:
if self.is_paused:
return ""
return "🟢" if self.is_up else "🔴"
@dataclass(slots=True)
class Incident:
+45 -1
View File
@@ -5,7 +5,8 @@ from utils.format import now
_COLUMNS = (
"id, name, url, match_type, ok_status, ok_body, "
"json_path, json_value, is_up, created_at"
"json_path, json_value, is_up, created_at, "
"fail_streak, is_paused, last_latency_ms, last_checked_at"
)
@@ -21,6 +22,10 @@ def _row_to_service(row: aiosqlite.Row) -> Service:
json_value=row["json_value"],
is_up=bool(row["is_up"]),
created_at=row["created_at"],
fail_streak=row["fail_streak"],
is_paused=bool(row["is_paused"]),
last_latency_ms=row["last_latency_ms"],
last_checked_at=row["last_checked_at"],
)
@@ -87,6 +92,45 @@ class ServiceRepository:
)
await self._conn.commit()
async def set_streak(self, service_id: int, streak: int) -> None:
await self._conn.execute(
"UPDATE services SET fail_streak = ? WHERE id = ?", (streak, service_id)
)
await self._conn.commit()
async def set_paused(self, service_id: int, *, is_paused: bool) -> None:
# Снятие с паузы обнуляет счётчик неудач: пока сервис стоял,
# его никто не проверял, и старая серия ничего не значит.
await self._conn.execute(
"UPDATE services SET is_paused = ?, fail_streak = 0 WHERE id = ?",
(int(is_paused), service_id),
)
await self._conn.commit()
async def touch_probe(self, service_id: int, latency_ms: int | None) -> None:
await self._conn.execute(
"UPDATE services SET last_latency_ms = ?, last_checked_at = ? WHERE id = ?",
(latency_ms, now().isoformat(), service_id),
)
await self._conn.commit()
async def rename(self, service_id: int, name: str) -> None:
await self._conn.execute(
"UPDATE services SET name = ? WHERE id = ?", (name, service_id)
)
await self._conn.commit()
async def set_baseline(
self, service_id: int, *, ok_status: int, ok_body: str, json_value: str | None
) -> None:
"""Переснять эталон с текущего ответа."""
await self._conn.execute(
"UPDATE services SET ok_status = ?, ok_body = ?, json_value = ? "
"WHERE id = ?",
(ok_status, ok_body, json_value, service_id),
)
await self._conn.commit()
async def delete(self, service_id: int) -> None:
await self._conn.execute("DELETE FROM services WHERE id = ?", (service_id,))
await self._conn.commit()
+47 -2
View File
@@ -1,4 +1,4 @@
from pydantic import Field, SecretStr
from pydantic import Field, SecretStr, computed_field
from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -11,6 +11,40 @@ class MonitorSettings(BaseSettings):
reminder_interval: int = 1800 # как часто напоминать что всё ещё лежит, сек
request_timeout: int = 10 # таймаут одного запроса, сек
# Сколько проверок подряд должны провалиться, прежде чем объявить падение.
# Одна неудачная проверка — это обычно моргнувшая сеть, а не упавший
# сервис. При 1 бот будит по любому чиху; при 2 ложные тревоги почти
# исчезают, а реакция замедляется всего на один интервал.
failure_threshold: int = 2
# Сколько символов ответа показывать в уведомлении.
body_preview: int = 400
class KomodoSettings(BaseSettings):
"""Связь с Komodo. Всё опционально — без неё бот работает как раньше."""
url: str = "" # https://komod.example.com
key: SecretStr = SecretStr("")
secret: SecretStr = SecretStr("")
timeout: int = 20
@computed_field
@property
def enabled(self) -> bool:
return bool(self.url and self.key.get_secret_value())
class WebSettings(BaseSettings):
"""Приёмник входящих алертов (Komodo Alerter с endpoint type Custom)."""
enabled: bool = False
host: str = "0.0.0.0" # noqa: S104
port: int = 8080
# Общий секрет. Komodo произвольные заголовки слать не умеет, поэтому
# принимаем и в X-Alert-Token, и в query-параметре ?token=
token: SecretStr = SecretStr("")
class DbSettings(BaseSettings):
path: str = "data/healthbot.db"
@@ -24,9 +58,15 @@ class LogSettings(BaseSettings):
class Settings(BaseSettings):
admin_id: int
# Оставлено для совместимости: раньше админ был ровно один.
admin_id: int = 0
# Несколько админов — чтобы бот годился не только владельцу-одиночке.
admin_ids: list[int] = Field(default_factory=list)
bot: BotSettings = Field(default_factory=BotSettings)
monitor: MonitorSettings = Field(default_factory=MonitorSettings)
komodo: KomodoSettings = Field(default_factory=KomodoSettings)
web: WebSettings = Field(default_factory=WebSettings)
db: DbSettings = Field(default_factory=DbSettings)
log: LogSettings = Field(default_factory=LogSettings)
@@ -34,5 +74,10 @@ class Settings(BaseSettings):
case_sensitive=False, env_file=".env", env_nested_delimiter="__", extra="ignore"
)
@computed_field
@property
def admins(self) -> set[int]:
return {i for i in (self.admin_id, *self.admin_ids) if i}
env = Settings()