feat(bot): show actual response on failure, flap threshold, pause/rename/baseline, komodo fleet control and alert intake
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user