diff --git a/pyproject.toml b/pyproject.toml
index c304067..a08f2ed 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -11,7 +11,9 @@ dependencies = [
"aiohttp>=3.10.0",
"aiosqlite>=0.20.0",
"pydantic-settings>=2.14.2",
+ "pyyaml>=6.0.3",
"rich>=15.0.0",
+ "tzdata>=2026.5",
]
[build-system]
diff --git a/src/healthbot/__init__.py b/src/healthbot/__init__.py
index 0e8e038..5a2125b 100644
--- a/src/healthbot/__init__.py
+++ b/src/healthbot/__init__.py
@@ -12,6 +12,7 @@ async def runner() -> None:
from . import handlers # noqa: PLC0415
from .common import bot, dp # noqa: PLC0415
+ from .digest import run_digest # noqa: PLC0415
from .komodo import komodo # noqa: PLC0415
from .monitor import run_monitor # noqa: PLC0415
from .web import run_web # noqa: PLC0415
@@ -20,14 +21,16 @@ async def runner() -> None:
dp.include_routers(handlers.router)
await bot.delete_webhook(drop_pending_updates=True)
- monitor = asyncio.create_task(run_monitor())
+ background = [asyncio.create_task(run_monitor()), asyncio.create_task(run_digest())]
web = await run_web() if env.web.enabled else None
try:
await dp.start_polling(bot)
finally:
- monitor.cancel()
- with contextlib.suppress(asyncio.CancelledError):
- await monitor
+ for task in background:
+ task.cancel()
+ for task in background:
+ with contextlib.suppress(asyncio.CancelledError):
+ await task
if web is not None:
await web.cleanup()
await komodo.close()
diff --git a/src/healthbot/actionargs.py b/src/healthbot/actionargs.py
new file mode 100644
index 0000000..3e1afaf
--- /dev/null
+++ b/src/healthbot/actionargs.py
@@ -0,0 +1,186 @@
+import json
+import re
+import shlex
+import tomllib
+from typing import Any
+
+import yaml
+
+QUOTES = str.maketrans({"“": '"', "”": '"', "„": '"', "«": '"', "»": '"'})
+KEY_RE = re.compile(r"[\w.-]+")
+TRUE = {"true", "yes", "on", "1", "да", "вкл"}
+FALSE = {"false", "no", "off", "0", "нет", "выкл"}
+VARIABLE_MARK = "[["
+DASHES = "-\u2014\u2013"
+
+
+class ArgError(ValueError):
+ pass
+
+
+def _key_value(text: str) -> dict[str, Any]:
+ result: dict[str, Any] = {}
+ for raw in text.splitlines():
+ line = raw.strip().removeprefix("- ").strip()
+ if not line or line.startswith("#"):
+ continue
+ match = re.match(r"([^=:\s]+)\s*[=:]\s*(.*)", line)
+ if not match:
+ continue
+ value = match.group(2).strip()
+ if len(value) > 1 and value[0] == value[-1] and value[0] in "\"'":
+ value = value[1:-1]
+ result[match.group(1)] = value
+ return result
+
+
+def parse_defaults(fmt: str, text: str) -> dict[str, Any]:
+ if not text.strip():
+ return {}
+ try:
+ if fmt == "json":
+ data = json.loads(text)
+ elif fmt == "toml":
+ data = tomllib.loads(text)
+ elif fmt == "yaml":
+ data = yaml.safe_load(text)
+ else:
+ data = _key_value(text)
+ except (ValueError, yaml.YAMLError):
+ return {}
+ if not isinstance(data, dict):
+ return {}
+ return json.loads(json.dumps(data, default=str))
+
+
+def type_name(value: object) -> str:
+ if isinstance(value, bool):
+ return "да или нет"
+ if isinstance(value, (int, float)):
+ return "число"
+ if isinstance(value, str):
+ return "текст"
+ return "JSON"
+
+
+def render(value: object, limit: int = 0) -> str:
+ text = json.dumps(value, ensure_ascii=False)
+ return text if not limit or len(text) <= limit else text[: limit - 1] + "…"
+
+
+def _guess(text: str) -> Any: # noqa: ANN401
+ try:
+ return json.loads(text)
+ except ValueError:
+ return text
+
+
+def _to_bool(text: str) -> bool:
+ lowered = text.strip().lower()
+ if lowered in TRUE:
+ return True
+ if lowered in FALSE:
+ return False
+ msg = "нужно true или false"
+ raise ArgError(msg)
+
+
+def _to_number(text: str, *, keep_float: bool) -> int | float:
+ try:
+ number = float(text.strip().replace(",", "."))
+ except ValueError as exc:
+ msg = "нужно число"
+ raise ArgError(msg) from exc
+ return number if keep_float or not number.is_integer() else int(number)
+
+
+def _to_json(default: object, text: str) -> Any: # noqa: ANN401
+ try:
+ value = json.loads(text)
+ except ValueError as exc:
+ msg = "нужен JSON"
+ raise ArgError(msg) from exc
+ if type(value) is not type(default):
+ msg = "нужен JSON-объект" if isinstance(default, dict) else "нужен JSON-массив"
+ raise ArgError(msg)
+ return value
+
+
+def coerce(default: object, text: str) -> Any: # noqa: ANN401
+ if isinstance(default, bool):
+ return _to_bool(text)
+ if isinstance(default, (int, float)):
+ return _to_number(text, keep_float=isinstance(default, float))
+ if isinstance(default, str):
+ return text
+ if default is None:
+ return _guess(text)
+ return _to_json(default, text)
+
+
+def _tokens(text: str) -> list[str]:
+ try:
+ return shlex.split(text.translate(QUOTES))
+ except ValueError as exc:
+ msg = "кавычка не закрыта"
+ raise ArgError(msg) from exc
+
+
+def _pairs(tokens: list[str], defaults: dict[str, Any]) -> list[tuple[str, str]]:
+ pairs: list[tuple[str, str]] = []
+ index = 0
+ while index < len(tokens):
+ token = tokens[index].lstrip(DASHES)
+ key, sep, value = token.partition("=")
+ inline = bool(sep) and bool(KEY_RE.fullmatch(key))
+ name = key if inline else token
+ index += 1
+ if defaults and name not in defaults:
+ msg = f"нет поля {name}, есть: {', '.join(defaults)}"
+ raise ArgError(msg)
+ if inline:
+ pairs.append((key, value))
+ elif isinstance(defaults.get(token), bool) and not (
+ index < len(tokens) and tokens[index].lower() in TRUE | FALSE
+ ):
+ pairs.append((token, "true"))
+ elif index < len(tokens):
+ pairs.append((token, tokens[index]))
+ index += 1
+ else:
+ msg = f"у поля {token} нет значения"
+ raise ArgError(msg)
+ return pairs
+
+
+def parse_cli(text: str, defaults: dict[str, Any]) -> dict[str, Any]:
+ text = text.strip()
+ if not text:
+ return {}
+ tokens = _tokens(text)
+ first = tokens[0].lstrip(DASHES).partition("=")[0]
+ if len(defaults) == 1 and first not in defaults:
+ key = next(iter(defaults))
+ return {key: coerce(defaults[key], " ".join(tokens))}
+ result: dict[str, Any] = {}
+ for key, value in _pairs(tokens, defaults):
+ try:
+ result[key] = coerce(defaults[key], value) if defaults else _guess(value)
+ except ArgError as exc:
+ msg = f"{key}: {exc}"
+ raise ArgError(msg) from exc
+ return result
+
+
+def changed(defaults: dict[str, Any], values: dict[str, Any]) -> dict[str, Any]:
+ return {k: v for k, v in values.items() if k not in defaults or defaults[k] != v}
+
+
+def ensure_safe(args: dict[str, Any]) -> None:
+ if VARIABLE_MARK in json.dumps(args, ensure_ascii=False):
+ msg = "в значениях нельзя «[[»: Komodo читает это как переменную"
+ raise ArgError(msg)
+
+
+def command_name(name: str) -> str:
+ return re.sub(r"[^a-z0-9_]", "_", name.lower()).strip("_")[:32]
diff --git a/src/healthbot/alerts.py b/src/healthbot/alerts.py
index 595dab5..9e5f561 100644
--- a/src/healthbot/alerts.py
+++ b/src/healthbot/alerts.py
@@ -1,8 +1,11 @@
-"""Превращение алерта Komodo в сообщение для телеграма."""
-
-import html
from typing import Any
+from aiogram.types import InlineKeyboardMarkup
+from aiogram.utils.keyboard import InlineKeyboardBuilder
+
+from healthbot.ui import esc
+from utils.env import env
+
LEVEL_ICON = {"OK": "✅", "WARNING": "🟠", "CRITICAL": "🔴"}
INFORMATIONAL = frozenset(
@@ -21,13 +24,33 @@ FAILURE = frozenset(
{"ProcedureFailed", "ActionFailed", "BuildFailed", "RepoBuildFailed"}
)
-# Komodo создаёт их сразу с resolved=true (resolved_ts == ts): это факт
-# «состояние сменилось», а не тревога, которую потом снимают. Без этого
-# и running → unhealthy, и unhealthy → running приходили как «Отбой».
STATE_CHANGE = frozenset({"StackStateChange", "ContainerStateChange"})
HEALTHY_STATES = frozenset({"running", "healthy"})
-ONE_SHOT = INFORMATIONAL | FAILURE | STATE_CHANGE
+CREATED_ALREADY_RESOLVED = INFORMATIONAL | FAILURE | STATE_CHANGE
+
+KNOWN_TYPES = (
+ "ServerUnreachable",
+ "ServerCpu",
+ "ServerMem",
+ "ServerDisk",
+ "ServerVersionMismatch",
+ "StackStateChange",
+ "ContainerStateChange",
+ "StackImageUpdateAvailable",
+ "DeploymentImageUpdateAvailable",
+ "StackAutoUpdated",
+ "DeploymentAutoUpdated",
+ "ResourceSyncPendingUpdates",
+ "ProcedureFailed",
+ "ActionFailed",
+ "BuildFailed",
+ "RepoBuildFailed",
+ "ScheduleRun",
+ "Custom",
+)
+
+CALLBACK_LIMIT = 64
def _gb(value: Any) -> str: # noqa: ANN401
@@ -44,79 +67,88 @@ def _pct(value: Any) -> str: # noqa: ANN401
return str(value)
+def _error(value: Any) -> str: # noqa: ANN401
+ if isinstance(value, dict):
+ return str(value.get("error") or value)
+ return str(value or "нет деталей")
+
+
def _describe(kind: str, d: dict) -> tuple[str, list[str]]: # noqa: C901, PLR0911, PLR0912
- """Заголовок и строки подробностей для конкретного типа алерта."""
name = d.get("name") or d.get("id") or "?"
server = d.get("server_name")
if kind == "ServerUnreachable":
- return f"Сервер {html.escape(name)} не отвечает", [
- f"Ошибка: {html.escape(str(d.get('err') or 'нет деталей'))}"
+ return f"Сервер {esc(name)} не отвечает", [
+ f"Ошибка: {esc(_error(d.get('err')))}"
]
if kind == "ServerCpu":
- return f"CPU на {html.escape(name)}", [
- f"Загрузка: {_pct(d.get('percentage'))}"
- ]
+ return f"CPU на {esc(name)}", [f"Загрузка: {_pct(d.get('percentage'))}"]
if kind == "ServerMem":
- return f"Память на {html.escape(name)}", [
+ return f"Память на {esc(name)}", [
f"Занято: {_gb(d.get('used_gb'))} из {_gb(d.get('total_gb'))}"
]
if kind == "ServerDisk":
- return f"Диск на {html.escape(name)}", [
- f"Точка: {html.escape(str(d.get('path', '/')))}",
+ return f"Диск на {esc(name)}", [
+ f"Точка: {esc(d.get('path', '/'))}",
f"Занято: {_gb(d.get('used_gb'))} из {_gb(d.get('total_gb'))}",
]
if kind == "ServerVersionMismatch":
- return f"Разъехались версии на {html.escape(name)}", [
- f"Periphery: {d.get('version')}, Core: {d.get('core_version')}",
+ return f"Разъехались версии на {esc(name)}", [
+ f"Periphery: {esc(d.get('version'))}, Core: {esc(d.get('core_version'))}",
"Чинится только по ssh - дашборд к такому серверу не достучится.",
]
- if kind in {"StackStateChange", "ContainerStateChange"}:
- return f"{html.escape(name)}: {d.get('from')} → {d.get('to')}", (
- [f"Сервер: {html.escape(server)}"] if server else []
+ if kind in STATE_CHANGE:
+ return f"{esc(name)}: {esc(d.get('from'))} → {esc(d.get('to'))}", (
+ [f"Сервер: {esc(server)}"] if server else []
)
if kind in {"StackImageUpdateAvailable", "DeploymentImageUpdateAvailable"}:
- return f"Обновление образа для {html.escape(name)}", [
- f"{html.escape(str(d.get('image', '?')))}"
+ return f"Обновление образа для {esc(name)}", [
+ f"{esc(d.get('image', '?'))}"
]
if kind in {"StackAutoUpdated", "DeploymentAutoUpdated"}:
- return f"{html.escape(name)} обновлён автоматически", [
- f"{html.escape(str(d.get('images') or d.get('image') or ''))}"
+ images = d.get("images") or d.get("image") or ""
+ if isinstance(images, list):
+ images = ", ".join(str(image) for image in images)
+ return f"{esc(name)} обновлён автоматически", [
+ f"{esc(images)}"
]
if kind == "ResourceSyncPendingUpdates":
- return f"Синк {html.escape(name)}: git разошёлся с Komodo", [
+ return f"Синк {esc(name)}: git разошёлся с Komodo", [
"Кто-то правил ресурсы мимо git, либо пуш не доехал."
]
- if kind in {"ProcedureFailed", "ActionFailed", "BuildFailed", "RepoBuildFailed"}:
+ if kind in FAILURE:
what = {"ProcedureFailed": "Процедура", "ActionFailed": "Действие"}.get(
kind, "Сборка"
)
- return f"{what} {html.escape(name)} упала", []
+ fell = "упало" if kind == "ActionFailed" else "упала"
+ return f"{what} {esc(name)} {fell}", []
if kind == "ScheduleRun":
- return f"По расписанию запущено: {html.escape(name)}", []
+ return f"По расписанию запущено: {esc(name)}", []
if kind == "Test":
return "Проверка связи с Komodo", ["Если ты это видишь - канал работает."]
if kind == "Custom":
- return html.escape(str(d.get("title") or "Сообщение из Komodo")), [
- html.escape(str(d.get("body") or ""))
- ]
+ return esc(d.get("title") or "Сообщение из Komodo"), [esc(d.get("body") or "")]
rest = [
- f"{html.escape(str(k))}: {html.escape(str(v))}"
+ f"{esc(k)}: {esc(v)}"
for k, v in d.items()
if k not in {"id", "server_id", "swarm_id"}
]
- return f"{html.escape(kind)}: {html.escape(name)}", rest[:8]
+ return f"{esc(kind)}: {esc(name)}", rest[:8]
+
+
+def alert_kind(payload: dict) -> str:
+ return str((payload.get("data") or {}).get("type") or "Unknown")
def format_alert(payload: dict) -> str:
level = str(payload.get("level", "")).upper()
data = payload.get("data") or {}
- kind = str(data.get("type") or "Unknown")
+ kind = alert_kind(payload)
inner = data.get("data") or {}
informational = kind in INFORMATIONAL
- resolved = bool(payload.get("resolved")) and kind not in ONE_SHOT
+ resolved = bool(payload.get("resolved")) and kind not in CREATED_ALREADY_RESOLVED
if informational:
icon = "ℹ️"
@@ -131,11 +163,34 @@ def format_alert(payload: dict) -> str:
title, details = _describe(kind, inner if isinstance(inner, dict) else {})
if resolved:
- return "\n".join(
- [f"{icon} Отбой: {title}", f"Komodo · {html.escape(kind)}"]
- )
+ return "\n".join([f"{icon} Отбой: {title}", f"Komodo · {esc(kind)}"])
lines = [f"{icon} {title}"]
lines.extend(x for x in details if x)
- lines.append(f"Komodo · {html.escape(kind)}")
+ lines.append(f"Komodo · {esc(kind)}")
return "\n".join(lines)
+
+
+def alert_keyboard(payload: dict) -> InlineKeyboardMarkup | None:
+ kind = alert_kind(payload)
+ target = payload.get("target") or {}
+ target_type, target_id = target.get("type"), target.get("id")
+ kb = InlineKeyboardBuilder()
+ if env.komodo.enabled and target_id:
+ if target_type == "Stack":
+ kb.button(text="Стек", callback_data=f"go:stack:{target_id}")
+ kb.button(text="Логи", callback_data=f"go:log:{target_id}")
+ elif target_type == "Server":
+ kb.button(text="Сервер", callback_data=f"go:srv:{target_id}")
+ elif target_type == "Action":
+ kb.button(text="Действие", callback_data=f"go:act:a:{target_id}")
+ elif target_type == "Procedure":
+ kb.button(text="Процедура", callback_data=f"go:act:p:{target_id}")
+ mute = f"al:mute:{kind}"
+ if kind != "Test" and len(mute) <= CALLBACK_LIMIT:
+ kb.button(text="Не присылать такие", callback_data=mute)
+ buttons = len(list(kb.buttons))
+ if not buttons:
+ return None
+ kb.adjust(buttons - 1 or 1, 1)
+ return kb.as_markup()
diff --git a/src/healthbot/checker.py b/src/healthbot/checker.py
index 9882385..0b03646 100644
--- a/src/healthbot/checker.py
+++ b/src/healthbot/checker.py
@@ -1,21 +1,27 @@
-"""HTTP-пробинг сервисов и логика сравнения ответа с эталоном."""
-
+import asyncio
import difflib
-import html
import json
+import ssl
import time
from dataclasses import dataclass, field
+from datetime import UTC, datetime
+from urllib.parse import urlparse
import aiohttp
+from healthbot.ui import esc
from utils.db.models import MatchType, Service
from utils.env import env
from utils.format import short
-_MISSING = object()
+MISSING = object()
DIFF_LINES = 12
DIFF_HEADER_LINES = 2
+BODY_LIMIT = 1024 * 1024
+CHUNK = 64 * 1024
+TLS_TIMEOUT = 10
+HEADERS = {"User-Agent": "healthbot"}
@dataclass(slots=True)
@@ -29,8 +35,6 @@ class Probe:
@dataclass(slots=True)
class Verdict:
- """Результат сравнения."""
-
healthy: bool
reason: str = ""
expected: str | None = None
@@ -38,11 +42,10 @@ class Verdict:
details: list[str] = field(default_factory=list)
def report(self) -> str:
- """Человекочитаемое объяснение для сообщения в телеграм."""
lines: list[str] = []
if self.expected is not None or self.actual is not None:
- lines.append(f"Ожидали: {html.escape(self.expected or '-')}")
- lines.append(f"Сейчас: {html.escape(self.actual or '-')}")
+ lines.append(f"Ожидали: {esc(self.expected or '-')}")
+ lines.append(f"Сейчас: {esc(self.actual or '-')}")
lines.extend(self.details)
return "\n".join(lines)
@@ -51,9 +54,19 @@ async def probe(session: aiohttp.ClientSession, url: str, timeout: int) -> Probe
started = time.monotonic()
try:
async with session.get(
- url, timeout=aiohttp.ClientTimeout(total=timeout), allow_redirects=True
+ url,
+ timeout=aiohttp.ClientTimeout(total=timeout),
+ allow_redirects=True,
+ headers=HEADERS,
) as resp:
- body = await resp.text()
+ raw = bytearray()
+ async for chunk in resp.content.iter_chunked(CHUNK):
+ raw += chunk
+ if len(raw) >= BODY_LIMIT:
+ break
+ body = bytes(raw[:BODY_LIMIT]).decode(
+ resp.charset or "utf-8", errors="replace"
+ )
return Probe(
reachable=True,
status=resp.status,
@@ -65,40 +78,65 @@ async def probe(session: aiohttp.ClientSession, url: str, timeout: int) -> Probe
return Probe(
reachable=False, status=None, body="", error=f"таймаут ({timeout}с)"
)
- except aiohttp.ClientError as exc:
+ except aiohttp.ClientConnectorError as exc:
+ reason = exc.os_error.strerror or str(exc.os_error)
+ return Probe(
+ reachable=False, status=None, body="", error=f"нет связи: {reason}"
+ )
+ except (aiohttp.ClientError, ValueError, LookupError) as exc:
return Probe(reachable=False, status=None, body="", error=f"нет связи: {exc}")
+async def tls_expiry(url: str) -> datetime | None:
+ parsed = urlparse(url)
+ if parsed.scheme != "https" or not parsed.hostname:
+ return None
+ try:
+ _, writer = await asyncio.wait_for(
+ asyncio.open_connection(
+ parsed.hostname, parsed.port or 443, ssl=ssl.create_default_context()
+ ),
+ TLS_TIMEOUT,
+ )
+ except (OSError, TimeoutError):
+ return None
+ try:
+ cert = writer.get_extra_info("peercert") or {}
+ finally:
+ writer.close()
+ not_after = cert.get("notAfter")
+ if not not_after:
+ return None
+ return datetime.fromtimestamp(ssl.cert_time_to_seconds(not_after), UTC)
+
+
def dig(data: object, path: str) -> object:
- """Достаёт значение по dot-пути (индексы списков - числами)."""
node = data
for segment in path.split("."):
if isinstance(node, dict):
if segment not in node:
- return _MISSING
+ return MISSING
node = node[segment]
elif isinstance(node, list):
if not segment.isdigit() or int(segment) >= len(node):
- return _MISSING
+ return MISSING
node = node[int(segment)]
else:
- return _MISSING
+ return MISSING
return node
def snippet(text: str, limit: int | None = None) -> list[str]:
- """Кусок ответа в
, готовый к вставке в сообщение."""
limit = limit or env.monitor.body_preview
body = text.strip()
if not body:
- return ["(пустой ответ)"]
+ return ["пустой ответ"]
clipped = body[:limit]
tail = "\n…" if len(body) > limit else ""
- return [f"{html.escape(clipped)}{tail}"]
+ return [f"{esc(clipped)}{tail}"]
def body_diff(expected: str, actual: str) -> list[str]:
- """Чем именно тело отличается от эталона."""
diff = list(
difflib.unified_diff(
expected.strip().splitlines(), actual.strip().splitlines(), lineterm="", n=0
@@ -108,57 +146,34 @@ def body_diff(expected: str, actual: str) -> list[str]:
return ["Отличие в невидимых символах.", *snippet(actual)]
shown = diff[:DIFF_LINES]
text = "\n".join(shown)
- more = f"\n… ещё {len(diff) - len(shown)} строк" if len(diff) > len(shown) else ""
- return [f"{html.escape(text)}{more}"]
+ more = f"\nещё строк: {len(diff) - len(shown)}" if len(diff) > len(shown) else ""
+ return [f"{esc(text)}{more}"]
-def evaluate(service: Service, result: Probe) -> Verdict: # noqa: PLR0911
- """Жив ли сервис - и если нет, то что именно он сейчас отдаёт."""
- if not result.reachable:
- return Verdict(healthy=False, reason=result.error or "недоступен")
-
- if service.match_type is MatchType.STATUS:
- if result.status != service.ok_status:
- return Verdict(
- healthy=False,
- reason=f"HTTP {result.status}",
- expected=f"HTTP {service.ok_status}",
- actual=f"HTTP {result.status}",
- details=snippet(result.body),
- )
- return Verdict(healthy=True)
-
- if service.match_type is MatchType.BODY:
- if result.body.strip() != service.ok_body.strip():
- return Verdict(
- healthy=False,
- reason="тело ответа изменилось",
- details=body_diff(service.ok_body, result.body),
- )
- return Verdict(healthy=True)
-
+def _json_verdict(service: Service, result: Probe) -> Verdict:
+ expected = f"{service.json_path} = {service.json_value}"
try:
data = json.loads(result.body)
except ValueError:
return Verdict(
healthy=False,
reason="ответ перестал быть JSON",
- expected=f"{service.json_path} = {service.json_value}",
+ expected=expected,
actual=f"не JSON, HTTP {result.status}",
details=snippet(result.body),
)
value = dig(data, service.json_path or "")
- if value is _MISSING:
+ if value is MISSING:
return Verdict(
healthy=False,
reason=f"нет поля {service.json_path}",
- expected=f"{service.json_path} = {service.json_value}",
+ expected=expected,
actual="поля нет в ответе",
details=snippet(result.body),
)
- current = _scalar_str(value)
+ current = scalar_str(value)
if current != service.json_value:
return Verdict(
healthy=False,
@@ -170,8 +185,56 @@ def evaluate(service: Service, result: Probe) -> Verdict: # noqa: PLR0911
return Verdict(healthy=True)
-def _scalar_str(value: object) -> str:
- """Строковое представление скаляра JSON (bool/null как в JSON)."""
+def _status_verdict(service: Service, result: Probe) -> Verdict:
+ if result.status == service.ok_status:
+ return Verdict(healthy=True)
+ return Verdict(
+ healthy=False,
+ reason=f"HTTP {result.status}",
+ expected=f"HTTP {service.ok_status}",
+ actual=f"HTTP {result.status}",
+ details=snippet(result.body),
+ )
+
+
+def _body_verdict(service: Service, result: Probe) -> Verdict:
+ if result.body.strip() == service.ok_body.strip():
+ return Verdict(healthy=True)
+ return Verdict(
+ healthy=False,
+ reason="тело ответа изменилось",
+ details=body_diff(service.ok_body, result.body),
+ )
+
+
+def _contains_verdict(service: Service, result: Probe) -> Verdict:
+ needle = service.needle or ""
+ if needle in result.body:
+ return Verdict(healthy=True)
+ return Verdict(
+ healthy=False,
+ reason=f"в ответе нет «{short(needle, 40)}»",
+ expected=f"есть {needle}",
+ actual=f"нет, HTTP {result.status}",
+ details=snippet(result.body),
+ )
+
+
+VERDICTS = {
+ MatchType.STATUS: _status_verdict,
+ MatchType.BODY: _body_verdict,
+ MatchType.CONTAINS: _contains_verdict,
+ MatchType.JSON: _json_verdict,
+}
+
+
+def evaluate(service: Service, result: Probe) -> Verdict:
+ if not result.reachable:
+ return Verdict(healthy=False, reason=result.error or "недоступен")
+ return VERDICTS[service.match_type](service, result)
+
+
+def scalar_str(value: object) -> str:
if isinstance(value, bool):
return "true" if value else "false"
if value is None:
diff --git a/src/healthbot/common.py b/src/healthbot/common.py
index d60cc9e..431cad5 100644
--- a/src/healthbot/common.py
+++ b/src/healthbot/common.py
@@ -6,7 +6,7 @@ from utils.env import env
bot = Bot(
token=env.bot.token.get_secret_value(),
- default=DefaultBotProperties(parse_mode="HTML"),
+ default=DefaultBotProperties(parse_mode="HTML", link_preview_is_disabled=True),
)
dp = Dispatcher(storage=MemoryStorage())
diff --git a/src/healthbot/digest.py b/src/healthbot/digest.py
new file mode 100644
index 0000000..c9dc289
--- /dev/null
+++ b/src/healthbot/digest.py
@@ -0,0 +1,118 @@
+import asyncio
+from datetime import timedelta
+
+from healthbot.keyboards import back_to_menu
+from healthbot.komodo import KomodoError, Update, komodo
+from healthbot.notify import notify
+from healthbot.stats import DAY, downtime
+from healthbot.ui import esc, pct, plural
+from utils.db import db
+from utils.env import env
+from utils.format import from_ms, human_duration, now, parse, stamp, zone
+from utils.logging import logger
+
+TICK = 60
+MAX_PAGES = 5
+PAGE_SIZE = 100
+SHOWN = 6
+
+
+async def _services_block() -> list[str]:
+ services = await db.services.all()
+ if not services:
+ return []
+ end = now()
+ start = end - timedelta(seconds=DAY)
+ recent = await db.incidents.since(start.isoformat())
+ window = lost = 0.0
+ rows = []
+ for service in services:
+ mine = [i for i in recent if i.service_id == service.id]
+ began = max(start, parse(service.created_at))
+ span = max(0.0, (end - began).total_seconds())
+ down = downtime(mine, began, end)
+ window += span
+ lost += down
+ if mine:
+ rows.append((down, service, len(mine)))
+ uptime = (window - lost) / window * 100 if window else None
+ falls = sum(row[2] for row in rows)
+ if not falls:
+ return [f"Сервисы: аптайм {pct(uptime)}, падений не было"]
+ word = plural(falls, "падение", "падения", "падений")
+ lines = [
+ f"Сервисы: аптайм {pct(uptime)}, {falls} {word}, "
+ f"даунтайм {human_duration(lost)}"
+ ]
+ for down, service, count in sorted(rows, key=lambda row: -row[0])[:SHOWN]:
+ lines.append(
+ f"{service.mark} {esc(service.name)}: {count} "
+ f"{plural(count, 'падение', 'падения', 'падений')}, {human_duration(down)}"
+ )
+ return lines
+
+
+async def _recent_updates(since_ms: int) -> list[Update]:
+ result: list[Update] = []
+ for page in range(MAX_PAGES):
+ chunk = await komodo.updates({"start_ts": {"$gte": since_ms}}, page)
+ result.extend(chunk)
+ if len(chunk) < PAGE_SIZE:
+ break
+ return result
+
+
+async def _fleet_block() -> list[str]:
+ since = now() - timedelta(seconds=DAY)
+ try:
+ updates, alerts, names = await asyncio.gather(
+ _recent_updates(int(since.timestamp() * 1000)),
+ komodo.open_alerts(),
+ komodo.names(),
+ )
+ except KomodoError as exc:
+ return [f"Флот: {esc(exc)}"]
+ tz = db.settings.get("tz")
+ failed = [u for u in updates if u.done and not u.success]
+ lines = [f"Флот: операций {len(updates)}, неудачных {len(failed)}"]
+ grouped: dict[tuple[str, str], list[Update]] = {}
+ for update in failed:
+ grouped.setdefault((update.operation, update.target_id), []).append(update)
+ for (operation, target_id), group in list(grouped.items())[:SHOWN]:
+ times = f" ×{len(group)}" if len(group) > 1 else ""
+ lines.append(
+ f"🔴 {esc(operation)} {esc(names.get(target_id, group[0].target_type))}"
+ f"{times}, последний раз {stamp(from_ms(group[0].start_ts), tz)}"
+ )
+ if alerts:
+ lines.append(f"Открытых алертов: {len(alerts)}")
+ return lines
+
+
+async def digest_text() -> str:
+ blocks = [await _services_block()]
+ if env.komodo.enabled:
+ blocks.append(await _fleet_block())
+ lines = ["Сводка за сутки"]
+ for block in blocks:
+ if block:
+ lines += ["", *block]
+ return "\n".join(lines)
+
+
+async def run_digest() -> None:
+ while True:
+ try:
+ hour = db.settings.get("digest")
+ local = now().astimezone(zone(db.settings.get("tz")))
+ today = local.strftime("%Y-%m-%d")
+ if (
+ hour is not None
+ and local.hour == hour
+ and db.settings.get("digest_sent") != today
+ ):
+ await db.settings.set("digest_sent", today)
+ await notify(await digest_text(), back_to_menu())
+ except Exception:
+ logger.exception("Digest failed")
+ await asyncio.sleep(TICK)
diff --git a/src/healthbot/handlers/__init__.py b/src/healthbot/handlers/__init__.py
index b4d3c2d..33d036d 100644
--- a/src/healthbot/handlers/__init__.py
+++ b/src/healthbot/handlers/__init__.py
@@ -1,8 +1,33 @@
-from aiogram import Router
+from aiogram import Router, types
-from . import add, commands, fleet
+from utils.logging import logger
+
+from . import actions, add, fleet, menu, runs, services, settings
router = Router()
-router.include_routers(commands.router, add.router, fleet.router)
+router.include_routers(
+ menu.router,
+ services.router,
+ add.router,
+ fleet.router,
+ actions.router,
+ settings.router,
+ add.free,
+ actions.commands,
+ runs.router,
+)
+
+
+@router.errors()
+async def on_error(event: types.ErrorEvent) -> bool:
+ logger.error("Handler failed", exc_info=event.exception)
+ callback = event.update.callback_query
+ if callback is not None:
+ try:
+ await callback.answer("Не получилось, подробности в логах", show_alert=True)
+ except Exception:
+ logger.debug("Callback answer failed")
+ return True
+
__all__ = ["router"]
diff --git a/src/healthbot/handlers/actions.py b/src/healthbot/handlers/actions.py
new file mode 100644
index 0000000..10eb4b6
--- /dev/null
+++ b/src/healthbot/handlers/actions.py
@@ -0,0 +1,722 @@
+from typing import Any
+
+from aiogram import Bot, F, Router, types
+from aiogram.filters import Command
+from aiogram.fsm.context import FSMContext
+from aiogram.fsm.state import State, StatesGroup
+from aiogram.utils.keyboard import InlineKeyboardBuilder
+
+from healthbot.actionargs import (
+ ArgError,
+ changed,
+ coerce,
+ command_name,
+ ensure_safe,
+ parse_cli,
+ parse_defaults,
+ render,
+ type_name,
+)
+from healthbot.filters import Admin
+from healthbot.handlers.menu import sync_commands
+from healthbot.keyboards import back_to, back_to_menu
+from healthbot.komodo import Detail, KomodoError, Resource, Update, komodo
+from healthbot.runs import track
+from healthbot.ui import (
+ ANSWER,
+ DANGER,
+ SUCCESS,
+ esc,
+ int_arg,
+ label,
+ pager,
+ pages,
+ show,
+)
+from utils.db import db
+from utils.db.models import Pin, PinMode
+from utils.format import ago, from_ms, until
+from utils.logging import logger
+
+router = Router()
+router.message.filter(Admin())
+router.callback_query.filter(Admin())
+
+PAGE = 20
+WIDE_FORM = 6
+MAX_LABEL = 40
+KIND_TITLE = {"a": "Действия", "p": "Процедуры"}
+KIND_ONE = {"a": "Действие", "p": "Процедура"}
+KIND_BACK = {"a": "К действию", "p": "К процедуре"}
+USAGE_FIELDS = 3
+RUN_STATE = {"Ok": "успешно", "Failed": "с ошибкой", "Running": "идёт сейчас"}
+MODE_TEXT = {PinMode.INSTANT: "запускается сразу", PinMode.FORM: "открывает форму"}
+
+
+class ActionFlow(StatesGroup):
+ waiting_value = State()
+ waiting_label = State()
+
+
+def enabled() -> bool:
+ return bool(komodo.enabled and db.settings.get("actions"))
+
+
+async def _guard(callback: types.CallbackQuery) -> bool:
+ if enabled():
+ return True
+ await callback.answer("Действия выключены в настройках", show_alert=True)
+ return False
+
+
+async def _fail(event: types.Message | types.CallbackQuery, exc: Exception) -> None:
+ if isinstance(event, types.CallbackQuery):
+ await event.answer(str(exc)[:190], show_alert=True)
+ else:
+ await show(event, f"🔴 {esc(exc)}", back_to_menu())
+
+
+async def list_view(kind: str, page: int) -> tuple[str, types.InlineKeyboardMarkup]:
+ items = await komodo.resources(kind)
+ other = "p" if kind == "a" else "a"
+ try:
+ other_count = len(await komodo.resources(other))
+ except KomodoError:
+ other_count = 0
+ total = pages(len(items), PAGE)
+ page = min(page, total - 1)
+ chunk = items[page * PAGE : (page + 1) * PAGE]
+
+ kb = InlineKeyboardBuilder()
+ for item in chunk:
+ kb.button(
+ text=f"{item.mark} {label(item.name, 26)}",
+ callback_data=f"act:open:{kind}:{item.id}",
+ )
+ rows = [2] * (len(chunk) // 2) + [1] * (len(chunk) % 2)
+ nav = pager(kb, f"act:list:{kind}", page, total)
+ kb.button(
+ text=f"{KIND_TITLE[other]}: {other_count}", callback_data=f"act:list:{other}:0"
+ )
+ kb.button(text="Закреплённые", callback_data="set:pins")
+ kb.button(text="Меню", callback_data="menu:main")
+ kb.adjust(*rows, *([nav] if nav else []), 2, 1)
+ text = f"{KIND_TITLE[kind]}: {len(items)}"
+ if not items:
+ text += "\nКлюч бота не видит ни одного. Нужен доступ Execute в Komodo."
+ return text, kb.as_markup()
+
+
+def _values_line(values: dict[str, Any]) -> str:
+ return ", ".join(f"{esc(k)} = {esc(render(v, 60))}" for k, v in values.items())
+
+
+def _args_code(values: dict[str, Any]) -> str:
+ if not values:
+ return ""
+ return (
+ ""
+ + esc(" ".join(f"{k}={render(v)}" for k, v in values.items()))
+ + ""
+ )
+
+
+def _run_line(resource: Resource | None) -> str:
+ if resource is None or not resource.last_run_at:
+ return "Ещё не запускалось"
+ state = RUN_STATE.get(resource.state, "")
+ return f"Последний запуск: {ago(from_ms(resource.last_run_at))}" + (
+ f", {state}" if state else ""
+ )
+
+
+def _schedule_line(detail: Detail, resource: Resource | None) -> str:
+ if not detail.schedule or not detail.schedule_enabled:
+ return ""
+ line = f"Расписание: {esc(detail.schedule)}"
+ if resource and resource.schedule_error:
+ return f"{line}, ошибка: {esc(resource.schedule_error)}"
+ if resource and resource.next_run_at:
+ return f"{line}, следующий запуск {until(from_ms(resource.next_run_at))}"
+ return line
+
+
+async def _load(kind: str, target_id: str) -> tuple[Detail, Resource | None, dict]:
+ detail = await komodo.detail(kind, target_id)
+ resource = next(
+ (r for r in await komodo.resources(kind) if r.id == detail.id), None
+ )
+ defaults = (
+ parse_defaults(detail.args_format, detail.args_text) if kind == "a" else {}
+ )
+ return detail, resource, defaults
+
+
+async def _form(state: FSMContext) -> dict[str, Any]:
+ return (await state.get_data()).get("form") or {}
+
+
+async def card_view(
+ state: FSMContext, kind: str, target_id: str, preset: dict | None = None
+) -> tuple[str, types.InlineKeyboardMarkup]:
+ detail, resource, defaults = await _load(kind, target_id)
+ form: dict[str, Any] = await _form(state)
+ if form.get("id") != detail.id or preset is not None:
+ form = {"kind": kind, "id": detail.id, "values": dict(preset or {})}
+ form["name"] = detail.name
+ form["keys"] = list(defaults)
+ values = {k: v for k, v in form["values"].items() if k in defaults}
+ form["values"] = changed(defaults, values)
+ await state.set_state(None)
+ await state.update_data(form=form)
+
+ lines = [f"{esc(detail.name)}"]
+ if detail.description:
+ lines.append(esc(detail.description))
+ if detail.stages:
+ lines += ["", "Этапы: " + " → ".join(esc(s) for s in detail.stages)]
+ if defaults:
+ lines.append("")
+ for key, default in defaults.items():
+ if key in form["values"]:
+ lines.append(
+ f"{esc(key)} = {esc(render(form['values'][key], 200))}"
+ f" (в Komodo {esc(render(default, 60))})"
+ )
+ else:
+ lines.append(f"{esc(key)} = {esc(render(default, 200))}")
+ lines += ["", _run_line(resource)]
+ schedule = _schedule_line(detail, resource)
+ if schedule:
+ lines.append(schedule)
+
+ ref = _ref(form)
+ kb = InlineKeyboardBuilder()
+ for index, key in enumerate(defaults):
+ value = form["values"].get(key, defaults[key])
+ kb.button(
+ text=label(f"{key}: {render(value)}", 40),
+ callback_data=f"act:f:{index}:{ref}",
+ )
+ width = 2 if len(defaults) > WIDE_FORM else 1
+ rows = [width] * (len(defaults) // width) + [1] * (len(defaults) % width)
+ kb.button(text="Запустить", callback_data=f"act:run:{ref}", style=SUCCESS)
+ kb.button(text="Закрепить", callback_data=f"act:pin:{ref}")
+ tail = 2
+ if form["values"]:
+ kb.button(text="Сбросить", callback_data=f"act:reset:{ref}")
+ tail = 3
+ kb.button(text="К списку", callback_data=f"act:list:{kind}:0")
+ kb.adjust(*rows, tail, 1)
+ return "\n".join(lines), kb.as_markup()
+
+
+async def _show_card( # noqa: PLR0913
+ event: types.Message | types.CallbackQuery,
+ state: FSMContext,
+ kind: str,
+ target_id: str,
+ *,
+ preset: dict | None = None,
+ new: bool = False,
+) -> None:
+ try:
+ text, markup = await card_view(state, kind, target_id, preset)
+ except KomodoError as exc:
+ await _fail(event, exc)
+ return
+ await show(event, text, markup, new=new)
+ if isinstance(event, types.CallbackQuery):
+ await event.answer()
+
+
+@router.message(Command("actions"))
+async def cmd_actions(message: types.Message, state: FSMContext) -> None:
+ await state.clear()
+ if not enabled():
+ await show(
+ message, "Действия выключены или Komodo не настроен.", back_to_menu()
+ )
+ return
+ try:
+ text, markup = await list_view("a", 0)
+ except KomodoError as exc:
+ await _fail(message, exc)
+ return
+ await show(message, text, markup)
+
+
+@router.callback_query(F.data.startswith("act:list:"))
+async def act_list(callback: types.CallbackQuery, state: FSMContext) -> None:
+ if not await _guard(callback):
+ return
+ await state.set_state(None)
+ kind = (callback.data or "").split(":")[2]
+ try:
+ text, markup = await list_view(kind, int_arg(callback.data))
+ except KomodoError as exc:
+ await _fail(callback, exc)
+ return
+ await show(callback, text, markup)
+ await callback.answer()
+
+
+@router.callback_query(F.data.startswith("act:open:"))
+async def act_open(callback: types.CallbackQuery, state: FSMContext) -> None:
+ if not await _guard(callback):
+ return
+ _, _, kind, target_id = (callback.data or "").split(":", 3)
+ await _show_card(callback, state, kind, target_id)
+
+
+@router.callback_query(F.data.startswith("go:act:"))
+async def go_action(callback: types.CallbackQuery, state: FSMContext) -> None:
+ if not await _guard(callback):
+ return
+ _, _, kind, target_id = (callback.data or "").split(":", 3)
+ await _show_card(callback, state, kind, target_id, new=True)
+
+
+def _ref(form: dict[str, Any]) -> str:
+ return f"{form['kind']}:{form['id']}"
+
+
+async def _current(callback: types.CallbackQuery, state: FSMContext) -> dict | None:
+ kind, target_id = (callback.data or "").rsplit(":", 2)[-2:]
+ form = await _form(state)
+ if form.get("id") == target_id:
+ return form
+ try:
+ text, markup = await card_view(state, kind, target_id, {})
+ except KomodoError as exc:
+ await _fail(callback, exc)
+ return None
+ await show(callback, text, markup)
+ await callback.answer("Форма устарела, открыл её заново", show_alert=True)
+ return None
+
+
+@router.callback_query(F.data.startswith("act:back:"))
+async def act_back(callback: types.CallbackQuery, state: FSMContext) -> None:
+ form = await _current(callback, state)
+ if form is None:
+ return
+ await _show_card(callback, state, form["kind"], form["id"])
+
+
+@router.callback_query(F.data.startswith("act:reset:"))
+async def act_reset(callback: types.CallbackQuery, state: FSMContext) -> None:
+ form = await _current(callback, state)
+ if form is None:
+ return
+ await _show_card(callback, state, form["kind"], form["id"], preset={})
+
+
+@router.callback_query(F.data.startswith("act:f:"))
+async def act_field(callback: types.CallbackQuery, state: FSMContext) -> None:
+ form = await _current(callback, state)
+ if form is None:
+ return
+ index = int((callback.data or "").split(":")[2])
+ if index >= len(form["keys"]):
+ await callback.answer()
+ return
+ key = form["keys"][index]
+ try:
+ _, _, defaults = await _load(form["kind"], form["id"])
+ except KomodoError as exc:
+ await _fail(callback, exc)
+ return
+ default = defaults.get(key)
+ value = form["values"].get(key, default)
+ if isinstance(default, bool):
+ form["values"] = {**form["values"], key: not value}
+ await state.update_data(form=form)
+ await _show_card(callback, state, form["kind"], form["id"])
+ return
+
+ form["field"] = key
+ await state.update_data(form=form)
+ await state.set_state(ActionFlow.waiting_value)
+ kb = InlineKeyboardBuilder()
+ if key in form["values"]:
+ kb.button(
+ text=label(f"Вернуть {render(default)}", 40),
+ callback_data=f"act:fd:{_ref(form)}",
+ )
+ kb.button(text="Отмена", callback_data=f"act:back:{_ref(form)}")
+ kb.adjust(1)
+ await show(
+ callback,
+ f"{esc(key)}: пришли новое значение ({type_name(default)}).\n"
+ f"Сейчас: {esc(render(value, 300))}",
+ kb.as_markup(),
+ )
+ await callback.answer()
+
+
+@router.callback_query(F.data.startswith("act:fd:"))
+async def act_field_default(callback: types.CallbackQuery, state: FSMContext) -> None:
+ form = await _current(callback, state)
+ if form is None:
+ return
+ form["values"] = {k: v for k, v in form["values"].items() if k != form.get("field")}
+ await state.update_data(form=form)
+ await _show_card(callback, state, form["kind"], form["id"])
+
+
+@router.message(ActionFlow.waiting_value, ANSWER)
+async def on_value(message: types.Message, state: FSMContext) -> None:
+ form = await _form(state)
+ key = form.get("field")
+ if not form or key is None:
+ await state.set_state(None)
+ return
+ try:
+ _, _, defaults = await _load(form["kind"], form["id"])
+ values = {**form["values"], key: coerce(defaults.get(key), message.text or "")}
+ ensure_safe(values)
+ except ArgError as exc:
+ await show(
+ message,
+ f"{esc(key)}: {esc(exc)}. Пришли ещё раз.",
+ back_to("Отмена", f"act:back:{_ref(form)}"),
+ )
+ return
+ except KomodoError as exc:
+ await _fail(message, exc)
+ return
+ form["values"] = values
+ await state.update_data(form=form)
+ await _show_card(message, state, form["kind"], form["id"])
+
+
+async def _execute( # noqa: PLR0913
+ origin: types.Message | types.CallbackQuery,
+ kind: str,
+ target_id: str,
+ name: str,
+ args: dict,
+ *,
+ title: str | None = None,
+ new: bool = False,
+) -> None:
+ async def starter() -> Update:
+ try:
+ ensure_safe(args)
+ except ArgError as exc:
+ raise KomodoError(str(exc)) from exc
+ logger.info(f"Run{'Action' if kind == 'a' else 'Procedure'} {name}")
+ return await komodo.run(kind, target_id, args)
+
+ await track(
+ origin,
+ title or name,
+ starter,
+ back=[(KIND_BACK[kind], f"act:open:{kind}:{target_id}"), ("Меню", "menu:main")],
+ verbose=kind == "a",
+ extra=_args_code(args),
+ new=new,
+ )
+
+
+@router.callback_query(F.data.startswith("act:run:"))
+async def act_run(callback: types.CallbackQuery, state: FSMContext) -> None:
+ form = await _current(callback, state)
+ if form is None or not await _guard(callback):
+ return
+ if not db.settings.get("confirm"):
+ await callback.answer()
+ await _execute(callback, form["kind"], form["id"], form["name"], form["values"])
+ return
+ kb = InlineKeyboardBuilder()
+ kb.button(text="Запустить", callback_data=f"act:go:{_ref(form)}", style=SUCCESS)
+ kb.button(text="Отмена", callback_data=f"act:back:{_ref(form)}")
+ kb.adjust(2)
+ changes = f"\nИзменено: {_values_line(form['values'])}" if form["values"] else ""
+ await show(
+ callback, f"Запустить {esc(form['name'])}?{changes}", kb.as_markup()
+ )
+ await callback.answer()
+
+
+@router.callback_query(F.data.startswith("act:go:"))
+async def act_go(callback: types.CallbackQuery, state: FSMContext) -> None:
+ form = await _current(callback, state)
+ if form is None or not await _guard(callback):
+ return
+ await callback.answer()
+ await _execute(callback, form["kind"], form["id"], form["name"], form["values"])
+
+
+@router.callback_query(F.data.startswith("act:pin:"))
+async def act_pin(callback: types.CallbackQuery, state: FSMContext) -> None:
+ form = await _current(callback, state)
+ if form is None:
+ return
+ remembered = (
+ f"Кнопка запомнит: {_values_line(form['values'])}."
+ if form["values"]
+ else "Значения кнопка возьмёт из Komodo в момент запуска."
+ )
+ kb = InlineKeyboardBuilder()
+ ref = _ref(form)
+ kb.button(text="Запускать сразу", callback_data=f"act:pinmode:instant:{ref}")
+ kb.button(text="Открывать форму", callback_data=f"act:pinmode:form:{ref}")
+ kb.button(text="Отмена", callback_data=f"act:back:{ref}")
+ kb.adjust(2, 1)
+ await show(
+ callback,
+ f"Закрепить {esc(form['name'])} на главной.\n{remembered}",
+ kb.as_markup(),
+ )
+ await callback.answer()
+
+
+def pin_view(pin: Pin) -> tuple[str, types.InlineKeyboardMarkup]:
+ values = (
+ f"Значения: {_values_line(pin.args)}"
+ if pin.args
+ else "Значения: по умолчанию из Komodo"
+ )
+ lines = [
+ f"Кнопка {esc(pin.label)}",
+ f"{KIND_ONE[pin.kind]}: {esc(pin.name)}",
+ f"Режим: {MODE_TEXT[pin.mode]}",
+ values,
+ ]
+ kb = InlineKeyboardBuilder()
+ kb.button(text="Переименовать", callback_data=f"pin:ren:{pin.id}")
+ other = PinMode.FORM if pin.mode is PinMode.INSTANT else PinMode.INSTANT
+ kb.button(
+ text="Открывать форму" if other is PinMode.FORM else "Запускать сразу",
+ callback_data=f"pin:mode:{pin.id}",
+ )
+ kb.button(text="Выше", callback_data=f"pin:up:{pin.id}")
+ kb.button(text="Ниже", callback_data=f"pin:down:{pin.id}")
+ kb.button(text="Открепить", callback_data=f"pin:del:{pin.id}", style=DANGER)
+ kb.button(text="Закреплённые", callback_data="set:pins")
+ kb.button(text="Меню", callback_data="menu:main")
+ kb.adjust(2, 2, 1, 2)
+ return "\n".join(lines), kb.as_markup()
+
+
+@router.callback_query(F.data.startswith("act:pinmode:"))
+async def act_pin_mode(
+ callback: types.CallbackQuery, state: FSMContext, bot: Bot
+) -> None:
+ form = await _current(callback, state)
+ if form is None:
+ return
+ mode = PinMode((callback.data or "").split(":")[2])
+ pin = await db.pins.add(
+ kind=form["kind"],
+ target_id=form["id"],
+ name=form["name"],
+ label=form["name"][:MAX_LABEL],
+ mode=mode,
+ args=form["values"],
+ )
+ await sync_commands(bot)
+ text, markup = pin_view(pin)
+ await show(callback, text, markup)
+ await callback.answer()
+
+
+async def pins_view() -> tuple[str, types.InlineKeyboardMarkup]:
+ pins = await db.pins.all()
+ kb = InlineKeyboardBuilder()
+ for pin in pins:
+ kb.button(text=label(pin.label, 40), callback_data=f"pin:open:{pin.id}")
+ if enabled():
+ kb.button(text="Действия", callback_data="act:list:a:0")
+ kb.button(text="Меню", callback_data="menu:main")
+ kb.adjust(*[1] * len(pins), 2)
+ text = "Закреплённые"
+ if not pins:
+ text += "\nПока пусто. Открой действие и нажми «Закрепить»."
+ return text, kb.as_markup()
+
+
+@router.callback_query(F.data == "set:pins")
+async def pins_list(callback: types.CallbackQuery, state: FSMContext) -> None:
+ await state.set_state(None)
+ text, markup = await pins_view()
+ await show(callback, text, markup)
+ await callback.answer()
+
+
+async def _pin(callback: types.CallbackQuery) -> Pin | None:
+ pin = await db.pins.get(int_arg(callback.data))
+ if pin is None:
+ await callback.answer("Эта кнопка уже откреплена", show_alert=True)
+ return pin
+
+
+@router.callback_query(F.data.startswith("pin:open:"))
+async def pin_open(callback: types.CallbackQuery, state: FSMContext) -> None:
+ pin = await _pin(callback)
+ if pin is None:
+ return
+ await state.set_state(None)
+ text, markup = pin_view(pin)
+ await show(callback, text, markup)
+ await callback.answer()
+
+
+@router.callback_query(F.data.startswith("pin:run:"))
+async def pin_run(callback: types.CallbackQuery, state: FSMContext) -> None:
+ pin = await _pin(callback)
+ if pin is None or not await _guard(callback):
+ return
+ if pin.mode is PinMode.FORM:
+ await _show_card(callback, state, pin.kind, pin.target_id, preset=pin.args)
+ return
+ await callback.answer()
+ await _execute(
+ callback, pin.kind, pin.target_id, pin.name, pin.args, title=pin.label, new=True
+ )
+
+
+@router.callback_query(F.data.startswith("pin:mode:"))
+async def pin_mode(callback: types.CallbackQuery) -> None:
+ pin = await _pin(callback)
+ if pin is None:
+ return
+ other = PinMode.FORM if pin.mode is PinMode.INSTANT else PinMode.INSTANT
+ await db.pins.set_mode(pin.id, other)
+ pin.mode = other
+ text, markup = pin_view(pin)
+ await show(callback, text, markup)
+ await callback.answer()
+
+
+async def _move(callback: types.CallbackQuery, step: int) -> None:
+ pin = await _pin(callback)
+ if pin is None:
+ return
+ await db.pins.move(pin.id, step)
+ text, markup = await pins_view()
+ await show(callback, text, markup)
+ await callback.answer()
+
+
+@router.callback_query(F.data.startswith("pin:up:"))
+async def pin_up(callback: types.CallbackQuery) -> None:
+ await _move(callback, -1)
+
+
+@router.callback_query(F.data.startswith("pin:down:"))
+async def pin_down(callback: types.CallbackQuery) -> None:
+ await _move(callback, 1)
+
+
+@router.callback_query(F.data.startswith("pin:del:"))
+async def pin_delete(callback: types.CallbackQuery, bot: Bot) -> None:
+ await db.pins.delete(int_arg(callback.data))
+ await sync_commands(bot)
+ text, markup = await pins_view()
+ await show(callback, text, markup)
+ await callback.answer()
+
+
+@router.callback_query(F.data.startswith("pin:ren:"))
+async def pin_rename(callback: types.CallbackQuery, state: FSMContext) -> None:
+ pin = await _pin(callback)
+ if pin is None:
+ return
+ await state.set_state(ActionFlow.waiting_label)
+ await state.update_data(pin_id=pin.id)
+ await show(
+ callback,
+ f"Пришли новое название для кнопки {esc(pin.label)}.",
+ back_to("Отмена", f"pin:open:{pin.id}"),
+ )
+ await callback.answer()
+
+
+@router.message(ActionFlow.waiting_label, ANSWER)
+async def on_label(message: types.Message, state: FSMContext, bot: Bot) -> None:
+ text = " ".join((message.text or "").split())[:MAX_LABEL]
+ data = await state.get_data()
+ await state.set_state(None)
+ pin = await db.pins.get(data.get("pin_id", 0))
+ if pin is None or not text:
+ await show(message, "Эта кнопка уже откреплена.", back_to_menu())
+ return
+ await db.pins.rename(pin.id, text)
+ await sync_commands(bot)
+ pin.label = text
+ view, markup = pin_view(pin)
+ await show(message, view, markup)
+
+
+def _usage(detail: Detail, defaults: dict) -> str:
+ parts = [f"/{command_name(detail.name)}"]
+ for key, value in list(defaults.items())[:USAGE_FIELDS]:
+ parts += [key, '"текст"' if isinstance(value, str) else render(value)]
+ return f"Пример: {esc(' '.join(parts))}"
+
+
+async def _find(name: str) -> Resource | None:
+ wanted = command_name(name)
+ for kind in ("a", "p"):
+ try:
+ items = await komodo.resources(kind)
+ except KomodoError:
+ continue
+ for item in items:
+ if command_name(item.name) == wanted:
+ return item
+ return None
+
+
+commands = Router()
+commands.message.filter(Admin())
+
+
+@commands.message(F.text.startswith("/"))
+async def on_command(message: types.Message, state: FSMContext) -> None:
+ head, *tail = (message.text or "").split(maxsplit=1)
+ rest = tail[0] if tail else ""
+ name = head[1:].split("@")[0]
+ resource = await _find(name) if enabled() else None
+ if resource is not None and not db.settings.get("commands"):
+ await show(
+ message,
+ "Запуск действий командами выключен.",
+ back_to("Настройки", "set:main"),
+ )
+ return
+ if resource is None and not (enabled() and db.settings.get("commands")):
+ await show(message, "Не знаю такую команду.", back_to_menu())
+ return
+ if resource is None:
+ await show(
+ message, f"Действия {esc(name)} в Komodo нет.", back_to_menu()
+ )
+ return
+ await state.set_state(None)
+ try:
+ detail, _, defaults = await _load(resource.kind, resource.id)
+ args = changed(defaults, parse_cli(rest, defaults))
+ ensure_safe(args)
+ except ArgError as exc:
+ detail = await komodo.detail(resource.kind, resource.id)
+ defaults = parse_defaults(detail.args_format, detail.args_text)
+ await show(
+ message,
+ f"{esc(resource.name)}: {esc(exc)}\n{_usage(detail, defaults)}",
+ )
+ return
+ except KomodoError as exc:
+ await _fail(message, exc)
+ return
+ await _execute(message, resource.kind, resource.id, detail.name, args)
+
+
+@commands.message(F.text)
+async def on_unknown(message: types.Message) -> None:
+ await show(
+ message, "Не понял. Пришли ссылку на сервис или открой меню.", back_to_menu()
+ )
diff --git a/src/healthbot/handlers/add.py b/src/healthbot/handlers/add.py
index 624a35e..2ff1a2a 100644
--- a/src/healthbot/handlers/add.py
+++ b/src/healthbot/handlers/add.py
@@ -1,6 +1,6 @@
-"""Добавление сервиса: прислал ссылку → выбрал в меню за чем следить."""
-
-import html
+import asyncio
+import json
+import re
from urllib.parse import urlparse
import aiohttp
@@ -9,9 +9,12 @@ from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.utils.keyboard import InlineKeyboardBuilder
-from healthbot.checker import _scalar_str, probe
+from healthbot.checker import Probe, probe, scalar_str
from healthbot.filters import Admin
-from healthbot.keyboards import back_to_menu
+from healthbot.handlers.menu import dashboard
+from healthbot.handlers.services import show_card
+from healthbot.keyboards import back_to, back_to_menu
+from healthbot.ui import ANSWER, SUCCESS, esc, int_arg, label, show
from utils.db import db
from utils.db.models import MatchType
from utils.env import env
@@ -20,14 +23,51 @@ router = Router()
router.message.filter(Admin())
router.callback_query.filter(Admin())
-URL_RE = r"^https?://\S+$"
-MAX_CHILDREN = 40
+FULL_URL_RE = re.compile(r"https?://\S+", re.IGNORECASE)
+BARE_URL_RE = re.compile(
+ r"(localhost|(\d{1,3}\.){3}\d{1,3}|[\w-]+(\.[\w-]+)*\.[a-z]{2,})(:\d+)?(/\S*)?",
+ re.IGNORECASE | re.ASCII,
+)
+MAX_CHILDREN = 30
+MAX_BULK = 20
PREVIEW_LEN = 30
+BODY_PREVIEW = 350
+HEALTH_KEYS = (
+ "status",
+ "ok",
+ "healthy",
+ "health",
+ "state",
+ "alive",
+ "up",
+ "ready",
+ "success",
+)
class AddFlow(StatesGroup):
waiting_url = State()
choosing = State()
+ waiting_needle = State()
+ waiting_new_url = State()
+
+
+def normalize(token: str) -> str | None:
+ if FULL_URL_RE.fullmatch(token):
+ return token
+ if BARE_URL_RE.fullmatch(token):
+ return f"https://{token}"
+ return None
+
+
+def extract_urls(text: str | None) -> list[str] | None:
+ tokens = (text or "").split()
+ if not tokens or len(tokens) > MAX_BULK:
+ return None
+ urls = [normalize(token) for token in tokens]
+ if any(url is None for url in urls):
+ return None
+ return list(dict.fromkeys(url for url in urls if url))
def _name_from_url(url: str) -> str:
@@ -45,7 +85,8 @@ def _node_at(root: object, path: list[str]) -> object:
def _children(node: object) -> list[tuple[str, object]]:
if isinstance(node, dict):
- return list(node.items())
+ kids: list[tuple[str, object]] = [(str(k), v) for k, v in node.items()]
+ return sorted(kids, key=lambda kid: kid[0].lower() not in HEALTH_KEYS)
if isinstance(node, list):
return [(str(i), v) for i, v in enumerate(node)]
return []
@@ -56,140 +97,298 @@ def _preview(value: object) -> str:
return "{…}"
if isinstance(value, list):
return "[…]"
- text = _scalar_str(value)
+ text = scalar_str(value)
return text if len(text) <= PREVIEW_LEN else text[: PREVIEW_LEN - 1] + "…"
def _choose_view(data: dict) -> tuple[str, types.InlineKeyboardMarkup]:
parsed = data["parsed"]
path: list[str] = data["path"]
- body_preview = html.escape(data["body"][:350]) or "(пусто)"
lines = [
- f"🔗 {html.escape(data['name'])}",
- f"{html.escape(data['url'])}",
- f"Ответ: HTTP {data['status']}",
- f"{body_preview}",
+ f"{esc(data['name'])}",
+ f"{esc(data['url'])}",
+ f"HTTP {data['status']}, отклик {data['latency']} мс",
+ f"{esc(data['body'][:BODY_PREVIEW]) or 'пустой ответ'}",
]
kb = InlineKeyboardBuilder()
+ rows: list[int] = []
if parsed is not None:
- crumb = " › ".join(["корень", *path])
- lines.append(f"За каким полем следить? {html.escape(crumb)}")
- node = _node_at(parsed, path)
- kids = _children(node)
+ crumb = " › ".join(["ответ", *path])
+ lines.append(f"За каким полем следить? {esc(crumb)}")
+ kids = _children(_node_at(parsed, path))
for i, (key, value) in enumerate(kids[:MAX_CHILDREN]):
- kb.button(text=f"{key}: {_preview(value)}", callback_data=f"add:nav:{i}")
+ healthy = not path and key.lower() in HEALTH_KEYS
+ kb.button(
+ text=label(f"{key}: {_preview(value)}"),
+ callback_data=f"add:nav:{i}",
+ style=SUCCESS
+ if healthy and not isinstance(value, (dict, list))
+ else None,
+ )
+ rows += [1] * min(len(kids), MAX_CHILDREN)
if len(kids) > MAX_CHILDREN:
- lines.append(f"…показаны первые {MAX_CHILDREN} полей")
+ lines.append(f"Показаны первые {MAX_CHILDREN} полей.")
else:
- lines.append("Ответ не JSON - можно следить за статусом или телом целиком.")
+ lines.append("За чем следить?")
if path:
- kb.button(text="⬆️ Назад", callback_data="add:up")
- kb.button(text="🔢 Только HTTP-статус", callback_data="add:status")
- kb.button(text="📄 Всё тело целиком", callback_data="add:body")
- kb.button(text="❌ Отмена", callback_data="add:cancel")
- kb.adjust(1)
+ kb.button(text="Назад", callback_data="add:up")
+ rows.append(1)
+ kb.button(text=f"HTTP-статус {data['status']}", callback_data="add:status")
+ kb.button(text="Текст в ответе", callback_data="add:text")
+ kb.button(text="Тело целиком", callback_data="add:body")
+ kb.button(text="Отмена", callback_data="add:cancel")
+ kb.adjust(*rows, 2, 2)
return "\n".join(lines), kb.as_markup()
+async def _probe(url: str) -> Probe:
+ async with aiohttp.ClientSession() as session:
+ return await probe(session, url, env.monitor.request_timeout)
+
+
+def _unreachable_kb(*, editing: bool) -> types.InlineKeyboardMarkup:
+ kb = InlineKeyboardBuilder()
+ kb.button(text="Повторить", callback_data="add:retry")
+ if not editing:
+ kb.button(text="Добавить всё равно", callback_data="add:force")
+ kb.button(text="Отмена", callback_data="add:cancel")
+ kb.adjust(2, 1)
+ return kb.as_markup()
+
+
+async def _begin(
+ event: types.Message | types.CallbackQuery,
+ state: FSMContext,
+ url: str,
+ *,
+ edit_id: int | None = None,
+ name: str | None = None,
+) -> None:
+ result = await _probe(url)
+ name = name or _name_from_url(url)
+ await state.set_state(AddFlow.choosing)
+ await state.set_data({"url": url, "name": name, "edit_id": edit_id})
+
+ if not result.reachable:
+ hint = "" if edit_id else "\nМожно добавить и так: буду ждать HTTP 200."
+ await show(
+ event,
+ f"{esc(name)} не отвечает: {esc(result.error or '')}.{hint}",
+ _unreachable_kb(editing=edit_id is not None),
+ )
+ return
+
+ try:
+ parsed = json.loads(result.body)
+ except ValueError:
+ parsed = None
+
+ await state.update_data(
+ status=result.status,
+ latency=result.latency_ms,
+ body=result.body,
+ parsed=parsed if isinstance(parsed, (dict, list)) else None,
+ path=[],
+ )
+ text, markup = _choose_view(await state.get_data())
+ await show(event, text, markup)
+
+
@router.callback_query(F.data == "menu:add")
async def on_add_menu(callback: types.CallbackQuery, state: FSMContext) -> None:
await state.set_state(AddFlow.waiting_url)
- if isinstance(callback.message, types.Message):
- await callback.message.edit_text(
- "Пришли ссылку на сервис "
- "(например https://example.com/health) - "
- "я запрошу её и предложу выбрать, за чем следить.",
- reply_markup=back_to_menu(),
- )
+ await show(
+ callback,
+ "Пришли ссылку на сервис, например example.com/health.\n"
+ "Несколько ссылок через пробел или с новой строки добавлю разом.",
+ back_to_menu(),
+ )
await callback.answer()
-@router.message(F.text.regexp(URL_RE))
-async def on_url(message: types.Message, state: FSMContext) -> None:
- url = (message.text or "").strip()
+async def _add_many(message: types.Message, urls: list[str]) -> None:
+ fresh = [url for url in urls if await db.services.by_url(url) is None]
+ results = await asyncio.gather(*(_probe(url) for url in fresh))
+ added, skipped = [], [f"{esc(u)}: уже есть" for u in urls if u not in fresh]
+ for url, result in zip(fresh, results, strict=True):
+ if not result.reachable or result.status is None:
+ skipped.append(f"{esc(url)}: {esc(result.error or 'нет ответа')}")
+ continue
+ service = await db.services.add(
+ name=_name_from_url(url),
+ url=url,
+ match_type=MatchType.STATUS,
+ ok_status=result.status,
+ )
+ added.append(f"🟢 {esc(service.name)} · HTTP {result.status}")
+ lines = [f"Добавил {len(added)} из {len(urls)}, слежу за HTTP-статусом.", *added]
+ if skipped:
+ lines += ["", "Пропустил:", *skipped]
+ await show(message, "\n".join(lines), back_to("Сервисы", "menu:list:0"))
- if await db.services.by_url(url) is not None:
- await message.answer("Этот сервис уже под наблюдением. /list - список.")
- return
- note = await message.answer("Запрашиваю…")
- async with aiohttp.ClientSession() as session:
- result = await probe(session, url, env.monitor.request_timeout)
-
- if not result.reachable:
+async def _on_urls(message: types.Message, state: FSMContext, urls: list[str]) -> None:
+ if len(urls) > 1:
await state.clear()
- await note.edit_text(
- f"Не смог достучаться: {html.escape(result.error or '')}.\n"
- "Эталон снимаю только с живого сервиса - попробуй, когда он поднимется.",
- reply_markup=back_to_menu(),
+ await _add_many(message, urls)
+ return
+ existing = await db.services.by_url(urls[0])
+ if existing is not None:
+ await state.clear()
+ await show_card(message, existing)
+ return
+ await _begin(message, state, urls[0])
+
+
+@router.message(AddFlow.waiting_url, ANSWER)
+async def on_url_expected(message: types.Message, state: FSMContext) -> None:
+ urls = extract_urls(message.text)
+ if urls is None:
+ await show(
+ message,
+ "Это не похоже на ссылку. Пример: example.com/health",
+ back_to_menu(),
)
return
+ await _on_urls(message, state, urls)
- try:
- import json # noqa: PLC0415
- parsed = json.loads(result.body)
- except ValueError:
- parsed = None
+@router.message(AddFlow.waiting_new_url, ANSWER)
+async def on_new_url(message: types.Message, state: FSMContext) -> None:
+ data = await state.get_data()
+ service = await db.services.get(data["edit_id"])
+ if service is None:
+ await state.clear()
+ await show(message, "Сервис уже удалён.", back_to_menu())
+ return
+ urls = extract_urls(message.text)
+ back = back_to("Отмена", f"svc:open:{service.id}")
+ if urls is None or len(urls) != 1:
+ await show(message, "Нужна одна ссылка.", back)
+ return
+ other = await db.services.by_url(urls[0])
+ if other is not None and other.id != service.id:
+ await show(message, f"Этот адрес уже у {esc(other.name)}.", back)
+ return
+ await state.clear()
+ await db.services.set_url(service.id, urls[0])
+ updated = await db.services.get(service.id)
+ if updated is not None:
+ await show_card(message, updated)
- await state.set_state(AddFlow.choosing)
- await state.set_data(
- {
- "url": url,
- "name": _name_from_url(url),
- "status": result.status,
- "body": result.body,
- "parsed": parsed,
- "path": [],
- }
+
+@router.callback_query(F.data.startswith("svc:url:"))
+async def svc_url(callback: types.CallbackQuery, state: FSMContext) -> None:
+ service = await db.services.get(int_arg(callback.data))
+ if service is None:
+ await callback.answer("Сервис не найден", show_alert=True)
+ return
+ await state.set_state(AddFlow.waiting_new_url)
+ await state.set_data({"edit_id": service.id})
+ await show(
+ callback,
+ f"Пришли новый адрес для {esc(service.name)}.\n"
+ f"Сейчас: {esc(service.url)}",
+ back_to("Отмена", f"svc:open:{service.id}"),
)
- text, markup = _choose_view(await state.get_data())
- await note.edit_text(text, reply_markup=markup)
+ await callback.answer()
+
+
+@router.callback_query(F.data.startswith("svc:rule:"))
+async def svc_rule(callback: types.CallbackQuery, state: FSMContext) -> None:
+ service = await db.services.get(int_arg(callback.data))
+ if service is None:
+ await callback.answer("Сервис не найден", show_alert=True)
+ return
+ await callback.answer()
+ await _begin(callback, state, service.url, edit_id=service.id, name=service.name)
async def _save( # noqa: PLR0913
- callback: types.CallbackQuery,
+ event: types.Message | types.CallbackQuery,
state: FSMContext,
*,
match_type: MatchType,
ok_body: str = "",
json_path: str | None = None,
json_value: str | None = None,
+ needle: str | None = None,
) -> None:
data = await state.get_data()
- service = await db.services.add(
- name=data["name"],
- url=data["url"],
- match_type=match_type,
- ok_status=data["status"],
- ok_body=ok_body,
- json_path=json_path,
- json_value=json_value,
- )
await state.clear()
- if isinstance(callback.message, types.Message):
- await callback.message.edit_text(
- f"✅ Слежу за {html.escape(service.name)}\n"
- f"{html.escape(service.url)}\n"
- f"Правило: {service.rule}\n"
- f"Проверяю раз в {env.monitor.check_interval}с.",
- reply_markup=back_to_menu(),
- )
- await callback.answer("Готово")
+ rule = {
+ "match_type": match_type,
+ "ok_status": data.get("status") or 200,
+ "ok_body": ok_body,
+ "json_path": json_path,
+ "json_value": json_value,
+ "needle": needle,
+ }
+ if data.get("edit_id"):
+ await db.services.set_rule(data["edit_id"], **rule)
+ service = await db.services.get(data["edit_id"])
+ else:
+ service = await db.services.add(name=data["name"], url=data["url"], **rule)
+ if service is None:
+ await show(event, "Сервис уже удалён.", back_to_menu())
+ return
+ await show_card(event, service)
@router.callback_query(AddFlow.choosing, F.data == "add:status")
async def on_pick_status(callback: types.CallbackQuery, state: FSMContext) -> None:
await _save(callback, state, match_type=MatchType.STATUS)
+ await callback.answer()
+
+
+@router.callback_query(AddFlow.choosing, F.data == "add:force")
+async def on_force(callback: types.CallbackQuery, state: FSMContext) -> None:
+ await _save(callback, state, match_type=MatchType.STATUS)
+ await callback.answer()
+
+
+@router.callback_query(AddFlow.choosing, F.data == "add:retry")
+async def on_retry(callback: types.CallbackQuery, state: FSMContext) -> None:
+ data = await state.get_data()
+ await callback.answer()
+ await _begin(
+ callback, state, data["url"], edit_id=data.get("edit_id"), name=data["name"]
+ )
@router.callback_query(AddFlow.choosing, F.data == "add:body")
async def on_pick_body(callback: types.CallbackQuery, state: FSMContext) -> None:
data = await state.get_data()
await _save(callback, state, match_type=MatchType.BODY, ok_body=data["body"])
+ await callback.answer()
+
+
+@router.callback_query(AddFlow.choosing, F.data == "add:text")
+async def on_pick_text(callback: types.CallbackQuery, state: FSMContext) -> None:
+ await state.set_state(AddFlow.waiting_needle)
+ await show(
+ callback,
+ "Пришли текст, который должен быть в ответе.",
+ back_to("Отмена", "add:cancel"),
+ )
+ await callback.answer()
+
+
+@router.message(AddFlow.waiting_needle, ANSWER)
+async def on_needle(message: types.Message, state: FSMContext) -> None:
+ needle = message.text or ""
+ data = await state.get_data()
+ if needle not in data["body"]:
+ await show(
+ message,
+ "Сейчас такого текста в ответе нет. Пришли другой.",
+ back_to("Отмена", "add:cancel"),
+ )
+ return
+ await _save(message, state, match_type=MatchType.CONTAINS, needle=needle)
@router.callback_query(AddFlow.choosing, F.data == "add:up")
@@ -204,9 +403,8 @@ async def on_up(callback: types.CallbackQuery, state: FSMContext) -> None:
@router.callback_query(AddFlow.choosing, F.data.startswith("add:nav:"))
async def on_nav(callback: types.CallbackQuery, state: FSMContext) -> None:
data = await state.get_data()
- index = int((callback.data or "").rsplit(":", 1)[-1])
- node = _node_at(data["parsed"], data["path"])
- kids = _children(node)
+ kids = _children(_node_at(data["parsed"], data["path"]))
+ index = int_arg(callback.data)
if index >= len(kids):
await callback.answer()
return
@@ -221,20 +419,39 @@ async def on_nav(callback: types.CallbackQuery, state: FSMContext) -> None:
state,
match_type=MatchType.JSON,
json_path=".".join([*data["path"], key]),
- json_value=_scalar_str(value),
+ json_value=scalar_str(value),
)
-
-
-@router.callback_query(AddFlow.choosing, F.data == "add:cancel")
-async def on_cancel(callback: types.CallbackQuery, state: FSMContext) -> None:
- await state.clear()
- if isinstance(callback.message, types.Message):
- await callback.message.edit_text("Отменено.", reply_markup=back_to_menu())
await callback.answer()
+@router.callback_query(F.data == "add:cancel")
+async def on_cancel(callback: types.CallbackQuery, state: FSMContext) -> None:
+ data = await state.get_data()
+ await state.clear()
+ service = await db.services.get(data["edit_id"]) if data.get("edit_id") else None
+ if service is not None:
+ await show_card(callback, service)
+ else:
+ text, markup = await dashboard()
+ await show(callback, text, markup)
+ await callback.answer()
+
+
+@router.callback_query(F.data.startswith("add:"))
+async def on_stale(callback: types.CallbackQuery) -> None:
+ await callback.answer("Эта форма устарела, пришли ссылку заново", show_alert=True)
+
+
async def _rerender(callback: types.CallbackQuery, state: FSMContext) -> None:
text, markup = _choose_view(await state.get_data())
- if isinstance(callback.message, types.Message):
- await callback.message.edit_text(text, reply_markup=markup)
+ await show(callback, text, markup)
await callback.answer()
+
+
+free = Router()
+free.message.filter(Admin())
+
+
+@free.message(F.text.func(lambda text: extract_urls(text) is not None))
+async def on_url(message: types.Message, state: FSMContext) -> None:
+ await _on_urls(message, state, extract_urls(message.text) or [])
diff --git a/src/healthbot/handlers/commands.py b/src/healthbot/handlers/commands.py
deleted file mode 100644
index 5b2eac3..0000000
--- a/src/healthbot/handlers/commands.py
+++ /dev/null
@@ -1,358 +0,0 @@
-"""Меню, список сервисов, карточка, статистика, ручная проверка, удаление."""
-
-import html
-import json as jsonlib
-
-import aiohttp
-from aiogram import Bot, F, Router, types
-from aiogram.filters import Command, CommandStart
-from aiogram.fsm.context import FSMContext
-from aiogram.fsm.state import State, StatesGroup
-
-from healthbot.checker import _scalar_str, dig, evaluate, probe, snippet
-from healthbot.filters import Admin
-from healthbot.keyboards import (
- after_check,
- back_to_menu,
- confirm_delete,
- main_menu,
- service_card,
- services_list,
-)
-from utils.db import db
-from utils.db.models import MatchType, Service
-from utils.env import env
-from utils.format import human_duration, now, parse
-from utils.logging import logger
-
-router = Router()
-router.message.filter(Admin())
-router.callback_query.filter(Admin())
-
-MAX_NAME = 64
-
-GREETING = (
- "👋 healthbot - слежу за твоими сервисами и пишу, если что-то легло.\n\n"
- "Пришли ссылку на health-эндпоинт или жми кнопку 👇"
-)
-
-
-class RenameFlow(StatesGroup):
- waiting_name = State()
-
-
-@router.startup()
-async def on_startup(bot: Bot) -> None:
- commands = [
- types.BotCommand(command="start", description="Меню"),
- types.BotCommand(command="list", description="Сервисы"),
- types.BotCommand(command="stats", description="Статистика"),
- ]
- if env.komodo.enabled:
- commands.append(types.BotCommand(command="fleet", description="Флот"))
- await bot.set_my_commands(commands)
- logger.info(f"[green]Started as[/] @{(await bot.me()).username}")
-
-
-@router.shutdown()
-async def on_shutdown() -> None:
- logger.info("Shutting down bot...")
-
-
-@router.message(CommandStart())
-async def cmd_start(message: types.Message) -> None:
- await message.answer(GREETING, reply_markup=main_menu())
-
-
-@router.callback_query(F.data == "menu:main")
-async def menu_main(callback: types.CallbackQuery) -> None:
- if isinstance(callback.message, types.Message):
- await callback.message.edit_text(GREETING, reply_markup=main_menu())
- await callback.answer()
-
-
-async def _list_view() -> tuple[str, types.InlineKeyboardMarkup]:
- services = await db.services.all()
- if not services:
- return "Пока ни одного сервиса. Пришли ссылку, чтобы добавить.", back_to_menu()
- watched = [s for s in services if not s.is_paused]
- up = sum(s.is_up for s in watched)
- paused = len(services) - len(watched)
- tail = f", ⏸ {paused}" if paused else ""
- text = f"📋 Сервисы - 🟢 {up} / 🔴 {len(watched) - up}{tail}\nТыкни, чтобы открыть."
- return text, services_list(services)
-
-
-@router.message(Command("list"))
-async def cmd_list(message: types.Message) -> None:
- text, markup = await _list_view()
- await message.answer(text, reply_markup=markup)
-
-
-@router.callback_query(F.data == "menu:list")
-async def menu_list(callback: types.CallbackQuery) -> None:
- text, markup = await _list_view()
- if isinstance(callback.message, types.Message):
- await callback.message.edit_text(text, reply_markup=markup)
- await callback.answer()
-
-
-def _downtime(incidents: list) -> float:
- total = 0.0
- for inc in incidents:
- end = parse(inc.ended_at) if inc.ended_at else now()
- total += (end - parse(inc.started_at)).total_seconds()
- return total
-
-
-async def _card_view(service: Service) -> str:
- incidents = await db.incidents.for_service(service.id)
- down = _downtime(incidents)
- window = (now() - parse(service.created_at)).total_seconds()
- uptime = 100.0 if window <= 0 else max(0.0, (window - down) / window * 100)
-
- if service.is_paused:
- status = "⏸ на паузе - не проверяется"
- elif service.is_up:
- status = "🟢 работает"
- if service.fail_streak:
- status += (
- f"\n ⚠️ неудач подряд: {service.fail_streak}"
- f"/{env.monitor.failure_threshold}"
- )
- else:
- current = await db.incidents.current(service.id)
- since = (
- human_duration((now() - parse(current.started_at)).total_seconds())
- if current
- else "?"
- )
- reason = html.escape(current.reason) if current else ""
- status = f"🔴 лежит уже {since}\n причина: {reason}"
-
- latency = (
- f"{service.last_latency_ms} мс" if service.last_latency_ms is not None else "-"
- )
-
- return (
- f"{html.escape(service.name)}\n"
- f"{html.escape(service.url)}\n\n"
- f"Правило: {service.rule}\n"
- f"Статус: {status}\n"
- f"Отклик: {latency}\n\n"
- f"📊 Аптайм: {uptime:.2f}%\n"
- f"Инцидентов: {len(incidents)}\n"
- f"Суммарный даунтайм: {human_duration(down) if down else '0с'}"
- )
-
-
-async def _show_card(callback: types.CallbackQuery, service: Service) -> None:
- if isinstance(callback.message, types.Message):
- await callback.message.edit_text(
- await _card_view(service), reply_markup=service_card(service)
- )
-
-
-async def _load(callback: types.CallbackQuery) -> Service | None:
- service_id = int((callback.data or "").rsplit(":", 1)[-1])
- service = await db.services.get(service_id)
- if service is None:
- await callback.answer("Сервис не найден", show_alert=True)
- return service
-
-
-@router.callback_query(F.data.startswith("svc:open:"))
-async def svc_open(callback: types.CallbackQuery) -> None:
- service = await _load(callback)
- if service is None:
- return
- await _show_card(callback, service)
- await callback.answer()
-
-
-@router.callback_query(F.data.startswith("svc:check:"))
-async def svc_check(callback: types.CallbackQuery) -> None:
- """Проверить прямо сейчас и показать, что сервис отдаёт."""
- service = await _load(callback)
- if service is None:
- return
- await callback.answer("Запрашиваю…")
-
- async with aiohttp.ClientSession() as session:
- result = await probe(session, service.url, env.monitor.request_timeout)
- verdict = evaluate(service, result)
-
- head = "✅ Правило выполняется" if verdict.healthy else "🔴 Правило нарушено"
- lines = [
- f"{html.escape(service.name)}",
- f"{html.escape(service.url)}",
- "",
- head,
- f"HTTP: {result.status if result.reachable else '-'}"
- f" отклик: {result.latency_ms if result.latency_ms is not None else '-'} мс",
- ]
- if not verdict.healthy:
- lines.append(f"Причина: {html.escape(verdict.reason)}")
- report = verdict.report()
- if report:
- lines.append(report)
- elif result.reachable:
- lines.extend(snippet(result.body))
-
- if isinstance(callback.message, types.Message):
- await callback.message.edit_text(
- "\n".join(lines),
- reply_markup=after_check(
- service.id, offer_baseline=not verdict.healthy and result.reachable
- ),
- )
-
-
-@router.callback_query(F.data.startswith("svc:base:"))
-async def svc_baseline(callback: types.CallbackQuery) -> None:
- """Принять текущий ответ за новый эталон."""
- service = await _load(callback)
- if service is None:
- return
- async with aiohttp.ClientSession() as session:
- result = await probe(session, service.url, env.monitor.request_timeout)
- if not result.reachable:
- await callback.answer("Сервис недоступен - эталон снимать не с чего", True) # noqa: FBT003
- return
-
- json_value = service.json_value
- if service.match_type is MatchType.JSON:
- try:
- value = dig(jsonlib.loads(result.body), service.json_path or "")
- except ValueError:
- await callback.answer("Ответ не JSON - эталон не обновлён", True) # noqa: FBT003
- return
- json_value = _scalar_str(value)
-
- await db.services.set_baseline(
- service.id,
- ok_status=result.status or service.ok_status,
- ok_body=(
- result.body if service.match_type is MatchType.BODY else service.ok_body
- ),
- json_value=json_value,
- )
- await db.services.set_streak(service.id, 0)
- updated = await db.services.get(service.id)
- if updated is not None:
- await _show_card(callback, updated)
- await callback.answer("Эталон обновлён")
-
-
-@router.callback_query(F.data.startswith("svc:pause:"))
-async def svc_pause(callback: types.CallbackQuery) -> None:
- service = await _load(callback)
- if service is None:
- return
- await db.services.set_paused(service.id, is_paused=True)
- updated = await db.services.get(service.id)
- if updated is not None:
- await _show_card(callback, updated)
- await callback.answer("На паузе")
-
-
-@router.callback_query(F.data.startswith("svc:resume:"))
-async def svc_resume(callback: types.CallbackQuery) -> None:
- service = await _load(callback)
- if service is None:
- return
- await db.services.set_paused(service.id, is_paused=False)
- updated = await db.services.get(service.id)
- if updated is not None:
- await _show_card(callback, updated)
- await callback.answer("Снова слежу")
-
-
-@router.callback_query(F.data.startswith("svc:rename:"))
-async def svc_rename(callback: types.CallbackQuery, state: FSMContext) -> None:
- service = await _load(callback)
- if service is None:
- return
- await state.set_state(RenameFlow.waiting_name)
- await state.set_data({"service_id": service.id})
- if isinstance(callback.message, types.Message):
- await callback.message.edit_text(
- f"Как назвать вместо {html.escape(service.name)}?\n"
- "Пришли новое имя сообщением.",
- reply_markup=back_to_menu(),
- )
- await callback.answer()
-
-
-@router.message(RenameFlow.waiting_name)
-async def on_new_name(message: types.Message, state: FSMContext) -> None:
- name = (message.text or "").strip()[:MAX_NAME]
- if not name:
- await message.answer("Пустое имя не годится.")
- return
- data = await state.get_data()
- await state.clear()
- await db.services.rename(data["service_id"], name)
- service = await db.services.get(data["service_id"])
- if service is None:
- await message.answer("Сервис успел удалиться.")
- return
- await message.answer(await _card_view(service), reply_markup=service_card(service))
-
-
-@router.callback_query(F.data.startswith("svc:del:"))
-async def svc_del(callback: types.CallbackQuery) -> None:
- service_id = int((callback.data or "").rsplit(":", 1)[-1])
- if isinstance(callback.message, types.Message):
- await callback.message.edit_text(
- "Точно удалить сервис вместе со всей статистикой?",
- reply_markup=confirm_delete(service_id),
- )
- await callback.answer()
-
-
-@router.callback_query(F.data.startswith("svc:delyes:"))
-async def svc_del_yes(callback: types.CallbackQuery) -> None:
- service_id = int((callback.data or "").rsplit(":", 1)[-1])
- await db.services.delete(service_id)
- text, markup = await _list_view()
- if isinstance(callback.message, types.Message):
- await callback.message.edit_text(text, reply_markup=markup)
- await callback.answer("Удалено")
-
-
-async def _stats_view() -> tuple[str, types.InlineKeyboardMarkup]:
- services = await db.services.all()
- if not services:
- return "Пока нет данных.", back_to_menu()
- lines = ["📊 Статистика\n"]
- for service in services:
- incidents = await db.incidents.for_service(service.id)
- down = _downtime(incidents)
- window = (now() - parse(service.created_at)).total_seconds()
- uptime = 100.0 if window <= 0 else max(0.0, (window - down) / window * 100)
- latency = (
- f", отклик {service.last_latency_ms} мс"
- if service.last_latency_ms is not None
- else ""
- )
- lines.append(
- f"{service.mark} {html.escape(service.name)} - "
- f"аптайм {uptime:.2f}%, инцидентов {len(incidents)}, "
- f"даунтайм {human_duration(down) if down else '0с'}{latency}"
- )
- return "\n".join(lines), back_to_menu()
-
-
-@router.message(Command("stats"))
-async def cmd_stats(message: types.Message) -> None:
- text, markup = await _stats_view()
- await message.answer(text, reply_markup=markup)
-
-
-@router.callback_query(F.data == "menu:stats")
-async def menu_stats(callback: types.CallbackQuery) -> None:
- text, markup = await _stats_view()
- if isinstance(callback.message, types.Message):
- await callback.message.edit_text(text, reply_markup=markup)
- await callback.answer()
diff --git a/src/healthbot/handlers/fleet.py b/src/healthbot/handlers/fleet.py
index b3b9ac8..b57dba9 100644
--- a/src/healthbot/handlers/fleet.py
+++ b/src/healthbot/handlers/fleet.py
@@ -1,27 +1,43 @@
-"""Флот из телеграма: посмотреть сервера и стеки, нажать Deploy с телефона."""
-
-import html
+import asyncio
+import re
+from collections.abc import Awaitable
from aiogram import F, Router, types
from aiogram.filters import Command
+from aiogram.fsm.context import FSMContext
+from aiogram.types import BufferedInputFile
from aiogram.utils.keyboard import InlineKeyboardBuilder
+from healthbot.alerts import format_alert
from healthbot.filters import Admin
-from healthbot.keyboards import back_to_menu
-from healthbot.komodo import KomodoError, Stack, komodo
+from healthbot.keyboards import back_to, back_to_menu
+from healthbot.komodo import KomodoError, Server, Stack, Update, komodo
+from healthbot.runs import track
+from healthbot.ui import DANGER, PRIMARY, esc, label, pager, pages, pre, show
+from utils.db import db
+from utils.format import from_ms, stamp
from utils.logging import logger
router = Router()
router.message.filter(Admin())
router.callback_query.filter(Admin())
-LOG_TAIL = 30
-LOG_CHARS = 2500
+LOG_TAIL = 40
+LOG_FILE_TAIL = 1000
+LOG_CHARS = 3200
+STACKS_PAGE = 20
+HISTORY = 12
+JOURNAL = 15
+FAILED_BUTTONS = 5
+ALL_SERVICES = -1
+SMALL_DISK_GB = 2
+LOG_PREFIX_RE = re.compile(r"^\S+\s+\| ", re.MULTILINE)
-ACTIONS: dict[str, tuple[str, str, str]] = {
- "dep": ("🚀 Deploy", "DeployStack", "Деплой запущен"),
- "res": ("🔄 Restart", "RestartStack", "Перезапуск запущен"),
- "sto": ("⏹ Stop", "StopStack", "Остановка запущена"),
+OPS: dict[str, tuple[str, str, str | None]] = {
+ "dep": ("Deploy", "DeployStack", PRIMARY),
+ "res": ("Restart", "RestartStack", None),
+ "sto": ("Stop", "StopStack", DANGER),
+ "sta": ("Start", "StartStack", None),
}
@@ -32,123 +48,317 @@ async def _guard(callback: types.CallbackQuery) -> bool:
return False
-def _fleet_kb(
- stacks: list[Stack], servers: dict[str, str]
-) -> types.InlineKeyboardMarkup:
- kb = InlineKeyboardBuilder()
- for stack in sorted(stacks, key=lambda s: (servers.get(s.server_id, ""), s.name)):
- kb.button(
- text=f"{stack.mark} {stack.name}", callback_data=f"fleet:stack:{stack.id}"
- )
- kb.button(text="🔄 Обновить", callback_data="fleet:main")
- kb.button(text="⬅️ Меню", callback_data="menu:main")
- kb.adjust(1)
- return kb.as_markup()
+def _usage(server: Server) -> str:
+ parts = []
+ if server.cpu is not None:
+ parts.append(f"cpu {server.cpu:.0f}%")
+ if server.mem is not None:
+ parts.append(f"память {server.mem:.0f}%")
+ if server.disk is not None:
+ parts.append(f"диск {server.disk:.0f}%")
+ return ", ".join(parts)
-async def _fleet_view() -> tuple[str, types.InlineKeyboardMarkup]:
- servers = await komodo.servers()
- stacks = await komodo.stacks()
- by_id = {s.id: s.name for s in servers}
-
- lines = ["🛰 Флот", ""]
- for server in sorted(servers, key=lambda s: s.name):
- mine = [s for s in stacks if s.server_id == server.id]
- good = sum(s.state == "running" for s in mine)
- lines.append(
- f"{server.mark} {html.escape(server.name)} - "
- f"{good}/{len(mine)} стеков в строю"
- )
-
- broken = [s for s in stacks if s.state not in {"running", "unknown"}]
- if broken:
- lines.append("")
- lines.append("Не в строю:")
- lines.extend(
- f"{s.mark} {html.escape(s.name)} - {html.escape(s.state)}" for s in broken
- )
-
- return "\n".join(lines), _fleet_kb(stacks, by_id)
+def _stack_count(stacks: list[Stack]) -> str:
+ good = sum(s.healthy for s in stacks)
+ if good == len(stacks):
+ return f"стеков {len(stacks)}, все работают"
+ return f"стеков {len(stacks)}, работают {good}"
-async def _stack_view(stack_id: str) -> tuple[str, types.InlineKeyboardMarkup]:
- stacks = await komodo.stacks()
- stack = next((s for s in stacks if s.id == stack_id), None)
- if stack is None:
- return "Стек не найден - возможно, его удалили.", back_to_menu()
-
- servers = {s.id: s.name for s in await komodo.servers()}
- text = (
- f"{stack.mark} {html.escape(stack.name)}\n"
- f"Состояние: {html.escape(stack.state or '?')}\n"
- f"Сервер: {html.escape(servers.get(stack.server_id, '?'))}"
+async def fleet_view() -> tuple[str, types.InlineKeyboardMarkup]:
+ servers, stacks, alerts = await asyncio.gather(
+ komodo.servers(), komodo.stacks(), komodo.open_alerts()
)
+ servers.sort(key=lambda s: s.name)
+ lines = [f"Флот: серверов {len(servers)}, стеков {len(stacks)}", ""]
+ kb = InlineKeyboardBuilder()
+ for server in servers:
+ mine = [s for s in stacks if s.server_id == server.id]
+ if server.state == "NotOk":
+ lines.append(f"{server.mark} {esc(server.name)} не отвечает")
+ elif server.state == "Disabled":
+ lines.append(f"{server.mark} {esc(server.name)} выключен в Komodo")
+ else:
+ usage = _usage(server)
+ lines.append(
+ f"{server.mark} {esc(server.name)}"
+ + (f" · {usage}" if usage else "")
+ )
+ lines.append(_stack_count(mine) if mine else "стеков нет")
+ kb.button(
+ text=f"{server.mark} {label(server.name, 28)}",
+ callback_data=f"fleet:srv:{server.id}:0",
+ )
+ rows = [2] * (len(servers) // 2) + [1] * (len(servers) % 2)
+
+ broken = [s for s in stacks if not s.healthy]
+ extra = 0
+ if broken:
+ kb.button(text=f"Не работают: {len(broken)}", callback_data="fleet:bad")
+ extra += 1
+ if alerts:
+ kb.button(text=f"Алерты: {len(alerts)}", callback_data="fleet:alerts")
+ extra += 1
+ kb.button(text="Журнал", callback_data="fleet:journal")
+ kb.button(text="Обновить", callback_data="fleet:main")
+ kb.button(text="Меню", callback_data="menu:main")
+ kb.adjust(*rows, *([extra] if extra else []), 3)
+ return "\n".join(lines), kb.as_markup()
+
+
+def _stack_buttons(kb: InlineKeyboardBuilder, stacks: list[Stack]) -> list[int]:
+ for stack in stacks:
+ kb.button(
+ text=f"{stack.mark} {label(stack.name, 28)}",
+ callback_data=f"fleet:stack:{stack.id}",
+ )
+ return [2] * (len(stacks) // 2) + [1] * (len(stacks) % 2)
+
+
+async def server_view(
+ server_id: str, page: int
+) -> tuple[str, types.InlineKeyboardMarkup]:
+ servers, stacks = await asyncio.gather(komodo.servers(), komodo.stacks())
+ server = next((s for s in servers if s.id == server_id), None)
+ if server is None:
+ return "Сервер не найден.", back_to("К флоту", "fleet:main")
+
+ lines = [f"{server.mark} {esc(server.name)}"]
+ if server.state == "NotOk":
+ lines.append("не отвечает")
+ facts = []
+ if server.version:
+ facts.append(f"Komodo {esc(server.version)}")
+ if server.cpu is not None:
+ facts.append(f"cpu {server.cpu:.0f}%")
+ if server.load is not None:
+ facts.append(f"load {server.load:.2f}")
+ if facts:
+ lines.append(" · ".join(facts))
+ if server.mem is not None:
+ lines.append(
+ f"Память: {server.mem_used:.1f} из {server.mem_total:.1f} ГБ"
+ f" ({server.mem:.0f}%)"
+ )
+ try:
+ disks = await komodo.disks(server_id)
+ except KomodoError:
+ disks = []
+ for mount, used, total in [d for d in disks if d[2] >= SMALL_DISK_GB][:4]:
+ lines.append(
+ f"Диск {esc(mount)}: {used:.1f} из {total:.1f} ГБ"
+ f" ({used / total * 100:.0f}%)"
+ )
+
+ mine = sorted(
+ (s for s in stacks if s.server_id == server_id),
+ key=lambda s: (s.healthy, s.name),
+ )
+ lines += ["", _stack_count(mine) if mine else "стеков нет"]
+ total_pages = pages(len(mine), STACKS_PAGE)
+ page = min(page, total_pages - 1)
+ kb = InlineKeyboardBuilder()
+ rows = _stack_buttons(kb, mine[page * STACKS_PAGE : (page + 1) * STACKS_PAGE])
+ nav = pager(kb, f"fleet:srv:{server_id}", page, total_pages)
+ kb.button(text="Обновить", callback_data=f"fleet:srv:{server_id}:{page}")
+ kb.button(text="К флоту", callback_data="fleet:main")
+ kb.adjust(*rows, *([nav] if nav else []), 2)
+ return "\n".join(lines), kb.as_markup()
+
+
+async def stack_view(stack_id: str) -> tuple[str, types.InlineKeyboardMarkup]:
+ stack = await komodo.stack(stack_id)
+ if stack is None:
+ return "Стек не найден, возможно его удалили.", back_to("К флоту", "fleet:main")
+
+ lines = [
+ f"{stack.mark} {esc(stack.name)}",
+ f"Сервер: {esc(stack.server_name or 'не назначен')}",
+ f"Состояние: {esc(stack.status or stack.state or 'неизвестно')}",
+ ]
+ if stack.repo:
+ lines.append(f"Репозиторий: {esc(stack.repo)}, ветка {esc(stack.branch)}")
+ if stack.deployed_hash:
+ newer = f", в репозитории уже {esc(stack.latest_hash)}" if stack.behind else ""
+ lines.append(f"Выкачен {esc(stack.deployed_hash)}{newer}")
+ if stack.image_updates:
+ lines.append(f"Новых образов: {stack.image_updates}")
+
+ try:
+ containers = await komodo.containers(stack_id)
+ except KomodoError:
+ containers = []
+ if containers:
+ lines.append("")
+ for item in containers:
+ facts = [item.status, f"cpu {item.cpu}" if item.cpu else "", item.mem]
+ lines.append(
+ f"{item.mark} {esc(item.service)}"
+ + "".join(f" · {esc(fact)}" for fact in facts if fact)
+ )
kb = InlineKeyboardBuilder()
- for key, (label, _, _) in ACTIONS.items():
- kb.button(text=label, callback_data=f"fleet:ask:{key}:{stack.id}")
- kb.button(text="📜 Логи", callback_data=f"fleet:log:{stack.id}")
- kb.button(text="⬅️ К флоту", callback_data="fleet:main")
- kb.adjust(3, 2)
- return text, kb.as_markup()
+ shown = ("dep", "res", "sto") if stack.state != "down" else ("dep", "sta")
+ for key in shown:
+ text, _, style = OPS[key]
+ kb.button(text=text, callback_data=f"fleet:ask:{key}:{stack.id}", style=style)
+ kb.button(text="Логи", callback_data=f"fleet:logs:{stack.id}")
+ kb.button(text="История", callback_data=f"fleet:hist:{stack.id}")
+ kb.button(text="Обновить", callback_data=f"fleet:stack:{stack.id}")
+ kb.button(
+ text="К серверу" if stack.server_id else "К флоту",
+ callback_data=f"fleet:srv:{stack.server_id}:0"
+ if stack.server_id
+ else "fleet:main",
+ )
+ kb.adjust(len(shown), 2, 2)
+ return "\n".join(lines), kb.as_markup()
+
+
+async def _render(
+ event: types.Message | types.CallbackQuery,
+ view: Awaitable[tuple[str, types.InlineKeyboardMarkup]],
+ *,
+ new: bool = False,
+) -> None:
+ try:
+ text, markup = await view
+ except KomodoError as exc:
+ if isinstance(event, types.CallbackQuery):
+ await event.answer(str(exc)[:190], show_alert=True)
+ else:
+ await show(event, f"🔴 {esc(exc)}", back_to_menu())
+ return
+ await show(event, text, markup, new=new)
+ if isinstance(event, types.CallbackQuery):
+ await event.answer()
@router.message(Command("fleet"))
-async def cmd_fleet(message: types.Message) -> None:
+async def cmd_fleet(message: types.Message, state: FSMContext) -> None:
+ await state.clear()
if not komodo.enabled:
- await message.answer("Komodo не настроен - см. README, раздел про KOMODO__*.")
+ await show(message, "Komodo не настроен: нужны переменные KOMODO__*.")
return
- try:
- text, markup = await _fleet_view()
- except KomodoError as exc:
- await message.answer(f"⚠️ {html.escape(str(exc))}")
- return
- await message.answer(text, reply_markup=markup)
+ await _render(message, fleet_view())
@router.callback_query(F.data == "fleet:main")
-async def fleet_main(callback: types.CallbackQuery) -> None:
+async def fleet_main(callback: types.CallbackQuery, state: FSMContext) -> None:
if not await _guard(callback):
return
- try:
- text, markup = await _fleet_view()
- except KomodoError as exc:
- await callback.answer(str(exc), show_alert=True)
+ await state.clear()
+ komodo.forget()
+ await _render(callback, fleet_view())
+
+
+def _id_and_page(data: str | None) -> tuple[str, int]:
+ parts = (data or "").split(":")
+ page = int(parts[3]) if len(parts) > 3 and parts[3].lstrip("-").isdigit() else 0 # noqa: PLR2004
+ return parts[2], page
+
+
+@router.callback_query(F.data.startswith("fleet:srv:"))
+async def fleet_server(callback: types.CallbackQuery) -> None:
+ if not await _guard(callback):
return
- if isinstance(callback.message, types.Message):
- await callback.message.edit_text(text, reply_markup=markup)
- await callback.answer()
+ server_id, page = _id_and_page(callback.data)
+ await _render(callback, server_view(server_id, page))
+
+
+@router.callback_query(F.data.startswith("go:srv:"))
+async def go_server(callback: types.CallbackQuery) -> None:
+ if not await _guard(callback):
+ return
+ server_id, _ = _id_and_page(callback.data)
+ await _render(callback, server_view(server_id, 0), new=True)
@router.callback_query(F.data.startswith("fleet:stack:"))
async def fleet_stack(callback: types.CallbackQuery) -> None:
if not await _guard(callback):
return
- stack_id = (callback.data or "").rsplit(":", 1)[-1]
- try:
- text, markup = await _stack_view(stack_id)
- except KomodoError as exc:
- await callback.answer(str(exc), show_alert=True)
+ stack_id, _ = _id_and_page(callback.data)
+ await _render(callback, stack_view(stack_id))
+
+
+@router.callback_query(F.data.startswith("go:stack:"))
+async def go_stack(callback: types.CallbackQuery) -> None:
+ if not await _guard(callback):
return
- if isinstance(callback.message, types.Message):
- await callback.message.edit_text(text, reply_markup=markup)
- await callback.answer()
+ stack_id, _ = _id_and_page(callback.data)
+ await _render(callback, stack_view(stack_id), new=True)
+
+
+async def _bad_view() -> tuple[str, types.InlineKeyboardMarkup]:
+ broken = sorted(
+ (s for s in await komodo.stacks() if not s.healthy),
+ key=lambda s: (s.server_name, s.name),
+ )
+ lines = ["Не работают", ""]
+ lines.extend(
+ f"{s.mark} {esc(s.name)} · {esc(s.server_name)} · {esc(s.state)}"
+ for s in broken
+ )
+ kb = InlineKeyboardBuilder()
+ rows = _stack_buttons(kb, broken[:40])
+ kb.button(text="К флоту", callback_data="fleet:main")
+ kb.adjust(*rows, 1)
+ return "\n".join(lines), kb.as_markup()
+
+
+@router.callback_query(F.data == "fleet:bad")
+async def fleet_bad(callback: types.CallbackQuery) -> None:
+ if not await _guard(callback):
+ return
+ await _render(callback, _bad_view())
+
+
+async def _alerts_view() -> tuple[str, types.InlineKeyboardMarkup]:
+ tz = db.settings.get("tz")
+ alerts = await komodo.open_alerts()
+ lines = ["Открытые алерты"]
+ if not alerts:
+ lines.append("Сейчас ни одного.")
+ for alert in alerts[:10]:
+ since = stamp(from_ms(alert.get("ts") or 0), tz)
+ lines += ["", format_alert(alert), f"с {since}"]
+ return "\n".join(lines), back_to("К флоту", "fleet:main")
+
+
+@router.callback_query(F.data == "fleet:alerts")
+async def fleet_alerts(callback: types.CallbackQuery) -> None:
+ if not await _guard(callback):
+ return
+ await _render(callback, _alerts_view())
@router.callback_query(F.data.startswith("fleet:ask:"))
async def fleet_ask(callback: types.CallbackQuery) -> None:
- """Подтверждение. Deploy с телефона слишком легко нажать штаниной."""
+ if not await _guard(callback):
+ return
_, _, key, stack_id = (callback.data or "").split(":", 3)
- label = ACTIONS[key][0]
+ text, _, style = OPS[key]
+ try:
+ stack = await komodo.stack(stack_id)
+ except KomodoError as exc:
+ await callback.answer(str(exc)[:190], show_alert=True)
+ return
+ if stack is None:
+ await callback.answer("Стек не найден", show_alert=True)
+ return
kb = InlineKeyboardBuilder()
- kb.button(text=f"✅ Да, {label}", callback_data=f"fleet:do:{key}:{stack_id}")
- kb.button(text="⬅️ Отмена", callback_data=f"fleet:stack:{stack_id}")
- kb.adjust(1)
- if isinstance(callback.message, types.Message):
- await callback.message.edit_text(
- f"{label} - подтверди, это подействует на прод.",
- reply_markup=kb.as_markup(),
- )
+ kb.button(
+ text=text, callback_data=f"fleet:do:{key}:{stack_id}", style=style or PRIMARY
+ )
+ kb.button(text="Отмена", callback_data=f"fleet:stack:{stack_id}")
+ kb.adjust(2)
+ await show(
+ callback,
+ f"{text} {esc(stack.name)} на {esc(stack.server_name)}?",
+ kb.as_markup(),
+ )
await callback.answer()
@@ -157,49 +367,226 @@ async def fleet_do(callback: types.CallbackQuery) -> None:
if not await _guard(callback):
return
_, _, key, stack_id = (callback.data or "").split(":", 3)
- _, method, done = ACTIONS[key]
-
- await callback.answer(done)
- if isinstance(callback.message, types.Message):
- await callback.message.edit_text(f"⏳ {done}…")
-
+ text, method, _ = OPS[key]
+ await callback.answer()
try:
- await komodo.execute(method, {"stack": stack_id, "services": []})
- logger.info(f"{method} {stack_id} - запущено из телеграма")
- except KomodoError as exc:
- if isinstance(callback.message, types.Message):
- await callback.message.edit_text(
- f"⚠️ Не вышло: {html.escape(str(exc))}", reply_markup=back_to_menu()
- )
- return
-
- try:
- text, markup = await _stack_view(stack_id)
+ stack = await komodo.stack(stack_id)
except KomodoError:
- text, markup = "Готово.", back_to_menu()
- if isinstance(callback.message, types.Message):
- await callback.message.edit_text(f"✅ {done}.\n\n{text}", reply_markup=markup)
+ stack = None
+ name = stack.name if stack else stack_id
+
+ async def starter() -> Update:
+ logger.info(f"{method} {name} started from telegram")
+ return await komodo.execute(method, {"stack": stack_id, "services": []})
+
+ await track(
+ callback,
+ f"{text} {name}",
+ starter,
+ back=[
+ ("К стеку", f"fleet:stack:{stack_id}"),
+ ("Логи", f"fleet:logs:{stack_id}"),
+ ],
+ verbose=False,
+ )
+
+
+def _picked(stack: Stack, index: int) -> list[str]:
+ if index == ALL_SERVICES or index >= len(stack.services):
+ return []
+ return [stack.services[index]]
+
+
+async def _log_view(
+ stack_id: str, index: int
+) -> tuple[str, types.InlineKeyboardMarkup]:
+ stack = await komodo.stack(stack_id)
+ if stack is None:
+ return "Стек не найден.", back_to("К флоту", "fleet:main")
+ services = _picked(stack, index)
+ log = await komodo.stack_log(stack_id, services, LOG_TAIL)
+ if services or len(stack.services) == 1:
+ log = LOG_PREFIX_RE.sub("", log)
+ scope = f" · {esc(services[0])}" if services else ""
+ body = pre(log, LOG_CHARS) if log else "лог пуст"
+ kb = InlineKeyboardBuilder()
+ kb.button(text="Обновить", callback_data=f"fleet:log:{stack_id}:{index}")
+ kb.button(text="Файлом", callback_data=f"fleet:logf:{stack_id}:{index}")
+ rows = [2]
+ if len(stack.services) > 1:
+ kb.button(text="Другой сервис", callback_data=f"fleet:logs:{stack_id}")
+ kb.button(text="К стеку", callback_data=f"fleet:stack:{stack_id}")
+ rows.append(2)
+ else:
+ kb.button(text="К стеку", callback_data=f"fleet:stack:{stack_id}")
+ rows.append(1)
+ kb.adjust(*rows)
+ return f"{esc(stack.name)}{scope}\n{body}", kb.as_markup()
+
+
+async def _log_picker(stack_id: str) -> tuple[str, types.InlineKeyboardMarkup]:
+ stack = await komodo.stack(stack_id)
+ if stack is None:
+ return "Стек не найден.", back_to("К флоту", "fleet:main")
+ if len(stack.services) <= 1:
+ return await _log_view(stack_id, ALL_SERVICES)
+ kb = InlineKeyboardBuilder()
+ for index, service in enumerate(stack.services):
+ kb.button(
+ text=label(service, 28), callback_data=f"fleet:log:{stack_id}:{index}"
+ )
+ kb.button(text="Все сервисы", callback_data=f"fleet:log:{stack_id}:{ALL_SERVICES}")
+ kb.button(text="К стеку", callback_data=f"fleet:stack:{stack_id}")
+ count = len(stack.services)
+ kb.adjust(*[2] * (count // 2), *[1] * (count % 2), 2)
+ return f"{esc(stack.name)}: чьи логи?", kb.as_markup()
+
+
+@router.callback_query(F.data.startswith("fleet:logs:"))
+async def fleet_logs(callback: types.CallbackQuery) -> None:
+ if not await _guard(callback):
+ return
+ stack_id, _ = _id_and_page(callback.data)
+ await _render(callback, _log_picker(stack_id))
+
+
+@router.callback_query(F.data.startswith("go:log:"))
+async def go_log(callback: types.CallbackQuery) -> None:
+ if not await _guard(callback):
+ return
+ stack_id, _ = _id_and_page(callback.data)
+ await _render(callback, _log_view(stack_id, ALL_SERVICES), new=True)
@router.callback_query(F.data.startswith("fleet:log:"))
async def fleet_log(callback: types.CallbackQuery) -> None:
if not await _guard(callback):
return
- stack_id = (callback.data or "").rsplit(":", 1)[-1]
- await callback.answer("Забираю логи…")
- try:
- log = await komodo.stack_log(stack_id, tail=LOG_TAIL)
- except KomodoError as exc:
- await callback.answer(str(exc), show_alert=True)
- return
+ stack_id, index = _id_and_page(callback.data)
+ await _render(callback, _log_view(stack_id, index))
- kb = InlineKeyboardBuilder()
- kb.button(text="🔄 Ещё раз", callback_data=f"fleet:log:{stack_id}")
- kb.button(text="⬅️ К стеку", callback_data=f"fleet:stack:{stack_id}")
- kb.adjust(2)
- tail = log[-LOG_CHARS:]
+
+@router.callback_query(F.data.startswith("fleet:logf:"))
+async def fleet_log_file(callback: types.CallbackQuery) -> None:
+ if not await _guard(callback):
+ return
+ stack_id, index = _id_and_page(callback.data)
+ try:
+ stack = await komodo.stack(stack_id)
+ if stack is None:
+ await callback.answer("Стек не найден", show_alert=True)
+ return
+ services = _picked(stack, index)
+ log = await komodo.stack_log(stack_id, services, LOG_FILE_TAIL)
+ except KomodoError as exc:
+ await callback.answer(str(exc)[:190], show_alert=True)
+ return
+ await callback.answer()
if isinstance(callback.message, types.Message):
- await callback.message.edit_text(
- f"📜 Последние {LOG_TAIL} строк:\n{html.escape(tail)}",
- reply_markup=kb.as_markup(),
+ name = "-".join([stack.name, *services])
+ await callback.message.answer_document(
+ BufferedInputFile((log or "empty").encode(), filename=f"{name}.log")
)
+
+
+def _same(one: Update, other: Update) -> bool:
+ return (one.operation, one.target_id, one.success, one.done) == (
+ other.operation,
+ other.target_id,
+ other.success,
+ other.done,
+ )
+
+
+def _collapse(updates: list[Update], *, everywhere: bool) -> list[tuple[Update, int]]:
+ result: list[tuple[Update, int]] = []
+ for update in updates:
+ scope = (
+ range(len(result)) if everywhere else range(len(result) - 1, len(result))
+ )
+ match = next((i for i in scope if i >= 0 and _same(result[i][0], update)), None)
+ if match is None:
+ result.append((update, 1))
+ else:
+ result[match] = (result[match][0], result[match][1] + 1)
+ return result
+
+
+def _update_mark(update: Update) -> str:
+ if not update.done:
+ return "🔄"
+ return "✅" if update.success else "🔴"
+
+
+async def _update_lines(
+ updates: list[Update], limit: int, *, with_target: bool
+) -> tuple[list[str], list[Update]]:
+ tz = db.settings.get("tz")
+ names = await komodo.names() if with_target else {}
+ lines, failed = [], []
+ for update, count in _collapse(updates, everywhere=with_target)[:limit]:
+ who = await komodo.username(update.operator)
+ target = (
+ f" {esc(names.get(update.target_id, update.target_type))}"
+ if with_target
+ else ""
+ )
+ times = f" ×{count}" if count > 1 else ""
+ lines.append(
+ f"{_update_mark(update)} {stamp(from_ms(update.start_ts), tz)} "
+ f"{esc(update.operation)}{target}{times} · {esc(who)}"
+ )
+ if update.done and not update.success:
+ failed.append(update)
+ return lines, failed
+
+
+def _failed_buttons(kb: InlineKeyboardBuilder, failed: list[Update]) -> int:
+ tz = db.settings.get("tz")
+ shown = failed[:FAILED_BUTTONS]
+ for update in shown:
+ kb.button(
+ text=f"🔴 {update.operation} {stamp(from_ms(update.start_ts), tz)}",
+ callback_data=f"run:st:{update.id}",
+ )
+ return len(shown)
+
+
+async def _history_view(stack_id: str) -> tuple[str, types.InlineKeyboardMarkup]:
+ stack = await komodo.stack(stack_id)
+ updates = await komodo.updates({"target.id": stack_id})
+ lines, failed = await _update_lines(updates, HISTORY, with_target=False)
+ title = esc(stack.name) if stack else "Стек"
+ kb = InlineKeyboardBuilder()
+ count = _failed_buttons(kb, failed)
+ kb.button(text="К стеку", callback_data=f"fleet:stack:{stack_id}")
+ kb.adjust(*[1] * count, 1)
+ body = "\n".join(lines) if lines else "Операций ещё не было."
+ return f"{title}: операции\n\n{body}", kb.as_markup()
+
+
+@router.callback_query(F.data.startswith("fleet:hist:"))
+async def fleet_history(callback: types.CallbackQuery) -> None:
+ if not await _guard(callback):
+ return
+ stack_id, _ = _id_and_page(callback.data)
+ await _render(callback, _history_view(stack_id))
+
+
+async def _journal_view() -> tuple[str, types.InlineKeyboardMarkup]:
+ updates = await komodo.updates()
+ lines, failed = await _update_lines(updates, JOURNAL, with_target=True)
+ kb = InlineKeyboardBuilder()
+ count = _failed_buttons(kb, failed)
+ kb.button(text="Обновить", callback_data="fleet:journal")
+ kb.button(text="К флоту", callback_data="fleet:main")
+ kb.adjust(*[1] * count, 2)
+ body = "\n".join(lines) if lines else "Операций ещё не было."
+ return f"Журнал\n\n{body}", kb.as_markup()
+
+
+@router.callback_query(F.data == "fleet:journal")
+async def fleet_journal(callback: types.CallbackQuery) -> None:
+ if not await _guard(callback):
+ return
+ await _render(callback, _journal_view())
diff --git a/src/healthbot/handlers/menu.py b/src/healthbot/handlers/menu.py
new file mode 100644
index 0000000..d265213
--- /dev/null
+++ b/src/healthbot/handlers/menu.py
@@ -0,0 +1,145 @@
+import asyncio
+
+from aiogram import Bot, F, Router, types
+from aiogram.filters import CommandStart
+from aiogram.fsm.context import FSMContext
+
+from healthbot.actionargs import command_name
+from healthbot.filters import Admin
+from healthbot.keyboards import main_menu
+from healthbot.komodo import KomodoError, komodo
+from healthbot.ui import MENU_COMMANDS, esc, plural, show
+from utils.db import db
+from utils.db.models import Service
+from utils.env import env
+from utils.format import human_duration, now, parse, short
+from utils.logging import logger
+
+router = Router()
+router.message.filter(Admin())
+router.callback_query.filter(Admin())
+
+SHOWN_PROBLEMS = 6
+FLEET_TIMEOUT = 6
+
+
+async def sync_commands(bot: Bot) -> None:
+ commands = [
+ types.BotCommand(command="start", description="Меню"),
+ types.BotCommand(command="list", description="Сервисы"),
+ types.BotCommand(command="stats", description="Статистика"),
+ ]
+ actions = env.komodo.enabled and db.settings.get("actions")
+ if env.komodo.enabled:
+ commands.append(types.BotCommand(command="fleet", description="Флот"))
+ if actions:
+ commands.append(types.BotCommand(command="actions", description="Действия"))
+ commands.append(types.BotCommand(command="settings", description="Настройки"))
+ if actions and db.settings.get("commands"):
+ taken = set(MENU_COMMANDS)
+ for pin in await db.pins.all():
+ name = command_name(pin.name)
+ if not name or name in taken:
+ continue
+ taken.add(name)
+ commands.append(
+ types.BotCommand(command=name, description=short(pin.label, 60))
+ )
+ await bot.set_my_commands(commands)
+
+
+@router.startup()
+async def on_startup(bot: Bot) -> None:
+ await sync_commands(bot)
+ logger.info(f"[green]Started as[/] @{(await bot.me()).username}")
+
+
+@router.shutdown()
+async def on_shutdown() -> None:
+ logger.info("Shutting down bot")
+
+
+async def _down_line(service: Service) -> str:
+ current = await db.incidents.current(service.id)
+ if current is None:
+ return f"🔴 {esc(service.name)}"
+ since = human_duration((now() - parse(current.started_at)).total_seconds())
+ return f"🔴 {esc(service.name)}: {since}, {esc(short(current.reason, 50))}"
+
+
+async def _services_block() -> list[str]:
+ services = await db.services.all()
+ if not services:
+ return ["Сервисов пока нет. Пришли ссылку, и я начну за ней следить."]
+ watched = [s for s in services if not s.is_paused]
+ down = [s for s in watched if not s.is_up]
+ paused = len(services) - len(watched)
+ working = len(watched) - len(down)
+ works = plural(working, "работает", "работают", "работают")
+ if not down and not paused:
+ every = "все " if working > 1 else ""
+ return [f"Сервисы: {every}{working} {works}"]
+ parts = [f"{working} {works}"]
+ if down:
+ parts.append(f"{len(down)} {plural(len(down), 'лежит', 'лежат', 'лежат')}")
+ if paused:
+ parts.append(f"{paused} на паузе")
+ lines = [f"Сервисы: {', '.join(parts)}"]
+ lines.extend([await _down_line(s) for s in down[:SHOWN_PROBLEMS]])
+ return lines
+
+
+async def _fleet_block() -> list[str]:
+ try:
+ servers, stacks, alerts = await asyncio.gather(
+ komodo.servers(), komodo.stacks(), komodo.open_alerts()
+ )
+ except KomodoError as exc:
+ return [f"Флот: {esc(exc)}"]
+ reachable = {s.id for s in servers if s.state == "Ok"}
+ good = sum(s.healthy for s in stacks)
+ summary = "все работают" if good == len(stacks) else f"работают {good}"
+ lines = [f"Флот: серверов {len(servers)}, стеков {len(stacks)}, {summary}"]
+ for server in servers:
+ if server.state == "NotOk":
+ lines.append(f"🔴 {esc(server.name)} не отвечает")
+ elif server.strained:
+ lines.append(f"🟠 {esc(server.name)}: {', '.join(server.strains)}")
+ troubled = [s for s in stacks if s.troubled and s.server_id in reachable]
+ lines.extend(
+ f"{s.mark} {esc(s.name)}: {esc(s.state)}" for s in troubled[:SHOWN_PROBLEMS]
+ )
+ if len(troubled) > SHOWN_PROBLEMS:
+ lines.append(f"и ещё {len(troubled) - SHOWN_PROBLEMS}")
+ if alerts:
+ lines.append(f"Открытых алертов: {len(alerts)}")
+ return lines
+
+
+async def dashboard() -> tuple[str, types.InlineKeyboardMarkup]:
+ lines = ["healthbot", "", *await _services_block()]
+ actions = env.komodo.enabled and db.settings.get("actions")
+ if env.komodo.enabled:
+ try:
+ fleet = await asyncio.wait_for(_fleet_block(), FLEET_TIMEOUT)
+ except TimeoutError:
+ fleet = ["Флот: Komodo не отвечает"]
+ lines += ["", *fleet]
+ pins = await db.pins.all() if actions else []
+ return "\n".join(lines), main_menu(pins, actions=bool(actions))
+
+
+@router.message(CommandStart())
+async def cmd_start(message: types.Message, state: FSMContext) -> None:
+ await state.clear()
+ text, markup = await dashboard()
+ await show(message, text, markup)
+
+
+@router.callback_query(F.data == "menu:main")
+async def menu_main(callback: types.CallbackQuery, state: FSMContext) -> None:
+ await state.clear()
+ komodo.forget()
+ text, markup = await dashboard()
+ await show(callback, text, markup)
+ await callback.answer()
diff --git a/src/healthbot/handlers/runs.py b/src/healthbot/handlers/runs.py
new file mode 100644
index 0000000..24cf60d
--- /dev/null
+++ b/src/healthbot/handlers/runs.py
@@ -0,0 +1,69 @@
+from aiogram import F, Router, types
+from aiogram.types import BufferedInputFile
+from aiogram.utils.keyboard import InlineKeyboardBuilder
+
+from healthbot.filters import Admin
+from healthbot.komodo import KomodoError, Update, komodo
+from healthbot.runs import output_of, result_kb, result_text
+from healthbot.ui import esc, show, tail_arg
+from utils.format import clock
+
+router = Router()
+router.callback_query.filter(Admin())
+
+
+async def _title(update: Update) -> str:
+ try:
+ name = (await komodo.names()).get(update.target_id, "")
+ except KomodoError:
+ name = ""
+ return f"{update.operation} {name}".strip()
+
+
+@router.callback_query(F.data.startswith("run:st:"))
+async def run_status(callback: types.CallbackQuery) -> None:
+ update_id = tail_arg(callback.data)
+ try:
+ update = await komodo.update(update_id)
+ except KomodoError as exc:
+ await callback.answer(str(exc)[:190], show_alert=True)
+ return
+ title = await _title(update)
+ await callback.answer()
+ if not update.done:
+ kb = InlineKeyboardBuilder()
+ kb.button(text="Проверить", callback_data=f"run:st:{update_id}")
+ await show(
+ callback,
+ f"⏳ {esc(title)} идёт уже {clock(update.seconds)}",
+ kb.as_markup(),
+ new=True,
+ )
+ return
+ await show(
+ callback,
+ result_text(title, update, verbose=update.success),
+ result_kb(update, []),
+ new=True,
+ )
+
+
+@router.callback_query(F.data.startswith("run:file:"))
+async def run_file(callback: types.CallbackQuery) -> None:
+ update_id = tail_arg(callback.data)
+ try:
+ update = await komodo.update(update_id)
+ except KomodoError as exc:
+ await callback.answer(str(exc)[:190], show_alert=True)
+ return
+ await callback.answer()
+ if isinstance(callback.message, types.Message):
+ text = output_of(update, only_failed=False) or "empty"
+ await callback.message.answer_document(
+ BufferedInputFile(text.encode(), filename=f"{update.operation}.log")
+ )
+
+
+@router.callback_query()
+async def stale_button(callback: types.CallbackQuery) -> None:
+ await callback.answer("Эта кнопка устарела, открой /start", show_alert=True)
diff --git a/src/healthbot/handlers/services.py b/src/healthbot/handlers/services.py
new file mode 100644
index 0000000..ba611d0
--- /dev/null
+++ b/src/healthbot/handlers/services.py
@@ -0,0 +1,410 @@
+import json as jsonlib
+
+import aiohttp
+from aiogram import F, Router, types
+from aiogram.filters import Command
+from aiogram.fsm.context import FSMContext
+from aiogram.fsm.state import State, StatesGroup
+
+from healthbot.checker import MISSING, dig, evaluate, probe, scalar_str, snippet
+from healthbot.filters import Admin
+from healthbot.keyboards import (
+ after_check,
+ back_to,
+ back_to_menu,
+ confirm_delete,
+ service_card,
+ services_list,
+)
+from healthbot.stats import (
+ PERIODS,
+ current_hour,
+ day_squares,
+ downtime,
+ latency_chart,
+ uptime,
+ uptime_line,
+)
+from healthbot.ui import ANSWER, esc, int_arg, plural, show
+from utils.db import db
+from utils.db.models import Incident, MatchType, Service
+from utils.env import env
+from utils.format import ago, human_duration, now, parse, short, stamp
+
+router = Router()
+router.message.filter(Admin())
+router.callback_query.filter(Admin())
+
+MAX_NAME = 64
+SHOWN_INCIDENTS = 10
+CHART_HOURS = 24
+
+
+class RenameFlow(StatesGroup):
+ waiting_name = State()
+
+
+async def list_view(page: int = 0) -> tuple[str, types.InlineKeyboardMarkup]:
+ services = await db.services.all()
+ if not services:
+ return "Сервисов пока нет. Пришли ссылку, чтобы добавить.", back_to_menu()
+ watched = [s for s in services if not s.is_paused]
+ up = sum(s.is_up for s in watched)
+ paused = len(services) - len(watched)
+ parts = [f"🟢 {up}", f"🔴 {len(watched) - up}"]
+ if paused:
+ parts.append(f"⏸ {paused}")
+ return f"Сервисы {' '.join(parts)}", services_list(services, page)
+
+
+@router.message(Command("list"))
+async def cmd_list(message: types.Message, state: FSMContext) -> None:
+ await state.clear()
+ text, markup = await list_view()
+ await show(message, text, markup)
+
+
+@router.callback_query(F.data.startswith("menu:list"))
+async def menu_list(callback: types.CallbackQuery, state: FSMContext) -> None:
+ await state.clear()
+ text, markup = await list_view(int_arg(callback.data))
+ await show(callback, text, markup)
+ await callback.answer()
+
+
+async def _status_line(service: Service) -> str:
+ if service.is_paused:
+ return "на паузе, не проверяется"
+ if not service.is_up:
+ current = await db.incidents.current(service.id)
+ if current is None:
+ return "лежит"
+ since = human_duration((now() - parse(current.started_at)).total_seconds())
+ muted = ", напоминания выключены" if current.muted else ""
+ return f"лежит {since}: {esc(current.reason)}{muted}"
+ parts = ["работает"]
+ if service.last_latency_ms is not None:
+ parts.append(f"отклик {service.last_latency_ms} мс")
+ if service.last_checked_at:
+ parts.append(f"проверен {ago(parse(service.last_checked_at))}")
+ if service.fail_streak:
+ parts.append(
+ f"неудач подряд {service.fail_streak} из {env.monitor.failure_threshold}"
+ )
+ return ", ".join(parts)
+
+
+def _incident_line(incident: Incident, tz: str) -> str:
+ began = parse(incident.started_at)
+ if incident.ended_at:
+ length = human_duration((parse(incident.ended_at) - began).total_seconds())
+ else:
+ length = "идёт сейчас"
+ return f"{stamp(began, tz)} · {length} · {esc(short(incident.reason, 60))}"
+
+
+async def card_view(service: Service) -> str:
+ tz = db.settings.get("tz")
+ incidents = await db.incidents.for_service(service.id)
+ hours = await db.stats.since(service.id, current_hour() - CHART_HOURS)
+ chart, average, peak = latency_chart(hours)
+
+ lines = [
+ f"{service.mark} {esc(service.name)}",
+ f"{esc(service.url)}",
+ "",
+ f"Правило: {service.rule}",
+ f"Статус: {await _status_line(service)}",
+ "",
+ f"Аптайм: {uptime_line(service, incidents)}",
+ f"14 дней: {day_squares(service, incidents, tz)}",
+ ]
+ if chart:
+ lines.append(f"Отклик за сутки: {chart}")
+ lines.append(f"в среднем {average} мс, пик {peak} мс")
+ if incidents:
+ last = _incident_line(incidents[-1], tz)
+ lines.append(f"Инцидентов: {len(incidents)}, последний {last}")
+ if service.tls_expires_at:
+ left = (parse(service.tls_expires_at) - now()).total_seconds()
+ state = f"ещё {human_duration(left)}" if left > 0 else "истёк"
+ lines.append(f"Сертификат: {state}")
+ return "\n".join(lines)
+
+
+async def show_card(
+ event: types.Message | types.CallbackQuery, service: Service, *, new: bool = False
+) -> None:
+ await show(event, await card_view(service), service_card(service), new=new)
+
+
+async def _load(callback: types.CallbackQuery) -> Service | None:
+ service = await db.services.get(int_arg(callback.data))
+ if service is None:
+ await callback.answer("Сервис не найден", show_alert=True)
+ return service
+
+
+@router.callback_query(F.data.startswith("svc:open:"))
+async def svc_open(callback: types.CallbackQuery, state: FSMContext) -> None:
+ service = await _load(callback)
+ if service is None:
+ return
+ await state.clear()
+ await show_card(callback, service)
+ await callback.answer()
+
+
+@router.callback_query(F.data.startswith("go:svc:"))
+async def go_service(callback: types.CallbackQuery) -> None:
+ service = await _load(callback)
+ if service is None:
+ return
+ await show_card(callback, service, new=True)
+ await callback.answer()
+
+
+async def _check_view(service: Service) -> tuple[str, types.InlineKeyboardMarkup]:
+ async with aiohttp.ClientSession() as session:
+ result = await probe(session, service.url, env.monitor.request_timeout)
+ verdict = evaluate(service, result)
+
+ head = "✅ Правило выполняется" if verdict.healthy else "🔴 Правило нарушено"
+ lines = [
+ f"{esc(service.name)}",
+ f"{esc(service.url)}",
+ "",
+ head,
+ ]
+ if result.reachable:
+ lines.append(f"HTTP {result.status}, отклик {result.latency_ms} мс")
+ if not verdict.healthy:
+ lines.append(f"Причина: {esc(verdict.reason)}")
+ report = verdict.report()
+ if report:
+ lines.append(report)
+ elif result.reachable:
+ lines.extend(snippet(result.body))
+ markup = after_check(
+ service.id, offer_baseline=not verdict.healthy and result.reachable
+ )
+ return "\n".join(lines), markup
+
+
+@router.callback_query(F.data.startswith("svc:check:"))
+async def svc_check(callback: types.CallbackQuery) -> None:
+ service = await _load(callback)
+ if service is None:
+ return
+ await callback.answer()
+ text, markup = await _check_view(service)
+ await show(callback, text, markup)
+
+
+@router.callback_query(F.data.startswith("go:check:"))
+async def go_check(callback: types.CallbackQuery) -> None:
+ service = await _load(callback)
+ if service is None:
+ return
+ await callback.answer()
+ text, markup = await _check_view(service)
+ await show(callback, text, markup, new=True)
+
+
+@router.callback_query(F.data.startswith("svc:base:"))
+async def svc_baseline(callback: types.CallbackQuery) -> None:
+ service = await _load(callback)
+ if service is None:
+ return
+ async with aiohttp.ClientSession() as session:
+ result = await probe(session, service.url, env.monitor.request_timeout)
+ if not result.reachable:
+ await callback.answer("Сервис недоступен, эталон снять не с чего", True) # noqa: FBT003
+ return
+
+ json_value = service.json_value
+ if service.match_type is MatchType.JSON:
+ try:
+ value = dig(jsonlib.loads(result.body), service.json_path or "")
+ except ValueError:
+ await callback.answer("Ответ не JSON, эталон не обновлён", True) # noqa: FBT003
+ return
+ if value is MISSING or isinstance(value, (dict, list)):
+ await callback.answer(
+ f"Поля {service.json_path} в ответе нет, эталон не обновлён",
+ True, # noqa: FBT003
+ )
+ return
+ json_value = scalar_str(value)
+
+ await db.services.set_baseline(
+ service.id,
+ ok_status=result.status or service.ok_status,
+ ok_body=(
+ result.body if service.match_type is MatchType.BODY else service.ok_body
+ ),
+ json_value=json_value,
+ )
+ await db.services.set_streak(service.id, 0)
+ updated = await db.services.get(service.id)
+ if updated is not None:
+ await show_card(callback, updated)
+ await callback.answer()
+
+
+async def _set_paused(callback: types.CallbackQuery, *, is_paused: bool) -> None:
+ service = await _load(callback)
+ if service is None:
+ return
+ await db.services.set_paused(service.id, is_paused=is_paused)
+ updated = await db.services.get(service.id)
+ if updated is not None:
+ await show_card(callback, updated)
+ await callback.answer()
+
+
+@router.callback_query(F.data.startswith("svc:pause:"))
+async def svc_pause(callback: types.CallbackQuery) -> None:
+ await _set_paused(callback, is_paused=True)
+
+
+@router.callback_query(F.data.startswith("svc:resume:"))
+async def svc_resume(callback: types.CallbackQuery) -> None:
+ await _set_paused(callback, is_paused=False)
+
+
+@router.callback_query(F.data.startswith("svc:mute:"))
+async def svc_mute(callback: types.CallbackQuery) -> None:
+ incident = await db.incidents.get(int_arg(callback.data))
+ if incident is None or incident.ended_at:
+ await callback.answer("Инцидент уже закрыт", show_alert=True)
+ return
+ await db.incidents.mute(incident.id)
+ if isinstance(callback.message, types.Message):
+ kb = types.InlineKeyboardMarkup(
+ inline_keyboard=[
+ row
+ for row in (
+ callback.message.reply_markup or back_to_menu()
+ ).inline_keyboard
+ if not any((b.callback_data or "").startswith("svc:mute:") for b in row)
+ ]
+ )
+ await callback.message.edit_reply_markup(reply_markup=kb)
+ await callback.answer()
+
+
+@router.callback_query(F.data.startswith("svc:inc:"))
+async def svc_incidents(callback: types.CallbackQuery) -> None:
+ service = await _load(callback)
+ if service is None:
+ return
+ tz = db.settings.get("tz")
+ incidents = await db.incidents.for_service(service.id)
+ lines = [f"{esc(service.name)}: инциденты", ""]
+ if not incidents:
+ lines.append("Ни одного падения.")
+ else:
+ lines.extend(
+ _incident_line(i, tz) for i in reversed(incidents[-SHOWN_INCIDENTS:])
+ )
+ total = downtime(incidents, parse(service.created_at), now())
+ lines += ["", f"Всего {len(incidents)}, даунтайм {human_duration(total)}"]
+ await show(
+ callback, "\n".join(lines), back_to("К сервису", f"svc:open:{service.id}")
+ )
+ await callback.answer()
+
+
+@router.callback_query(F.data.startswith("svc:rename:"))
+async def svc_rename(callback: types.CallbackQuery, state: FSMContext) -> None:
+ service = await _load(callback)
+ if service is None:
+ return
+ await state.set_state(RenameFlow.waiting_name)
+ await state.set_data({"service_id": service.id})
+ await show(
+ callback,
+ f"Пришли новое имя для {esc(service.name)}.",
+ back_to("Отмена", f"svc:open:{service.id}"),
+ )
+ await callback.answer()
+
+
+@router.message(RenameFlow.waiting_name, ANSWER)
+async def on_new_name(message: types.Message, state: FSMContext) -> None:
+ name = (message.text or "").strip()[:MAX_NAME]
+ if not name:
+ return
+ data = await state.get_data()
+ await state.clear()
+ await db.services.rename(data["service_id"], name)
+ service = await db.services.get(data["service_id"])
+ if service is None:
+ await show(message, "Сервис уже удалён.", back_to_menu())
+ return
+ await show_card(message, service)
+
+
+@router.callback_query(F.data.startswith("svc:del:"))
+async def svc_del(callback: types.CallbackQuery) -> None:
+ service = await _load(callback)
+ if service is None:
+ return
+ await show(
+ callback,
+ f"Удалить {esc(service.name)} вместе со статистикой?",
+ confirm_delete(service.id),
+ )
+ await callback.answer()
+
+
+@router.callback_query(F.data.startswith("svc:delyes:"))
+async def svc_del_yes(callback: types.CallbackQuery) -> None:
+ await db.services.delete(int_arg(callback.data))
+ text, markup = await list_view()
+ await show(callback, text, markup)
+ await callback.answer()
+
+
+async def stats_view() -> tuple[str, types.InlineKeyboardMarkup]:
+ services = await db.services.all()
+ if not services:
+ return "Сервисов пока нет.", back_to_menu()
+ month = PERIODS[-1][1]
+ rows = []
+ for service in services:
+ incidents = await db.incidents.for_service(service.id)
+ rows.append((uptime(service, incidents, month) or 0.0, service, incidents))
+ rows.sort(key=lambda row: row[0])
+
+ lines = ["Статистика", ""]
+ for _, service, incidents in rows:
+ speed = (
+ f" · {service.last_latency_ms} мс"
+ if service.last_latency_ms is not None
+ else ""
+ )
+ count = len(incidents)
+ falls = (
+ f"{count} {plural(count, 'падение', 'падения', 'падений')}"
+ if count
+ else "без падений"
+ )
+ lines.append(f"{service.mark} {esc(service.name)}{speed}")
+ lines.append(f"{uptime_line(service, incidents)} · {falls}")
+ return "\n".join(lines), back_to_menu()
+
+
+@router.message(Command("stats"))
+async def cmd_stats(message: types.Message, state: FSMContext) -> None:
+ await state.clear()
+ text, markup = await stats_view()
+ await show(message, text, markup)
+
+
+@router.callback_query(F.data == "menu:stats")
+async def menu_stats(callback: types.CallbackQuery) -> None:
+ text, markup = await stats_view()
+ await show(callback, text, markup)
+ await callback.answer()
diff --git a/src/healthbot/handlers/settings.py b/src/healthbot/handlers/settings.py
new file mode 100644
index 0000000..2f5c691
--- /dev/null
+++ b/src/healthbot/handlers/settings.py
@@ -0,0 +1,229 @@
+from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
+
+from aiogram import Bot, F, Router, types
+from aiogram.filters import Command
+from aiogram.fsm.context import FSMContext
+from aiogram.fsm.state import State, StatesGroup
+from aiogram.utils.keyboard import InlineKeyboardBuilder
+
+from healthbot.alerts import KNOWN_TYPES
+from healthbot.filters import Admin
+from healthbot.handlers.menu import sync_commands
+from healthbot.keyboards import back_to
+from healthbot.komodo import KomodoError, komodo
+from healthbot.ui import ANSWER, esc, int_arg, show, tail_arg
+from utils.db import db
+from utils.env import env
+from utils.format import now, zone
+
+router = Router()
+router.message.filter(Admin())
+router.callback_query.filter(Admin())
+
+TOGGLES = {
+ "actions": ("Действия Komodo", "включены", "выключены"),
+ "commands": ("Действия как команды", "включены", "выключены"),
+ "confirm": ("Подтверждение запуска", "включено", "выключено"),
+}
+DIGEST_HOURS = (7, 8, 9, 10, 12, 18, 21)
+
+
+class SettingsFlow(StatesGroup):
+ waiting_tz = State()
+
+
+async def _komodo_line() -> str:
+ if not env.komodo.enabled:
+ return "Komodo не подключён: нужны переменные KOMODO__*."
+ try:
+ return f"Komodo {esc(await komodo.version())} подключён."
+ except KomodoError as exc:
+ return f"Komodo: {esc(exc)}"
+
+
+async def settings_view() -> tuple[str, types.InlineKeyboardMarkup]:
+ tz = db.settings.get("tz")
+ lines = ["Настройки", await _komodo_line()]
+ kb = InlineKeyboardBuilder()
+ if env.komodo.enabled:
+ for key, (title, on, off) in TOGGLES.items():
+ if key != "actions" and not db.settings.get("actions"):
+ continue
+ state = on if db.settings.get(key) else off
+ kb.button(text=f"{title}: {state}", callback_data=f"set:t:{key}")
+ if db.settings.get("actions") and db.settings.get("commands"):
+ lines.append(
+ "Команда запускает действие по имени: "
+ "/имя_действия поле значение. "
+ "Закреплённые видны в меню команд."
+ )
+ if env.web.enabled:
+ muted = len(db.settings.get("muted"))
+ kb.button(text=f"Алерты Komodo, скрыто: {muted}", callback_data="set:alerts")
+ kb.button(text=f"Часовой пояс: {tz}", callback_data="set:tz")
+ hour = db.settings.get("digest")
+ digest = "выключена" if hour is None else f"в {hour:02d}:00"
+ kb.button(text=f"Сводка за сутки: {digest}", callback_data="set:digest")
+ if env.komodo.enabled and db.settings.get("actions"):
+ kb.button(text="Закреплённые", callback_data="set:pins")
+ kb.button(text="Меню", callback_data="menu:main")
+ kb.adjust(1)
+ return "\n".join(lines), kb.as_markup()
+
+
+@router.message(Command("settings"))
+async def cmd_settings(message: types.Message, state: FSMContext) -> None:
+ await state.clear()
+ text, markup = await settings_view()
+ await show(message, text, markup)
+
+
+@router.callback_query(F.data == "set:main")
+async def settings_main(callback: types.CallbackQuery, state: FSMContext) -> None:
+ await state.clear()
+ text, markup = await settings_view()
+ await show(callback, text, markup)
+ await callback.answer()
+
+
+@router.callback_query(F.data.startswith("set:t:"))
+async def settings_toggle(callback: types.CallbackQuery, bot: Bot) -> None:
+ key = tail_arg(callback.data)
+ if key not in TOGGLES:
+ await callback.answer()
+ return
+ await db.settings.set(key, not db.settings.get(key))
+ await sync_commands(bot)
+ text, markup = await settings_view()
+ await show(callback, text, markup)
+ await callback.answer()
+
+
+def _alerts_view() -> tuple[str, types.InlineKeyboardMarkup]:
+ muted: list[str] = db.settings.get("muted")
+ kinds = [*KNOWN_TYPES, *(kind for kind in muted if kind not in KNOWN_TYPES)]
+ kb = InlineKeyboardBuilder()
+ for kind in kinds:
+ mark = "🔕" if kind in muted else "🔔"
+ kb.button(text=f"{mark} {kind}", callback_data=f"set:mute:{kind}")
+ kb.button(text="К настройкам", callback_data="set:main")
+ kb.adjust(1)
+ return "Алерты Komodo", kb.as_markup()
+
+
+@router.callback_query(F.data == "set:alerts")
+async def settings_alerts(callback: types.CallbackQuery) -> None:
+ text, markup = _alerts_view()
+ await show(callback, text, markup)
+ await callback.answer()
+
+
+async def _flip_mute(kind: str) -> None:
+ muted: list[str] = list(db.settings.get("muted"))
+ if kind in muted:
+ muted.remove(kind)
+ else:
+ muted.append(kind)
+ await db.settings.set("muted", muted)
+
+
+@router.callback_query(F.data.startswith("set:mute:"))
+async def settings_mute(callback: types.CallbackQuery) -> None:
+ await _flip_mute(tail_arg(callback.data))
+ text, markup = _alerts_view()
+ await show(callback, text, markup)
+ await callback.answer()
+
+
+async def _swap_alert_button(
+ callback: types.CallbackQuery, text: str, data: str
+) -> None:
+ message = callback.message
+ if isinstance(message, types.Message):
+ old = message.reply_markup.inline_keyboard if message.reply_markup else []
+ rows = [
+ [b for b in row if not (b.callback_data or "").startswith("al:")]
+ for row in old
+ ]
+ rows = [row for row in rows if row]
+ rows.append([types.InlineKeyboardButton(text=text, callback_data=data)])
+ await message.edit_reply_markup(
+ reply_markup=types.InlineKeyboardMarkup(inline_keyboard=rows)
+ )
+ await callback.answer()
+
+
+@router.callback_query(F.data.startswith("al:mute:"))
+async def alert_mute(callback: types.CallbackQuery) -> None:
+ kind = tail_arg(callback.data)
+ if kind not in db.settings.get("muted"):
+ await _flip_mute(kind)
+ await _swap_alert_button(callback, f"{kind} скрыт, вернуть", f"al:show:{kind}")
+
+
+@router.callback_query(F.data.startswith("al:show:"))
+async def alert_unmute(callback: types.CallbackQuery) -> None:
+ kind = tail_arg(callback.data)
+ if kind in db.settings.get("muted"):
+ await _flip_mute(kind)
+ await _swap_alert_button(callback, "Не присылать такие", f"al:mute:{kind}")
+
+
+@router.callback_query(F.data == "set:tz")
+async def settings_tz(callback: types.CallbackQuery, state: FSMContext) -> None:
+ tz = db.settings.get("tz")
+ local = now().astimezone(zone(tz)).strftime("%H:%M")
+ await state.set_state(SettingsFlow.waiting_tz)
+ await show(
+ callback,
+ "Пришли часовой пояс, например Europe/Warsaw.\n"
+ f"Сейчас: {esc(tz)}, на часах {local}",
+ back_to("Отмена", "set:main"),
+ )
+ await callback.answer()
+
+
+@router.message(SettingsFlow.waiting_tz, ANSWER)
+async def on_tz(message: types.Message, state: FSMContext) -> None:
+ name = (message.text or "").strip()
+ try:
+ ZoneInfo(name)
+ except (ZoneInfoNotFoundError, ValueError, OSError):
+ await show(
+ message,
+ "Такого пояса нет. Нужно имя из базы tz, например "
+ "Europe/Warsaw или UTC.",
+ back_to("Отмена", "set:main"),
+ )
+ return
+ await state.clear()
+ await db.settings.set("tz", name)
+ text, markup = await settings_view()
+ await show(message, text, markup)
+
+
+@router.callback_query(F.data == "set:digest")
+async def settings_digest(callback: types.CallbackQuery) -> None:
+ kb = InlineKeyboardBuilder()
+ for hour in DIGEST_HOURS:
+ kb.button(text=f"{hour:02d}:00", callback_data=f"set:dg:{hour}")
+ kb.button(text="Выключить", callback_data="set:dg:off")
+ kb.button(text="К настройкам", callback_data="set:main")
+ kb.adjust(4, 3, 2)
+ await show(
+ callback,
+ "Сводка за сутки\n"
+ "Раз в день: аптайм и падения сервисов, операции и алерты флота.\n"
+ f"Время по поясу {esc(db.settings.get('tz'))}.",
+ kb.as_markup(),
+ )
+ await callback.answer()
+
+
+@router.callback_query(F.data.startswith("set:dg:"))
+async def settings_digest_set(callback: types.CallbackQuery) -> None:
+ value = tail_arg(callback.data)
+ await db.settings.set("digest", None if value == "off" else int_arg(callback.data))
+ text, markup = await settings_view()
+ await show(callback, text, markup)
+ await callback.answer()
diff --git a/src/healthbot/keyboards/__init__.py b/src/healthbot/keyboards/__init__.py
index 867252b..3816729 100644
--- a/src/healthbot/keyboards/__init__.py
+++ b/src/healthbot/keyboards/__init__.py
@@ -1,69 +1,111 @@
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
-from utils.db.models import Service
+from healthbot.ui import DANGER, PRIMARY, label, pager, pages
+from utils.db.models import Pin, Service
from utils.env import env
+SERVICES_PAGE = 12
-def main_menu() -> InlineKeyboardMarkup:
+
+def main_menu(pins: list[Pin], *, actions: bool) -> InlineKeyboardMarkup:
kb = InlineKeyboardBuilder()
- kb.button(text="➕ Добавить сервис", callback_data="menu:add")
- kb.button(text="📋 Сервисы", callback_data="menu:list")
- kb.button(text="📊 Статистика", callback_data="menu:stats")
+ rows: list[int] = []
+ for pin in pins:
+ kb.button(
+ text=label(pin.label, 30), callback_data=f"pin:run:{pin.id}", style=PRIMARY
+ )
+ rows += [2] * (len(pins) // 2) + [1] * (len(pins) % 2)
+
+ kb.button(text="Сервисы", callback_data="menu:list:0")
if env.komodo.enabled:
- kb.button(text="🛰 Флот", callback_data="fleet:main")
- kb.adjust(1, 2, 1)
+ kb.button(text="Флот", callback_data="fleet:main")
+ if actions:
+ kb.button(text="Действия", callback_data="act:list:a:0")
+ kb.button(text="Статистика", callback_data="menu:stats")
+ rows += [2, 2]
+ else:
+ kb.button(text="Статистика", callback_data="menu:stats")
+ rows += [2, 1]
else:
- kb.adjust(1, 2)
+ kb.button(text="Статистика", callback_data="menu:stats")
+ rows += [2]
+ kb.button(text="Добавить сервис", callback_data="menu:add")
+ kb.button(text="Настройки", callback_data="set:main")
+ kb.button(text="Обновить", callback_data="menu:main")
+ kb.adjust(*rows, 2, 1)
return kb.as_markup()
-def services_list(services: list[Service]) -> InlineKeyboardMarkup:
+def _service_order(service: Service) -> int:
+ if service.is_paused:
+ return 2
+ return 1 if service.is_up else 0
+
+
+def services_list(services: list[Service], page: int) -> InlineKeyboardMarkup:
+ ordered = sorted(services, key=_service_order)
+ total = pages(len(ordered), SERVICES_PAGE)
+ page = min(page, total - 1)
+ chunk = ordered[page * SERVICES_PAGE : (page + 1) * SERVICES_PAGE]
kb = InlineKeyboardBuilder()
- for svc in services:
- kb.button(text=f"{svc.mark} {svc.name}", callback_data=f"svc:open:{svc.id}")
- kb.button(text="➕ Добавить", callback_data="menu:add")
- kb.button(text="⬅️ Меню", callback_data="menu:main")
- kb.adjust(1)
+ for svc in chunk:
+ speed = (
+ f" · {svc.last_latency_ms} мс"
+ if svc.is_up and not svc.is_paused and svc.last_latency_ms is not None
+ else ""
+ )
+ kb.button(
+ text=f"{svc.mark} {label(svc.name, 36)}{speed}",
+ callback_data=f"svc:open:{svc.id}",
+ )
+ nav = pager(kb, "menu:list", page, total)
+ kb.button(text="Добавить", callback_data="menu:add")
+ kb.button(text="Меню", callback_data="menu:main")
+ kb.adjust(*[1] * len(chunk), *([nav] if nav else []), 2)
return kb.as_markup()
def service_card(service: Service) -> InlineKeyboardMarkup:
kb = InlineKeyboardBuilder()
- kb.button(text="🔍 Проверить сейчас", callback_data=f"svc:check:{service.id}")
+ kb.button(text="Проверить", callback_data=f"svc:check:{service.id}", style=PRIMARY)
+ kb.button(text="Инциденты", callback_data=f"svc:inc:{service.id}")
if service.is_paused:
- kb.button(text="▶️ Возобновить", callback_data=f"svc:resume:{service.id}")
+ kb.button(text="Возобновить", callback_data=f"svc:resume:{service.id}")
else:
- kb.button(text="⏸ Пауза", callback_data=f"svc:pause:{service.id}")
- kb.button(text="✏️ Переименовать", callback_data=f"svc:rename:{service.id}")
- kb.button(text="🗑 Удалить", callback_data=f"svc:del:{service.id}")
- kb.button(text="⬅️ К списку", callback_data="menu:list")
- kb.adjust(1, 2, 2)
+ kb.button(text="Пауза", callback_data=f"svc:pause:{service.id}")
+ kb.button(text="Правило", callback_data=f"svc:rule:{service.id}")
+ kb.button(text="Имя", callback_data=f"svc:rename:{service.id}")
+ kb.button(text="Адрес", callback_data=f"svc:url:{service.id}")
+ kb.button(text="Удалить", callback_data=f"svc:del:{service.id}")
+ kb.button(text="К списку", callback_data="menu:list:0")
+ kb.adjust(2)
return kb.as_markup()
def confirm_delete(service_id: int) -> InlineKeyboardMarkup:
kb = InlineKeyboardBuilder()
- kb.button(text="✅ Да, удалить", callback_data=f"svc:delyes:{service_id}")
- kb.button(text="⬅️ Отмена", callback_data=f"svc:open:{service_id}")
+ kb.button(text="Удалить", callback_data=f"svc:delyes:{service_id}", style=DANGER)
+ kb.button(text="Отмена", callback_data=f"svc:open:{service_id}")
kb.adjust(2)
return kb.as_markup()
def after_check(service_id: int, *, offer_baseline: bool) -> InlineKeyboardMarkup:
- """Клавиатура под результатом ручной проверки."""
kb = InlineKeyboardBuilder()
if offer_baseline:
- kb.button(text="📌 Принять как эталон", callback_data=f"svc:base:{service_id}")
- kb.button(text="🔄 Ещё раз", callback_data=f"svc:check:{service_id}")
- kb.button(text="⬅️ К сервису", callback_data=f"svc:open:{service_id}")
- kb.adjust(1, 2)
+ kb.button(text="Принять как эталон", callback_data=f"svc:base:{service_id}")
+ kb.button(text="Ещё раз", callback_data=f"svc:check:{service_id}")
+ kb.button(text="К сервису", callback_data=f"svc:open:{service_id}")
+ kb.adjust(*([1] if offer_baseline else []), 2)
return kb.as_markup()
-def back_to_menu() -> InlineKeyboardMarkup:
+def back_to(text: str, data: str) -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(
- inline_keyboard=[
- [InlineKeyboardButton(text="⬅️ Меню", callback_data="menu:main")]
- ]
+ inline_keyboard=[[InlineKeyboardButton(text=text, callback_data=data)]]
)
+
+
+def back_to_menu() -> InlineKeyboardMarkup:
+ return back_to("Меню", "menu:main")
diff --git a/src/healthbot/komodo.py b/src/healthbot/komodo.py
index e8a970f..271d88f 100644
--- a/src/healthbot/komodo.py
+++ b/src/healthbot/komodo.py
@@ -1,7 +1,8 @@
-"""Тонкий клиент Komodo: посмотреть флот и нажать кнопку."""
-
+import asyncio
+import json
import re
-from dataclasses import dataclass
+import time
+from dataclasses import dataclass, field
from typing import Any
import aiohttp
@@ -10,27 +11,123 @@ from utils.env import env
from utils.logging import logger
ANSI_RE = re.compile(r"\x1b\[[0-9;]*[a-zA-Z]")
+TAG_RE = re.compile(r"?span[^>]*>")
+SECRET_RE = re.compile(
+ r"\b(key|secret|token|password)(['\"]?\s*[:=]\s*)'[^']*'", re.IGNORECASE
+)
+SYSTEM_USER_RE = re.compile(r"[0-9a-f]{24}")
OK = 200
+CACHE_TTL = 8
+NAMES_TTL = 60
+BUSY_PAUSES = (1, 2, 4)
+POLL_FAST = 1
+POLL_SLOW = 3
+POLL_FAST_FOR = 15
+WAIT_FAILURES = 5
+
+STACK_MARKS = {
+ "running": "🟢",
+ "deploying": "🔄",
+ "restarting": "🔄",
+ "unhealthy": "🟠",
+ "paused": "⏸",
+ "created": "⚪️",
+ "down": "⚫️",
+ "stopped": "🔴",
+ "dead": "🔴",
+ "removing": "🔴",
+ "unknown": "⚪️",
+}
+IDLE_STATES = frozenset({"running", "down", "unknown"})
+SERVER_MARKS = {"Ok": "🟢", "NotOk": "🔴", "Disabled": "⏸"}
+RUN_MARKS = {"Ok": "🟢", "Failed": "🔴", "Running": "🔄"}
+
+KIND_API = {"a": ("Action", "action"), "p": ("Procedure", "procedure")}
def strip_ansi(text: str) -> str:
return ANSI_RE.sub("", text)
+def clean_output(text: str) -> str:
+ text = TAG_RE.sub("", strip_ansi(text))
+ return SECRET_RE.sub(r"\1\2'***'", text).strip()
+
+
+def oid(raw: dict) -> str:
+ value = raw.get("id") or raw.get("_id") or ""
+ if isinstance(value, dict):
+ return str(value.get("$oid", ""))
+ return str(value)
+
+
class KomodoError(RuntimeError):
pass
+def _error_text(status: int, body: str) -> str:
+ try:
+ data = json.loads(body)
+ except ValueError:
+ return f"HTTP {status}: {body[:300]}".strip()
+ if not isinstance(data, dict):
+ return f"HTTP {status}: {body[:300]}"
+ message = str(data.get("error") or body[:300])
+ trace = [str(item) for item in data.get("trace") or [] if item]
+ return message + (f" ({'; '.join(trace)})" if trace else "")
+
+
+def _percent(used: float | None, total: float | None) -> float | None:
+ if not used or not total:
+ return None
+ return used / total * 100
+
+
@dataclass(slots=True)
class Server:
id: str
name: str
state: str
+ version: str = ""
+ cpu: float | None = None
+ load: float | None = None
+ mem_used: float | None = None
+ mem_total: float | None = None
+ disk_used: float | None = None
+ disk_total: float | None = None
+ thresholds: dict = field(default_factory=dict)
@property
def mark(self) -> str:
- return {"Ok": "🟢", "NotOk": "🔴", "Disabled": "⏸"}.get(self.state, "⚪️")
+ if self.state == "Ok" and self.strained:
+ return "🟠"
+ return SERVER_MARKS.get(self.state, "⚪️")
+
+ @property
+ def mem(self) -> float | None:
+ return _percent(self.mem_used, self.mem_total)
+
+ @property
+ def disk(self) -> float | None:
+ return _percent(self.disk_used, self.disk_total)
+
+ @property
+ def strains(self) -> list[str]:
+ found = []
+ for label, value, key in (
+ ("cpu", self.cpu, "cpu_warning"),
+ ("память", self.mem, "mem_warning"),
+ ("диск", self.disk, "disk_warning"),
+ ):
+ limit = self.thresholds.get(key)
+ if value is not None and limit and value >= limit:
+ found.append(f"{label} {value:.0f}%")
+ return found
+
+ @property
+ def strained(self) -> bool:
+ return bool(self.strains)
@dataclass(slots=True)
@@ -38,52 +135,208 @@ class Stack:
id: str
name: str
state: str
+ status: str
server_id: str
+ server_name: str
+ repo: str = ""
+ branch: str = ""
+ deployed_hash: str = ""
+ latest_hash: str = ""
+ image_updates: int = 0
+ services: list[str] = field(default_factory=list)
@property
def mark(self) -> str:
- return {
- "running": "🟢",
- "unhealthy": "🟠",
- "paused": "⏸",
- "down": "⚫️",
- "stopped": "🔴",
- "restarting": "🔄",
- "unknown": "⚪️",
- }.get(self.state, "⚪️")
+ return STACK_MARKS.get(self.state, "⚪️")
+
+ @property
+ def healthy(self) -> bool:
+ return self.state == "running"
+
+ @property
+ def troubled(self) -> bool:
+ return self.state not in IDLE_STATES
+
+ @property
+ def behind(self) -> bool:
+ return bool(
+ self.deployed_hash
+ and self.latest_hash
+ and self.deployed_hash != self.latest_hash
+ )
+
+
+@dataclass(slots=True)
+class Container:
+ service: str
+ state: str
+ status: str
+ image: str
+ cpu: str = ""
+ mem: str = ""
+
+ @property
+ def mark(self) -> str:
+ return STACK_MARKS.get(self.state.lower(), "⚪️")
+
+
+@dataclass(slots=True)
+class Resource:
+ kind: str
+ id: str
+ name: str
+ state: str
+ last_run_at: int | None = None
+ next_run_at: int | None = None
+ schedule_error: str | None = None
+
+ @property
+ def mark(self) -> str:
+ return RUN_MARKS.get(self.state, "⚪️")
+
+
+@dataclass(slots=True)
+class Detail:
+ kind: str
+ id: str
+ name: str
+ description: str = ""
+ args_format: str = ""
+ args_text: str = ""
+ schedule: str = ""
+ schedule_enabled: bool = False
+ stages: list[str] = field(default_factory=list)
+
+
+@dataclass(slots=True)
+class Stage:
+ name: str
+ success: bool
+ output: str
+
+
+@dataclass(slots=True)
+class Update:
+ id: str
+ operation: str
+ status: str
+ success: bool
+ start_ts: int
+ end_ts: int | None
+ target_type: str
+ target_id: str
+ operator: str
+ stages: list[Stage] = field(default_factory=list)
+
+ @property
+ def done(self) -> bool:
+ return self.status == "Complete"
+
+ @property
+ def seconds(self) -> float:
+ end = self.end_ts or int(time.time() * 1000)
+ return max(0.0, (end - self.start_ts) / 1000)
+
+
+def _server(raw: dict) -> Server:
+ info = raw.get("info") or {}
+ stats = info.get("stats") or {}
+ load = (stats.get("load_average") or {}).get("one")
+ return Server(
+ id=raw["id"],
+ name=raw["name"],
+ state=info.get("state", ""),
+ version=info.get("version") or "",
+ cpu=stats.get("cpu_perc"),
+ load=load,
+ mem_used=stats.get("mem_used_gb"),
+ mem_total=stats.get("mem_total_gb"),
+ disk_used=stats.get("disk_used_gb"),
+ disk_total=stats.get("disk_total_gb"),
+ thresholds=info.get("alerting_thresholds") or {},
+ )
+
+
+def _stack(raw: dict) -> Stack:
+ info = raw.get("info") or {}
+ services = info.get("services") or []
+ return Stack(
+ id=raw["id"],
+ name=raw["name"],
+ state=info.get("state", ""),
+ status=info.get("status") or "",
+ server_id=info.get("server_id", ""),
+ server_name=info.get("server_name") or "",
+ repo=info.get("repo") or "",
+ branch=info.get("branch") or "",
+ deployed_hash=info.get("deployed_hash") or "",
+ latest_hash=info.get("latest_hash") or "",
+ image_updates=sum(bool(s.get("update_available")) for s in services),
+ services=[s.get("service", "") for s in services],
+ )
+
+
+def _resource(kind: str, raw: dict) -> Resource:
+ info = raw.get("info") or {}
+ return Resource(
+ kind=kind,
+ id=raw["id"],
+ name=raw["name"],
+ state=info.get("state", ""),
+ last_run_at=info.get("last_run_at"),
+ next_run_at=info.get("next_scheduled_run"),
+ schedule_error=info.get("schedule_error"),
+ )
+
+
+def _update(raw: dict) -> Update:
+ target = raw.get("target") or {}
+ return Update(
+ id=oid(raw),
+ operation=raw.get("operation", ""),
+ status=raw.get("status", ""),
+ success=bool(raw.get("success")),
+ start_ts=raw.get("start_ts") or 0,
+ end_ts=raw.get("end_ts"),
+ target_type=target.get("type", ""),
+ target_id=target.get("id", ""),
+ operator=raw.get("username") or raw.get("operator") or "",
+ stages=[
+ Stage(
+ name=log.get("stage", ""),
+ success=bool(log.get("success")),
+ output=(log.get("stdout") or "") + "\n" + (log.get("stderr") or ""),
+ )
+ for log in raw.get("logs") or []
+ ],
+ )
class Komodo:
def __init__(self) -> None:
self._session: aiohttp.ClientSession | None = None
+ self._cache: dict[str, tuple[float, Any]] = {}
+ self._usernames: dict[str, str] = {}
@property
def enabled(self) -> bool:
return env.komodo.enabled
- async def _call(self, route: str, kind: str, params: dict | None = None) -> Any: # noqa: ANN401
- if not self.enabled:
- msg = "Komodo не настроен (KOMODO__URL / KOMODO__KEY / KOMODO__SECRET)"
- raise KomodoError(msg)
+ async def _post(self, route: str, kind: str, params: dict) -> tuple[int, str]:
if self._session is None or self._session.closed:
self._session = aiohttp.ClientSession()
url = env.komodo.url.rstrip("/") + route
try:
async with self._session.post(
url,
- json={"type": kind, "params": params or {}},
+ json={"type": kind, "params": params},
headers={
"X-Api-Key": env.komodo.key.get_secret_value(),
"X-Api-Secret": env.komodo.secret.get_secret_value(),
},
timeout=aiohttp.ClientTimeout(total=env.komodo.timeout),
) as resp:
- text = await resp.text()
- if resp.status != OK:
- logger.warning(f"Komodo {kind} -> HTTP {resp.status}: {text[:300]}")
- msg = f"Komodo ответил HTTP {resp.status}"
- raise KomodoError(msg)
- return await resp.json()
+ return resp.status, await resp.text()
except TimeoutError as exc:
msg = f"Komodo не ответил за {env.komodo.timeout}с"
raise KomodoError(msg) from exc
@@ -91,38 +344,194 @@ class Komodo:
msg = f"Нет связи с Komodo: {exc}"
raise KomodoError(msg) from exc
- async def read(self, kind: str, params: dict | None = None) -> Any: # noqa: ANN401
- return await self._call("/read", kind, params)
+ async def _call(self, route: str, kind: str, params: dict | None = None) -> Any: # noqa: ANN401
+ if not self.enabled:
+ msg = "Komodo не настроен: нужны KOMODO__URL, KOMODO__KEY, KOMODO__SECRET"
+ raise KomodoError(msg)
+ status, body = 0, ""
+ pauses = BUSY_PAUSES if route == "/read" else ()
+ for pause in (*pauses, None):
+ status, body = await self._post(route, kind, params or {})
+ if status == OK:
+ return json.loads(body)
+ if pause is None or "busy" not in body.lower():
+ break
+ await asyncio.sleep(pause)
+ logger.warning(f"Komodo {kind} -> HTTP {status}: {body[:300]}")
+ raise KomodoError(_error_text(status, body))
- async def execute(self, kind: str, params: dict | None = None) -> Any: # noqa: ANN401
- return await self._call("/execute", kind, params)
+ async def read(
+ self, kind: str, params: dict | None = None, *, ttl: float = 0
+ ) -> Any: # noqa: ANN401
+ key = kind + json.dumps(params or {}, sort_keys=True)
+ cached = self._cache.get(key)
+ if ttl and cached and time.monotonic() - cached[0] < ttl:
+ return cached[1]
+ data = await self._call("/read", kind, params)
+ if ttl:
+ self._cache[key] = (time.monotonic(), data)
+ return data
+
+ async def execute(self, kind: str, params: dict | None = None) -> Update:
+ raw = await self._call("/execute", kind, params)
+ self._cache.clear()
+ return _update(raw if isinstance(raw, dict) else {})
+
+ def forget(self) -> None:
+ self._cache.clear()
+
+ async def _list(self, kind: str, ttl: float = CACHE_TTL) -> list[dict]:
+ return await self.read(kind, {"limit": 0}, ttl=ttl)
+
+ async def version(self) -> str:
+ return (await self.read("GetVersion", ttl=NAMES_TTL)).get("version", "")
async def servers(self) -> list[Server]:
- raw = await self.read("ListServers")
- return [
- Server(id=s["id"], name=s["name"], state=s.get("info", {}).get("state", ""))
- for s in raw
- ]
+ return [_server(s) for s in await self._list("ListServers")]
async def stacks(self) -> list[Stack]:
- raw = await self.read("ListStacks")
- return [
- Stack(
- id=s["id"],
- name=s["name"],
- state=s.get("info", {}).get("state", ""),
- server_id=s.get("info", {}).get("server_id", ""),
- )
- for s in raw
- ]
+ return [_stack(s) for s in await self._list("ListStacks")]
- async def stack_log(self, stack: str, tail: int = 40) -> str:
- """Хвост лога стека одной строкой."""
+ async def stack(self, stack_id: str) -> Stack | None:
+ return next((s for s in await self.stacks() if s.id == stack_id), None)
+
+ async def containers(self, stack_id: str) -> list[Container]:
+ raw = await self.read("ListStackServices", {"stack": stack_id})
+ result = []
+ for item in raw:
+ container = item.get("container") or {}
+ stats = container.get("stats") or {}
+ result.append(
+ Container(
+ service=item.get("service", ""),
+ state=container.get("state") or "down",
+ status=container.get("status") or "",
+ image=item.get("image") or container.get("image") or "",
+ cpu=stats.get("cpu_perc") or "",
+ mem=(stats.get("mem_usage") or "").split(" / ")[0],
+ )
+ )
+ return result
+
+ async def disks(self, server_id: str) -> list[tuple[str, float, float]]:
+ raw = await self.read("GetSystemStats", {"server": server_id}, ttl=CACHE_TTL)
+ seen: set[tuple[float, float]] = set()
+ result = []
+ for disk in raw.get("disks") or []:
+ used, total = disk.get("used_gb") or 0.0, disk.get("total_gb") or 0.0
+ if not total or (used, total) in seen:
+ continue
+ seen.add((used, total))
+ result.append((disk.get("mount", "?"), used, total))
+ return result
+
+ async def stack_log(self, stack: str, services: list[str], tail: int) -> str:
raw = await self.read(
- "GetStackLog", {"stack": stack, "services": [], "tail": tail}
+ "GetStackLog", {"stack": stack, "services": services, "tail": tail}
)
out = (raw.get("stdout") or "") + (raw.get("stderr") or "")
- return strip_ansi(out).strip() or "(пусто)"
+ return strip_ansi(out).strip()
+
+ async def resources(self, kind: str) -> list[Resource]:
+ api, _ = KIND_API[kind]
+ items = [_resource(kind, r) for r in await self._list(f"List{api}s")]
+ return sorted(items, key=lambda r: r.name.lower())
+
+ async def detail(self, kind: str, target_id: str) -> Detail:
+ api, param = KIND_API[kind]
+ raw = await self.read(f"Get{api}", {param: target_id}, ttl=CACHE_TTL)
+ config = raw.get("config") or {}
+ return Detail(
+ kind=kind,
+ id=oid(raw),
+ name=raw.get("name", ""),
+ description=raw.get("description") or "",
+ args_format=config.get("arguments_format") or "",
+ args_text=config.get("arguments") or "",
+ schedule=config.get("schedule") or "",
+ schedule_enabled=bool(config.get("schedule_enabled")),
+ stages=[
+ stage.get("name", "")
+ for stage in config.get("stages") or []
+ if stage.get("enabled", True)
+ ],
+ )
+
+ async def run(self, kind: str, target_id: str, args: dict | None) -> Update:
+ api, param = KIND_API[kind]
+ params: dict[str, Any] = {param: target_id}
+ if kind == "a" and args:
+ params["args"] = args
+ return await self.execute(f"Run{api}", params)
+
+ async def update(self, update_id: str) -> Update:
+ return _update(await self.read("GetUpdate", {"id": update_id}))
+
+ async def updates(self, query: dict | None = None, page: int = 0) -> list[Update]:
+ raw = await self.read("ListUpdates", {"query": query or {}, "page": page})
+ return [_update(u) for u in raw.get("updates") or []]
+
+ async def open_alerts(self) -> list[dict]:
+ try:
+ raw = await self.read(
+ "ListAlerts", {"query": {"resolved": False}}, ttl=CACHE_TTL
+ )
+ except KomodoError:
+ return []
+ return raw.get("alerts") or []
+
+ async def wait(self, update_id: str, timeout: float) -> Update | None: # noqa: ASYNC109
+ started = time.monotonic()
+ failures = 0
+ while True:
+ elapsed = time.monotonic() - started
+ try:
+ update = await self.update(update_id)
+ failures = 0
+ except KomodoError:
+ failures += 1
+ if failures >= WAIT_FAILURES:
+ raise
+ update = None
+ if update is not None and update.done:
+ self._cache.clear()
+ return update
+ if elapsed >= timeout:
+ return None
+ await asyncio.sleep(POLL_FAST if elapsed < POLL_FAST_FOR else POLL_SLOW)
+
+ async def names(self) -> dict[str, str]:
+ kinds = (
+ "ListServers",
+ "ListStacks",
+ "ListActions",
+ "ListProcedures",
+ "ListResourceSyncs",
+ "ListRepos",
+ "ListDeployments",
+ "ListBuilds",
+ "ListAlerters",
+ )
+ lists = await asyncio.gather(
+ *(self._list(kind, ttl=NAMES_TTL) for kind in kinds), return_exceptions=True
+ )
+ result: dict[str, str] = {}
+ for items in lists:
+ if isinstance(items, BaseException):
+ continue
+ result.update({item["id"]: item["name"] for item in items})
+ return result
+
+ async def username(self, user: str) -> str:
+ if not SYSTEM_USER_RE.fullmatch(user):
+ return user
+ if user not in self._usernames:
+ try:
+ raw = await self.read("GetUsername", {"user_id": user})
+ self._usernames[user] = raw.get("username") or user
+ except KomodoError:
+ self._usernames[user] = "Komodo"
+ return self._usernames[user]
async def close(self) -> None:
if self._session is not None and not self._session.closed:
diff --git a/src/healthbot/monitor.py b/src/healthbot/monitor.py
index 91ab5bd..3cee02a 100644
--- a/src/healthbot/monitor.py
+++ b/src/healthbot/monitor.py
@@ -1,24 +1,47 @@
-"""Фоновый цикл: пингует сервисы и уведомляет о падениях/восстановлениях."""
-
import asyncio
-import html
+import time
+from collections.abc import Awaitable
+from datetime import datetime
import aiohttp
+from aiogram.types import InlineKeyboardMarkup
+from aiogram.utils.keyboard import InlineKeyboardBuilder
-from healthbot.checker import Verdict, evaluate, probe
+from healthbot.checker import Verdict, evaluate, probe, tls_expiry
from healthbot.notify import notify
+from healthbot.stats import current_hour
+from healthbot.ui import esc
from utils.db import db
from utils.db.models import Service
from utils.env import env
from utils.format import human_duration, now, parse
from utils.logging import logger
+CONCURRENCY = 10
+KEEP_HOURS = 24 * 40
+TLS_EVERY = 6 * 3600
+TLS_WARN_DAYS = (14, 7, 3, 1)
+DAY = 86400
+
def _head(service: Service, icon: str, tail: str) -> str:
- return (
- f"{icon} {html.escape(service.name)} {tail}\n"
- f"{html.escape(service.url)}"
- )
+ return f"{icon} {esc(service.name)} {tail}\n{esc(service.url)}"
+
+
+def _down_kb(service: Service, incident_id: int | None) -> InlineKeyboardMarkup:
+ kb = InlineKeyboardBuilder()
+ kb.button(text="Проверить", callback_data=f"go:check:{service.id}")
+ kb.button(text="Открыть", callback_data=f"go:svc:{service.id}")
+ if incident_id is not None:
+ kb.button(text="Не напоминать", callback_data=f"svc:mute:{incident_id}")
+ kb.adjust(2, 1)
+ return kb.as_markup()
+
+
+def _open_kb(service: Service) -> InlineKeyboardMarkup:
+ kb = InlineKeyboardBuilder()
+ kb.button(text="Открыть", callback_data=f"go:svc:{service.id}")
+ return kb.as_markup()
async def _on_recovered(service: Service) -> None:
@@ -28,24 +51,27 @@ async def _on_recovered(service: Service) -> None:
await db.incidents.close(current.id)
downtime = human_duration((now() - parse(current.started_at)).total_seconds())
await db.services.set_up(service.id, is_up=True)
- await notify(f"{_head(service, '✅', 'снова в строю')}\nДаунтайм: {downtime}")
+ await notify(
+ f"{_head(service, '✅', 'снова в строю')}\nДаунтайм: {downtime}",
+ _open_kb(service),
+ )
logger.info(f"[green]UP[/] {service.name}")
async def _on_failed(service: Service, verdict: Verdict) -> None:
- await db.incidents.open(service.id, verdict.reason)
+ incident = await db.incidents.open(service.id, verdict.reason)
await db.services.set_up(service.id, is_up=False)
- lines = [_head(service, "🔴", "лёг"), f"Причина: {html.escape(verdict.reason)}"]
+ lines = [_head(service, "🔴", "лёг"), f"Причина: {esc(verdict.reason)}"]
report = verdict.report()
if report:
lines.append(report)
- await notify("\n".join(lines))
+ await notify("\n".join(lines), _down_kb(service, incident.id))
logger.warning(f"[red]DOWN[/] {service.name}: {verdict.reason}")
async def _on_still_down(service: Service, verdict: Verdict) -> None:
current = await db.incidents.current(service.id)
- if current is None:
+ if current is None or current.muted:
return
idle = (now() - parse(current.last_reminder_at)).total_seconds()
if idle < env.monitor.reminder_interval:
@@ -54,19 +80,24 @@ async def _on_still_down(service: Service, verdict: Verdict) -> None:
since = human_duration((now() - parse(current.started_at)).total_seconds())
lines = [
_head(service, "⏰", f"всё ещё лежит (уже {since})"),
- f"Причина: {html.escape(verdict.reason)}",
+ f"Причина: {esc(verdict.reason)}",
]
report = verdict.report()
if report:
lines.append(report)
- await notify("\n".join(lines))
+ await notify("\n".join(lines), _down_kb(service, current.id))
async def check_one(session: aiohttp.ClientSession, service: Service) -> Verdict:
- """Одна проверка со всеми побочными эффектами. Возвращает вердикт."""
result = await probe(session, service.url, env.monitor.request_timeout)
verdict = evaluate(service, result)
await db.services.touch_probe(service.id, result.latency_ms)
+ await db.stats.record(
+ service.id,
+ current_hour(),
+ healthy=verdict.healthy,
+ latency_ms=result.latency_ms,
+ )
if verdict.healthy:
if service.fail_streak:
@@ -91,21 +122,65 @@ async def check_one(session: aiohttp.ClientSession, service: Service) -> Verdict
return verdict
+def tls_step(expires: datetime, warned: int) -> int:
+ days = (expires - now()).total_seconds() / DAY
+ due = [limit for limit in TLS_WARN_DAYS if days <= limit]
+ if not due:
+ return 0
+ step = min(due)
+ return step if not warned or step < warned else warned
+
+
+async def check_tls(service: Service) -> None:
+ expires = await tls_expiry(service.url)
+ if expires is None:
+ return
+ step = tls_step(expires, service.tls_warned)
+ await db.services.set_tls(service.id, expires.isoformat(), step)
+ if step and step != service.tls_warned:
+ left = max(0.0, (expires - now()).total_seconds())
+ await notify(
+ f"{_head(service, '🟠', 'сертификат скоро истечёт')}\n"
+ f"Осталось: {human_duration(left)}",
+ _open_kb(service),
+ )
+
+
+async def _guarded(sem: asyncio.Semaphore, job: Awaitable[object], what: str) -> None:
+ async with sem:
+ try:
+ await job
+ except Exception:
+ logger.exception(f"Check failed: {what}")
+
+
+async def _sweep(session: aiohttp.ClientSession, *, with_tls: bool) -> None:
+ services = [s for s in await db.services.all() if not s.is_paused]
+ sem = asyncio.Semaphore(CONCURRENCY)
+ jobs = [_guarded(sem, check_one(session, s), s.url) for s in services]
+ if with_tls:
+ jobs += [_guarded(sem, check_tls(s), f"tls {s.url}") for s in services]
+ await asyncio.gather(*jobs)
+
+
async def run_monitor() -> None:
logger.info(
- f"Monitor: каждые {env.monitor.check_interval}с, "
- f"порог падения {env.monitor.failure_threshold}"
+ f"Monitor: every {env.monitor.check_interval}s, "
+ f"failure threshold {env.monitor.failure_threshold}"
)
+ next_tls = 0.0
+ purged_hour = 0
async with aiohttp.ClientSession() as session:
while True:
try:
- for service in await db.services.all():
- if service.is_paused:
- continue
- try:
- await check_one(session, service)
- except Exception:
- logger.exception(f"Ошибка проверки {service.url}")
+ with_tls = time.monotonic() >= next_tls
+ await _sweep(session, with_tls=with_tls)
+ if with_tls:
+ next_tls = time.monotonic() + TLS_EVERY
+ hour = current_hour()
+ if hour != purged_hour:
+ await db.stats.purge(hour - KEEP_HOURS)
+ purged_hour = hour
except Exception:
- logger.exception("Ошибка цикла мониторинга")
+ logger.exception("Monitor loop failed")
await asyncio.sleep(env.monitor.check_interval)
diff --git a/src/healthbot/notify.py b/src/healthbot/notify.py
index a57b046..84417dc 100644
--- a/src/healthbot/notify.py
+++ b/src/healthbot/notify.py
@@ -1,28 +1,15 @@
-"""Отправка уведомлений админам."""
+from aiogram.types import InlineKeyboardMarkup
from healthbot.common import bot
+from healthbot.ui import fit
from utils.env import env
from utils.logging import logger
-TELEGRAM_LIMIT = 4096
-SAFE_LIMIT = 3900
-
-def clip(text: str) -> str:
- if len(text) <= TELEGRAM_LIMIT:
- return text
- cut = text[:SAFE_LIMIT]
- cut = cut[: cut.rfind("\n")] if "\n" in cut else cut
- if cut.count("") > cut.count(""):
- cut += ""
- return cut + "\n…обрезано"
-
-
-async def notify(text: str) -> None:
- """Разослать всем админам. Падение одного не мешает остальным."""
- payload = clip(text)
+async def notify(text: str, markup: InlineKeyboardMarkup | None = None) -> None:
+ payload = fit(text)
for admin in env.admins:
try:
- await bot.send_message(admin, payload)
+ await bot.send_message(admin, payload, reply_markup=markup)
except Exception:
- logger.exception(f"Не смог отправить уведомление {admin}")
+ logger.exception(f"Notification to {admin} failed")
diff --git a/src/healthbot/runs.py b/src/healthbot/runs.py
new file mode 100644
index 0000000..4bbb5e4
--- /dev/null
+++ b/src/healthbot/runs.py
@@ -0,0 +1,147 @@
+import contextlib
+from collections.abc import Awaitable, Callable
+
+from aiogram.exceptions import TelegramBadRequest
+from aiogram.types import CallbackQuery, InlineKeyboardMarkup, Message
+from aiogram.utils.keyboard import InlineKeyboardBuilder
+
+from healthbot.komodo import KomodoError, Update, clean_output, komodo
+from healthbot.ui import edit, esc, pre, show
+from utils.format import clock
+
+RUN_TIMEOUT = 30 * 60
+OUTPUT_LIMIT = 3000
+QUICK_SECONDS = 10
+NOISE = ("Action completed successfully",)
+ACTIVE: set[str] = set()
+
+Back = list[tuple[str, str]]
+
+
+def _stage_text(output: str) -> str:
+ lines = [
+ line
+ for line in clean_output(output).splitlines()
+ if not any(mark in line for mark in NOISE)
+ ]
+ return "\n".join(lines).strip()
+
+
+def output_of(update: Update, *, only_failed: bool) -> str:
+ parts = []
+ for stage in update.stages:
+ if "config" in stage.name.lower():
+ if not stage.success:
+ parts.append(f"{stage.name}: вывод скрыт, в нём бывают секреты")
+ continue
+ if only_failed and stage.success:
+ continue
+ text = _stage_text(stage.output)
+ if not text:
+ continue
+ parts.append(f"{stage.name}:\n{text}" if len(update.stages) > 1 else text)
+ return "\n\n".join(parts)
+
+
+def result_text(title: str, update: Update, *, verbose: bool, extra: str = "") -> str:
+ icon = "✅" if update.success else "🔴"
+ state = "" if update.success else " не удалось"
+ lines = [f"{icon} {esc(title)}{state} · {clock(update.seconds)}"]
+ if extra:
+ lines.append(extra)
+ if verbose or not update.success:
+ output = output_of(update, only_failed=not verbose)
+ if output:
+ lines.append(pre(output, OUTPUT_LIMIT))
+ return "\n".join(lines)
+
+
+def result_kb(update: Update | None, back: Back) -> InlineKeyboardMarkup:
+ kb = InlineKeyboardBuilder()
+ for text, data in back:
+ kb.button(text=text, callback_data=data)
+ rows = [len(back)] if back else []
+ if update is not None and len(output_of(update, only_failed=False)) > OUTPUT_LIMIT:
+ kb.button(text="Весь вывод файлом", callback_data=f"run:file:{update.id}")
+ rows.append(1)
+ kb.adjust(*rows or [1])
+ return kb.as_markup()
+
+
+async def track( # noqa: PLR0913
+ origin: Message | CallbackQuery,
+ title: str,
+ starter: Callable[[], Awaitable[Update]],
+ *,
+ back: Back,
+ verbose: bool,
+ extra: str = "",
+ new: bool = False,
+) -> None:
+ source = origin.message if isinstance(origin, CallbackQuery) else origin
+ key = f"{source.message_id if source else 0}:{title}"
+ if key in ACTIVE:
+ return
+ ACTIVE.add(key)
+ try:
+ await _track(
+ origin, title, starter, back, verbose=verbose, extra=extra, new=new
+ )
+ finally:
+ ACTIVE.discard(key)
+
+
+async def _track( # noqa: PLR0913
+ origin: Message | CallbackQuery,
+ title: str,
+ starter: Callable[[], Awaitable[Update]],
+ back: Back,
+ *,
+ verbose: bool,
+ extra: str,
+ new: bool,
+) -> None:
+ progress = await show(origin, f"⏳ {esc(title)} выполняется", new=new)
+ if progress is None:
+ return
+ try:
+ started = await starter()
+ except KomodoError as exc:
+ await edit(
+ progress,
+ f"🔴 {esc(title)} не запустилось\n{esc(exc)}",
+ result_kb(None, back),
+ )
+ return
+
+ if not started.id:
+ await edit(progress, f"✅ {esc(title)} запущено", result_kb(None, back))
+ return
+
+ check = InlineKeyboardBuilder()
+ check.button(text="Проверить", callback_data=f"run:st:{started.id}")
+ try:
+ update = await komodo.wait(started.id, RUN_TIMEOUT)
+ except KomodoError as exc:
+ await edit(
+ progress,
+ f"⏳ {esc(title)} запущено, результат узнать не удалось\n{esc(exc)}",
+ check.as_markup(),
+ )
+ return
+ if update is None:
+ await edit(
+ progress,
+ f"⏳ {esc(title)} идёт дольше {RUN_TIMEOUT // 60} минут",
+ check.as_markup(),
+ )
+ return
+
+ text = result_text(title, update, verbose=verbose, extra=extra)
+ markup = result_kb(update, back)
+ if update.seconds < QUICK_SECONDS:
+ await edit(progress, text, markup)
+ return
+ await show(progress, text, markup)
+ with contextlib.suppress(TelegramBadRequest):
+ await progress.delete()
diff --git a/src/healthbot/stats.py b/src/healthbot/stats.py
new file mode 100644
index 0000000..f750343
--- /dev/null
+++ b/src/healthbot/stats.py
@@ -0,0 +1,67 @@
+from datetime import datetime, timedelta
+
+from healthbot.ui import pct, spark
+from utils.db.models import Hour, Incident, Service
+from utils.format import now, parse, zone
+
+DAY = 86400
+PERIODS = (("24ч", DAY), ("7д", 7 * DAY), ("30д", 30 * DAY))
+CHART_DAYS = 14
+CHART_HOURS = 24
+BLIP = 300
+
+
+def current_hour() -> int:
+ return int(now().timestamp()) // 3600
+
+
+def downtime(incidents: list[Incident], start: datetime, end: datetime) -> float:
+ total = 0.0
+ for incident in incidents:
+ began = max(parse(incident.started_at), start)
+ ended = min(parse(incident.ended_at) if incident.ended_at else end, end)
+ if ended > began:
+ total += (ended - began).total_seconds()
+ return total
+
+
+def uptime(service: Service, incidents: list[Incident], period: float) -> float | None:
+ end = now()
+ start = max(end - timedelta(seconds=period), parse(service.created_at))
+ window = (end - start).total_seconds()
+ if window <= 0:
+ return None
+ return max(0.0, (window - downtime(incidents, start, end)) / window * 100)
+
+
+def uptime_line(service: Service, incidents: list[Incident]) -> str:
+ return ", ".join(
+ f"{name} {pct(uptime(service, incidents, period))}" for name, period in PERIODS
+ )
+
+
+def day_squares(service: Service, incidents: list[Incident], tz_name: str) -> str:
+ tz = zone(tz_name)
+ today = now().astimezone(tz).replace(hour=0, minute=0, second=0, microsecond=0)
+ created = parse(service.created_at)
+ squares = []
+ for offset in range(CHART_DAYS - 1, -1, -1):
+ start = today - timedelta(days=offset)
+ end = min(start + timedelta(days=1), now())
+ if end <= created:
+ squares.append("⬜")
+ continue
+ lost = downtime(incidents, max(start, created), end)
+ squares.append("🟩" if not lost else "🟨" if lost < BLIP else "🟥")
+ return "".join(squares)
+
+
+def latency_chart(hours: dict[int, Hour]) -> tuple[str, int | None, int | None]:
+ last = current_hour()
+ window = [hours.get(h) for h in range(last - CHART_HOURS + 1, last + 1)]
+ values = [h.latency if h else None for h in window]
+ present = [v for v in values if v is not None]
+ if not present:
+ return "", None, None
+ peak = max(h.latency_max for h in window if h and h.latency_n)
+ return spark(values), sum(present) // len(present), peak
diff --git a/src/healthbot/ui.py b/src/healthbot/ui.py
new file mode 100644
index 0000000..7ae7bf9
--- /dev/null
+++ b/src/healthbot/ui.py
@@ -0,0 +1,153 @@
+import html
+from collections.abc import Sequence
+
+from aiogram import F
+from aiogram.enums import ButtonStyle
+from aiogram.exceptions import TelegramBadRequest
+from aiogram.types import CallbackQuery, InlineKeyboardMarkup, Message
+from aiogram.utils.keyboard import InlineKeyboardBuilder
+
+TELEGRAM_LIMIT = 4096
+SAFE_LIMIT = 3900
+BUTTON_LIMIT = 48
+SPARK = "▁▂▃▄▅▆▇█"
+FULL = 100
+
+MENU_COMMANDS = ("start", "list", "stats", "fleet", "actions", "settings")
+ANSWER = F.text & ~F.text.regexp(rf"^/({'|'.join(MENU_COMMANDS)})(@\w+)?$")
+
+PRIMARY = ButtonStyle.PRIMARY
+SUCCESS = ButtonStyle.SUCCESS
+DANGER = ButtonStyle.DANGER
+
+
+def esc(value: object) -> str:
+ return html.escape(str(value), quote=False)
+
+
+def fit(text: str) -> str:
+ if len(text) <= TELEGRAM_LIMIT:
+ return text
+ cut = text[:SAFE_LIMIT]
+ cut = cut[: cut.rfind("\n")] if "\n" in cut else cut
+ cut = cut[: cut.rfind("<")] if cut.rfind("<") > cut.rfind(">") else cut
+ for tag in ("code", "pre", "b", "i"):
+ if cut.count(f"<{tag}>") > cut.count(f"{tag}>"):
+ cut += f"{tag}>"
+ return cut + "\nобрезано"
+
+
+def tail(text: str, limit: int) -> str:
+ text = text.strip()
+ if len(text) <= limit:
+ return text
+ cut = text[-limit:]
+ return cut[cut.find("\n") + 1 :] if "\n" in cut else cut
+
+
+def pre(text: str, limit: int = 3000) -> str:
+ return f"{esc(tail(text, limit))}"
+
+
+def label(text: str, limit: int = BUTTON_LIMIT) -> str:
+ text = " ".join(text.split())
+ return text if len(text) <= limit else text[: limit - 1] + "…"
+
+
+def pct(value: float | None) -> str:
+ if value is None:
+ return "нет данных"
+ if value >= FULL:
+ return "100%"
+ return f"{min(value, 99.99):.2f}%"
+
+
+def spark(values: Sequence[float | None]) -> str:
+ present = [v for v in values if v is not None]
+ if not present:
+ return ""
+ low, high = min(present), max(present)
+ span = high - low
+ chars = []
+ for value in values:
+ if value is None:
+ chars.append("·")
+ elif not span:
+ chars.append(SPARK[2])
+ else:
+ chars.append(SPARK[round((value - low) / span * (len(SPARK) - 1))])
+ return "".join(chars)
+
+
+def plural(count: int, one: str, few: str, many: str) -> str:
+ tens, units = count % 100, count % 10
+ if units == 1 and tens != 11: # noqa: PLR2004
+ return one
+ if units in {2, 3, 4} and tens not in {12, 13, 14}:
+ return few
+ return many
+
+
+def pages(total: int, size: int) -> int:
+ return max(1, (total + size - 1) // size)
+
+
+def pager(kb: InlineKeyboardBuilder, prefix: str, page: int, total_pages: int) -> int:
+ if total_pages <= 1:
+ return 0
+ count = 0
+ if page > 0:
+ kb.button(text="‹ Назад", callback_data=f"{prefix}:{page - 1}")
+ count += 1
+ if page < total_pages - 1:
+ kb.button(text="Дальше ›", callback_data=f"{prefix}:{page + 1}")
+ count += 1
+ return count
+
+
+async def show(
+ event: Message | CallbackQuery,
+ text: str,
+ markup: InlineKeyboardMarkup | None = None,
+ *,
+ new: bool = False,
+) -> Message | None:
+ text = fit(text)
+ if isinstance(event, Message):
+ return await event.answer(text, reply_markup=markup)
+ message = event.message
+ if isinstance(message, Message) and not new:
+ try:
+ edited = await message.edit_text(text, reply_markup=markup)
+ except TelegramBadRequest as exc:
+ if "not modified" in str(exc):
+ return message
+ else:
+ return edited if isinstance(edited, Message) else message
+ if isinstance(message, Message):
+ return await message.answer(text, reply_markup=markup)
+ if event.bot is not None:
+ return await event.bot.send_message(
+ event.from_user.id, text, reply_markup=markup
+ )
+ return None
+
+
+async def edit(
+ message: Message, text: str, markup: InlineKeyboardMarkup | None = None
+) -> None:
+ text = fit(text)
+ try:
+ await message.edit_text(text, reply_markup=markup)
+ except TelegramBadRequest as exc:
+ if "not modified" not in str(exc):
+ await message.answer(text, reply_markup=markup)
+
+
+def tail_arg(data: str | None) -> str:
+ return (data or "").rsplit(":", 1)[-1]
+
+
+def int_arg(data: str | None, default: int = 0) -> int:
+ value = tail_arg(data)
+ return int(value) if value.lstrip("-").isdigit() else default
diff --git a/src/healthbot/web.py b/src/healthbot/web.py
index dcbbde2..9066327 100644
--- a/src/healthbot/web.py
+++ b/src/healthbot/web.py
@@ -1,17 +1,15 @@
-"""HTTP-приёмник входящих алертов."""
-
import hmac
import json
from aiohttp import web
-from healthbot.alerts import format_alert
+from healthbot.alerts import alert_keyboard, alert_kind, format_alert
from healthbot.notify import notify
+from healthbot.ui import esc
+from utils.db import db
from utils.env import env
from utils.logging import logger
-MUTED = frozenset({"ScheduleRun"})
-
def _authorized(request: web.Request) -> bool:
expected = env.web.token.get_secret_value()
@@ -23,32 +21,34 @@ def _authorized(request: web.Request) -> bool:
async def handle_alert(request: web.Request) -> web.Response:
if not _authorized(request):
- logger.warning(f"Отклонён алерт без токена от {request.remote}")
+ logger.warning(f"Alert without a valid token from {request.remote}")
return web.json_response({"error": "unauthorized"}, status=401)
raw = await request.text()
try:
payload = json.loads(raw)
except ValueError:
- logger.warning(f"Алерт не разобрался как JSON: {raw[:200]}")
+ logger.warning(f"Alert is not JSON: {raw[:200]}")
return web.json_response({"error": "bad json"}, status=400)
if not isinstance(payload, dict):
return web.json_response({"error": "expected object"}, status=400)
- try:
- text = format_alert(payload)
- except Exception:
- logger.exception("Не смог отформатировать алерт")
- text = f"🔔 Алерт из Komodo (не разобрал формат)\n{raw[:1000]}"
+ kind = alert_kind(payload)
+ logger.info(f"Alert received: {kind} (level={payload.get('level')})")
- kind = (payload.get("data") or {}).get("type", "?")
- logger.info(f"Алерт принят: {kind} (level={payload.get('level')})")
-
- if kind in MUTED:
+ if kind in db.settings.get("muted"):
return web.json_response({"ok": True, "muted": True})
- await notify(text)
+ try:
+ text = format_alert(payload)
+ markup = alert_keyboard(payload)
+ except Exception:
+ logger.exception("Alert formatting failed")
+ text = f"🔔 Алерт из Komodo (не разобрал формат)\n{esc(raw[:1000])}"
+ markup = None
+
+ await notify(text, markup)
return web.json_response({"ok": True})
@@ -65,5 +65,5 @@ async def run_web() -> web.AppRunner:
await runner.setup()
site = web.TCPSite(runner, env.web.host, env.web.port)
await site.start()
- logger.info(f"Web: слушаю {env.web.host}:{env.web.port}")
+ logger.info(f"Web: listening on {env.web.host}:{env.web.port}")
return runner
diff --git a/src/utils/db/__init__.py b/src/utils/db/__init__.py
index 9342ffe..51430ee 100644
--- a/src/utils/db/__init__.py
+++ b/src/utils/db/__init__.py
@@ -1,10 +1,20 @@
+import shutil
from pathlib import Path
import aiosqlite
from utils.env import env
+from utils.logging import logger
-from .repositories import IncidentRepository, ServiceRepository
+from .repositories import (
+ IncidentRepository,
+ PinRepository,
+ ServiceRepository,
+ SettingRepository,
+ StatRepository,
+)
+
+SCHEMA_VERSION = 2
SCHEMA = """
CREATE TABLE IF NOT EXISTS services (
@@ -30,6 +40,33 @@ CREATE TABLE IF NOT EXISTS incidents (
);
CREATE INDEX IF NOT EXISTS idx_incidents_service ON incidents(service_id);
+
+CREATE TABLE IF NOT EXISTS settings (
+ key TEXT PRIMARY KEY,
+ value TEXT NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS pins (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ kind TEXT NOT NULL,
+ target_id TEXT NOT NULL,
+ name TEXT NOT NULL,
+ label TEXT NOT NULL,
+ mode TEXT NOT NULL,
+ args TEXT NOT NULL DEFAULT '{}',
+ position INTEGER NOT NULL DEFAULT 0
+);
+
+CREATE TABLE IF NOT EXISTS hourly (
+ service_id INTEGER NOT NULL REFERENCES services(id) ON DELETE CASCADE,
+ hour INTEGER NOT NULL,
+ checks INTEGER NOT NULL DEFAULT 0,
+ fails INTEGER NOT NULL DEFAULT 0,
+ latency_sum INTEGER NOT NULL DEFAULT 0,
+ latency_n INTEGER NOT NULL DEFAULT 0,
+ latency_max INTEGER NOT NULL DEFAULT 0,
+ PRIMARY KEY (service_id, hour)
+);
"""
MIGRATIONS: tuple[tuple[str, str, str], ...] = (
@@ -37,26 +74,66 @@ MIGRATIONS: tuple[tuple[str, str, str], ...] = (
("services", "is_paused", "INTEGER NOT NULL DEFAULT 0"),
("services", "last_latency_ms", "INTEGER"),
("services", "last_checked_at", "TEXT"),
+ ("services", "needle", "TEXT"),
+ ("services", "tls_expires_at", "TEXT"),
+ ("services", "tls_warned", "INTEGER NOT NULL DEFAULT 0"),
+ ("incidents", "muted", "INTEGER NOT NULL DEFAULT 0"),
)
+def _prepare(path: str) -> None:
+ Path(path).parent.mkdir(parents=True, exist_ok=True)
+
+
+def _has_data(path: str) -> bool:
+ source = Path(path)
+ return source.is_file() and source.stat().st_size > 0
+
+
+def _copy(path: str, version: int) -> Path:
+ source = Path(path)
+ target = source.with_name(f"{source.name}.v{version}.bak")
+ shutil.copy2(source, target)
+ return target
+
+
class Database:
def __init__(self, path: str) -> None:
self._path = path
self._conn: aiosqlite.Connection | None = None
self.services: ServiceRepository = None # type: ignore[assignment]
self.incidents: IncidentRepository = None # type: ignore[assignment]
+ self.pins: PinRepository = None # type: ignore[assignment]
+ self.settings: SettingRepository = None # type: ignore[assignment]
+ self.stats: StatRepository = None # type: ignore[assignment]
async def connect(self) -> None:
- Path(self._path).parent.mkdir(parents=True, exist_ok=True)
+ _prepare(self._path)
+ await self._backup_before_migration()
self._conn = await aiosqlite.connect(self._path)
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.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
await self._conn.commit()
self.services = ServiceRepository(self._conn)
self.incidents = IncidentRepository(self._conn)
+ self.pins = PinRepository(self._conn)
+ self.settings = SettingRepository(self._conn)
+ self.stats = StatRepository(self._conn)
+ await self.settings.load()
+
+ async def _backup_before_migration(self) -> None:
+ if not _has_data(self._path):
+ return
+ async with aiosqlite.connect(self._path) as conn:
+ cursor = await conn.execute("PRAGMA user_version")
+ row = await cursor.fetchone()
+ version = row[0] if row else 0
+ if version < SCHEMA_VERSION:
+ target = _copy(self._path, version)
+ logger.info(f"Database copied to {target} before schema {SCHEMA_VERSION}")
async def _migrate(self) -> None:
assert self._conn is not None
diff --git a/src/utils/db/models.py b/src/utils/db/models.py
index 782f630..5ab7a5c 100644
--- a/src/utils/db/models.py
+++ b/src/utils/db/models.py
@@ -1,11 +1,18 @@
-from dataclasses import dataclass
+from dataclasses import dataclass, field
from enum import StrEnum
+from html import escape
class MatchType(StrEnum):
STATUS = "status"
BODY = "body"
JSON = "json"
+ CONTAINS = "contains"
+
+
+class PinMode(StrEnum):
+ INSTANT = "instant"
+ FORM = "form"
@dataclass(slots=True)
@@ -24,14 +31,23 @@ class Service:
is_paused: bool = False
last_latency_ms: int | None = None
last_checked_at: str | None = None
+ needle: str | None = None
+ tls_expires_at: str | None = None
+ tls_warned: int = 0
@property
def rule(self) -> str:
if self.match_type is MatchType.STATUS:
- return f"HTTP-статус = {self.ok_status}"
+ return f"HTTP-статус {self.ok_status}"
if self.match_type is MatchType.JSON:
- return f"{self.json_path} = {self.json_value}"
- return "тело ответа неизменно"
+ path = escape(self.json_path or "", quote=False)
+ value = escape(self.json_value or "", quote=False)
+ return f"{path} = {value}"
+ if self.match_type is MatchType.CONTAINS:
+ return (
+ f"в ответе есть {escape(self.needle or '', quote=False)}"
+ )
+ return "тело ответа не меняется"
@property
def mark(self) -> str:
@@ -48,3 +64,30 @@ class Incident:
ended_at: str | None
reason: str
last_reminder_at: str
+ muted: bool = False
+
+
+@dataclass(slots=True)
+class Pin:
+ id: int
+ kind: str
+ target_id: str
+ name: str
+ label: str
+ mode: PinMode
+ args: dict = field(default_factory=dict)
+ position: int = 0
+
+
+@dataclass(slots=True)
+class Hour:
+ hour: int
+ checks: int
+ fails: int
+ latency_sum: int
+ latency_n: int
+ latency_max: int
+
+ @property
+ def latency(self) -> int | None:
+ return self.latency_sum // self.latency_n if self.latency_n else None
diff --git a/src/utils/db/repositories/__init__.py b/src/utils/db/repositories/__init__.py
index 69e1312..2458f3d 100644
--- a/src/utils/db/repositories/__init__.py
+++ b/src/utils/db/repositories/__init__.py
@@ -1,4 +1,13 @@
from .incident import IncidentRepository
+from .pin import PinRepository
from .service import ServiceRepository
+from .setting import SettingRepository
+from .stat import StatRepository
-__all__ = ["IncidentRepository", "ServiceRepository"]
+__all__ = [
+ "IncidentRepository",
+ "PinRepository",
+ "ServiceRepository",
+ "SettingRepository",
+ "StatRepository",
+]
diff --git a/src/utils/db/repositories/incident.py b/src/utils/db/repositories/incident.py
index a394db3..f5de842 100644
--- a/src/utils/db/repositories/incident.py
+++ b/src/utils/db/repositories/incident.py
@@ -3,7 +3,7 @@ import aiosqlite
from utils.db.models import Incident
from utils.format import now
-_COLUMNS = "id, service_id, started_at, ended_at, reason, last_reminder_at"
+_COLUMNS = "id, service_id, started_at, ended_at, reason, last_reminder_at, muted"
def _row_to_incident(row: aiosqlite.Row) -> Incident:
@@ -14,6 +14,7 @@ def _row_to_incident(row: aiosqlite.Row) -> Incident:
ended_at=row["ended_at"],
reason=row["reason"],
last_reminder_at=row["last_reminder_at"],
+ muted=bool(row["muted"]),
)
@@ -29,7 +30,7 @@ class IncidentRepository:
(service_id, stamp, reason, stamp),
)
await self._conn.commit()
- incident = await self.get(cursor.lastrowid) # type: ignore[arg-type]
+ incident = await self.get(cursor.lastrowid or 0)
assert incident is not None
return incident
@@ -65,6 +66,12 @@ class IncidentRepository:
)
await self._conn.commit()
+ async def mute(self, incident_id: int) -> None:
+ await self._conn.execute(
+ "UPDATE incidents SET muted = 1 WHERE id = ?", (incident_id,)
+ )
+ await self._conn.commit()
+
async def for_service(self, service_id: int) -> list[Incident]:
cursor = await self._conn.execute(
f"SELECT {_COLUMNS} FROM incidents " # noqa: S608
@@ -72,3 +79,11 @@ class IncidentRepository:
(service_id,),
)
return [_row_to_incident(row) for row in await cursor.fetchall()]
+
+ async def since(self, stamp: str) -> list[Incident]:
+ cursor = await self._conn.execute(
+ f"SELECT {_COLUMNS} FROM incidents " # noqa: S608
+ "WHERE ended_at IS NULL OR ended_at >= ? ORDER BY id",
+ (stamp,),
+ )
+ return [_row_to_incident(row) for row in await cursor.fetchall()]
diff --git a/src/utils/db/repositories/pin.py b/src/utils/db/repositories/pin.py
new file mode 100644
index 0000000..08da3bf
--- /dev/null
+++ b/src/utils/db/repositories/pin.py
@@ -0,0 +1,98 @@
+import json
+
+import aiosqlite
+
+from utils.db.models import Pin, PinMode
+
+_COLUMNS = "id, kind, target_id, name, label, mode, args, position"
+
+
+def _row_to_pin(row: aiosqlite.Row) -> Pin:
+ return Pin(
+ id=row["id"],
+ kind=row["kind"],
+ target_id=row["target_id"],
+ name=row["name"],
+ label=row["label"],
+ mode=PinMode(row["mode"]),
+ args=json.loads(row["args"] or "{}"),
+ position=row["position"],
+ )
+
+
+class PinRepository:
+ def __init__(self, conn: aiosqlite.Connection) -> None:
+ self._conn = conn
+
+ async def all(self) -> list[Pin]:
+ cursor = await self._conn.execute(
+ f"SELECT {_COLUMNS} FROM pins ORDER BY position, id" # noqa: S608
+ )
+ return [_row_to_pin(row) for row in await cursor.fetchall()]
+
+ async def get(self, pin_id: int) -> Pin | None:
+ cursor = await self._conn.execute(
+ f"SELECT {_COLUMNS} FROM pins WHERE id = ?", # noqa: S608
+ (pin_id,),
+ )
+ row = await cursor.fetchone()
+ return _row_to_pin(row) if row else None
+
+ async def add( # noqa: PLR0913
+ self,
+ *,
+ kind: str,
+ target_id: str,
+ name: str,
+ label: str,
+ mode: PinMode,
+ args: dict,
+ ) -> Pin:
+ cursor = await self._conn.execute(
+ "INSERT INTO pins (kind, target_id, name, label, mode, args, position) "
+ "VALUES (?, ?, ?, ?, ?, ?, "
+ "(SELECT COALESCE(MAX(position), 0) + 1 FROM pins))",
+ (
+ kind,
+ target_id,
+ name,
+ label,
+ mode.value,
+ json.dumps(args, ensure_ascii=False),
+ ),
+ )
+ await self._conn.commit()
+ pin = await self.get(cursor.lastrowid or 0)
+ assert pin is not None
+ return pin
+
+ async def rename(self, pin_id: int, label: str) -> None:
+ await self._conn.execute(
+ "UPDATE pins SET label = ? WHERE id = ?", (label, pin_id)
+ )
+ await self._conn.commit()
+
+ async def set_mode(self, pin_id: int, mode: PinMode) -> None:
+ await self._conn.execute(
+ "UPDATE pins SET mode = ? WHERE id = ?", (mode.value, pin_id)
+ )
+ await self._conn.commit()
+
+ async def move(self, pin_id: int, step: int) -> None:
+ pins = await self.all()
+ index = next((i for i, p in enumerate(pins) if p.id == pin_id), None)
+ if index is None:
+ return
+ target = index + step
+ if not 0 <= target < len(pins):
+ return
+ pins[index], pins[target] = pins[target], pins[index]
+ for position, pin in enumerate(pins, start=1):
+ await self._conn.execute(
+ "UPDATE pins SET position = ? WHERE id = ?", (position, pin.id)
+ )
+ await self._conn.commit()
+
+ async def delete(self, pin_id: int) -> None:
+ await self._conn.execute("DELETE FROM pins WHERE id = ?", (pin_id,))
+ await self._conn.commit()
diff --git a/src/utils/db/repositories/service.py b/src/utils/db/repositories/service.py
index 80db74a..b6f341c 100644
--- a/src/utils/db/repositories/service.py
+++ b/src/utils/db/repositories/service.py
@@ -6,7 +6,8 @@ from utils.format import now
_COLUMNS = (
"id, name, url, match_type, ok_status, ok_body, "
"json_path, json_value, is_up, created_at, "
- "fail_streak, is_paused, last_latency_ms, last_checked_at"
+ "fail_streak, is_paused, last_latency_ms, last_checked_at, "
+ "needle, tls_expires_at, tls_warned"
)
@@ -26,6 +27,9 @@ def _row_to_service(row: aiosqlite.Row) -> Service:
is_paused=bool(row["is_paused"]),
last_latency_ms=row["last_latency_ms"],
last_checked_at=row["last_checked_at"],
+ needle=row["needle"],
+ tls_expires_at=row["tls_expires_at"],
+ tls_warned=row["tls_warned"],
)
@@ -65,11 +69,12 @@ class ServiceRepository:
ok_body: str = "",
json_path: str | None = None,
json_value: str | None = None,
+ needle: str | None = None,
) -> Service:
cursor = await self._conn.execute(
"INSERT INTO services "
"(name, url, match_type, ok_status, ok_body, json_path, json_value, "
- "is_up, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?)",
+ "needle, is_up, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?)",
(
name,
url,
@@ -78,14 +83,49 @@ class ServiceRepository:
ok_body,
json_path,
json_value,
+ needle,
now().isoformat(),
),
)
await self._conn.commit()
- service = await self.get(cursor.lastrowid) # type: ignore[arg-type]
+ service = await self.get(cursor.lastrowid or 0)
assert service is not None
return service
+ async def set_rule( # noqa: PLR0913
+ self,
+ service_id: int,
+ *,
+ match_type: MatchType,
+ ok_status: int,
+ ok_body: str = "",
+ json_path: str | None = None,
+ json_value: str | None = None,
+ needle: str | None = None,
+ ) -> None:
+ await self._conn.execute(
+ "UPDATE services SET match_type = ?, ok_status = ?, ok_body = ?, "
+ "json_path = ?, json_value = ?, needle = ?, fail_streak = 0 WHERE id = ?",
+ (
+ match_type.value,
+ ok_status,
+ ok_body,
+ json_path,
+ json_value,
+ needle,
+ service_id,
+ ),
+ )
+ await self._conn.commit()
+
+ async def set_url(self, service_id: int, url: str) -> None:
+ await self._conn.execute(
+ "UPDATE services SET url = ?, fail_streak = 0, tls_expires_at = NULL, "
+ "tls_warned = 0 WHERE id = ?",
+ (url, service_id),
+ )
+ await self._conn.commit()
+
async def set_up(self, service_id: int, *, is_up: bool) -> None:
await self._conn.execute(
"UPDATE services SET is_up = ? WHERE id = ?", (int(is_up), service_id)
@@ -112,6 +152,13 @@ class ServiceRepository:
)
await self._conn.commit()
+ async def set_tls(self, service_id: int, expires_at: str, warned: int) -> None:
+ await self._conn.execute(
+ "UPDATE services SET tls_expires_at = ?, tls_warned = ? WHERE id = ?",
+ (expires_at, warned, 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)
@@ -121,7 +168,6 @@ class ServiceRepository:
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 = ?",
diff --git a/src/utils/db/repositories/setting.py b/src/utils/db/repositories/setting.py
new file mode 100644
index 0000000..a68fafa
--- /dev/null
+++ b/src/utils/db/repositories/setting.py
@@ -0,0 +1,38 @@
+import json
+from typing import Any
+
+import aiosqlite
+
+DEFAULTS: dict[str, Any] = {
+ "actions": True,
+ "commands": False,
+ "confirm": True,
+ "muted": ["ScheduleRun"],
+ "tz": "UTC",
+ "digest": None,
+ "digest_sent": "",
+}
+
+
+class SettingRepository:
+ def __init__(self, conn: aiosqlite.Connection) -> None:
+ self._conn = conn
+ self._cache: dict[str, Any] = {}
+
+ async def load(self) -> None:
+ cursor = await self._conn.execute("SELECT key, value FROM settings")
+ self._cache = {
+ row["key"]: json.loads(row["value"]) for row in await cursor.fetchall()
+ }
+
+ def get(self, key: str) -> Any: # noqa: ANN401
+ return self._cache.get(key, DEFAULTS[key])
+
+ async def set(self, key: str, value: Any) -> None: # noqa: ANN401
+ self._cache[key] = value
+ await self._conn.execute(
+ "INSERT INTO settings (key, value) VALUES (?, ?) "
+ "ON CONFLICT(key) DO UPDATE SET value = excluded.value",
+ (key, json.dumps(value, ensure_ascii=False)),
+ )
+ await self._conn.commit()
diff --git a/src/utils/db/repositories/stat.py b/src/utils/db/repositories/stat.py
new file mode 100644
index 0000000..a80fe29
--- /dev/null
+++ b/src/utils/db/repositories/stat.py
@@ -0,0 +1,50 @@
+import aiosqlite
+
+from utils.db.models import Hour
+
+SECONDS_IN_HOUR = 3600
+
+
+class StatRepository:
+ def __init__(self, conn: aiosqlite.Connection) -> None:
+ self._conn = conn
+
+ async def record(
+ self, service_id: int, hour: int, *, healthy: bool, latency_ms: int | None
+ ) -> None:
+ measured = latency_ms is not None
+ latency = latency_ms or 0
+ await self._conn.execute(
+ "INSERT INTO hourly "
+ "(service_id, hour, checks, fails, latency_sum, latency_n, latency_max) "
+ "VALUES (?, ?, 1, ?, ?, ?, ?) "
+ "ON CONFLICT(service_id, hour) DO UPDATE SET "
+ "checks = checks + 1, fails = fails + excluded.fails, "
+ "latency_sum = latency_sum + excluded.latency_sum, "
+ "latency_n = latency_n + excluded.latency_n, "
+ "latency_max = MAX(latency_max, excluded.latency_max)",
+ (service_id, hour, int(not healthy), latency, int(measured), latency),
+ )
+ await self._conn.commit()
+
+ async def since(self, service_id: int, hour: int) -> dict[int, Hour]:
+ cursor = await self._conn.execute(
+ "SELECT hour, checks, fails, latency_sum, latency_n, latency_max "
+ "FROM hourly WHERE service_id = ? AND hour >= ? ORDER BY hour",
+ (service_id, hour),
+ )
+ return {
+ row["hour"]: Hour(
+ hour=row["hour"],
+ checks=row["checks"],
+ fails=row["fails"],
+ latency_sum=row["latency_sum"],
+ latency_n=row["latency_n"],
+ latency_max=row["latency_max"],
+ )
+ for row in await cursor.fetchall()
+ }
+
+ async def purge(self, before_hour: int) -> None:
+ await self._conn.execute("DELETE FROM hourly WHERE hour < ?", (before_hour,))
+ await self._conn.commit()
diff --git a/src/utils/format.py b/src/utils/format.py
index facdf31..d134c16 100644
--- a/src/utils/format.py
+++ b/src/utils/format.py
@@ -1,4 +1,5 @@
-from datetime import UTC, datetime
+from datetime import UTC, datetime, tzinfo
+from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
def now() -> datetime:
@@ -9,7 +10,23 @@ def parse(ts: str) -> datetime:
return datetime.fromisoformat(ts)
+def from_ms(ms: float) -> datetime:
+ return datetime.fromtimestamp(ms / 1000, UTC)
+
+
+def zone(name: str) -> tzinfo:
+ try:
+ return ZoneInfo(name)
+ except (ZoneInfoNotFoundError, ValueError):
+ return UTC
+
+
+def stamp(moment: datetime, tz_name: str) -> str:
+ return moment.astimezone(zone(tz_name)).strftime("%d.%m %H:%M")
+
+
MINUTE = 60
+HOUR = 3600
MAX_PARTS = 2
@@ -27,6 +44,23 @@ def human_duration(seconds: float) -> str:
return " ".join(parts[:MAX_PARTS])
+def ago(moment: datetime) -> str:
+ return f"{human_duration(max(0.0, (now() - moment).total_seconds()))} назад"
+
+
+def until(moment: datetime) -> str:
+ return f"через {human_duration(max(0.0, (moment - now()).total_seconds()))}"
+
+
+def clock(seconds: float) -> str:
+ if seconds < MINUTE:
+ return f"{seconds:.1f}с" if seconds < 10 else f"{int(seconds)}с" # noqa: PLR2004
+ total = int(seconds)
+ if total < HOUR:
+ return f"{total // MINUTE}:{total % MINUTE:02d}"
+ return f"{total // HOUR}:{total % HOUR // MINUTE:02d}:{total % MINUTE:02d}"
+
+
def short(value: object, limit: int = 60) -> str:
text = str(value).replace("\n", " ").strip()
return text if len(text) <= limit else text[: limit - 1] + "…"
diff --git a/src/utils/logging.py b/src/utils/logging.py
index 79271b6..6d0b282 100644
--- a/src/utils/logging.py
+++ b/src/utils/logging.py
@@ -22,14 +22,14 @@ def setup_logging() -> None:
markup=True,
rich_tracebacks=True,
enable_link_path=False,
- tracebacks_show_locals=True,
+ tracebacks_show_locals=False,
omit_repeated_times=False,
show_time=env.log.show_time,
tracebacks_suppress=[router],
)
],
)
- install(console=console, show_locals=True)
+ install(console=console, show_locals=False)
logger = logging.getLogger("healthbot")
diff --git a/uv.lock b/uv.lock
index 46ba644..efe4114 100644
--- a/uv.lock
+++ b/uv.lock
@@ -248,7 +248,9 @@ dependencies = [
{ name = "aiohttp" },
{ name = "aiosqlite" },
{ name = "pydantic-settings" },
+ { name = "pyyaml" },
{ name = "rich" },
+ { name = "tzdata" },
]
[package.dev-dependencies]
@@ -263,7 +265,9 @@ requires-dist = [
{ name = "aiohttp", specifier = ">=3.10.0" },
{ name = "aiosqlite", specifier = ">=0.20.0" },
{ name = "pydantic-settings", specifier = ">=2.14.2" },
+ { name = "pyyaml", specifier = ">=6.0.3" },
{ name = "rich", specifier = ">=15.0.0" },
+ { name = "tzdata", specifier = ">=2026.5" },
]
[package.metadata.requires-dev]
@@ -572,6 +576,42 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
]
+[[package]]
+name = "pyyaml"
+version = "6.0.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
+ { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
+ { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
+ { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
+ { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
+ { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
+ { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
+ { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
+ { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
+ { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
+ { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
+ { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
+]
+
[[package]]
name = "rich"
version = "15.0.0"
@@ -656,6 +696,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
]
+[[package]]
+name = "tzdata"
+version = "2026.5"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d9/68/f1b440335057bfce71b6e50a9d09445aa2ecbd08359a337976627b8409e7/tzdata-2026.5.tar.gz", hash = "sha256:8cc73c0a0bfca7dbfa59235d60b2eff82231dee33f53d206db1acd9173cfc0a7", size = 200404, upload-time = "2026-10-03T09:23:14.143Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/94/21/1e5995a1c920cce14e4bffae20c665ec10e7ed03ab25e006cd741092b718/tzdata-2026.5-py2.py3-none-any.whl", hash = "sha256:b683bd1b6659ddcd810ff02ad09ba821d4bf1065072805063eb35c49617905ac", size = 347996, upload-time = "2026-10-03T09:23:12.535Z" },
+]
+
[[package]]
name = "yarl"
version = "1.24.2"