diff --git a/README.md b/README.md index c854a87..130b701 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,11 @@ HttpOnly-cookie (`ADMIN_SESSION_SECRET`), все формы защищены CSR журнал `money` с действием «Корректировка админом»). Каждое ручное изменение пишется в `bot.user_changes`: кто, когда, было → стало и причина. +Раздел «Логи» — события из ClickHouse: фильтры по типу, пользователю (ID или username) +и датам, пагинация по 50. Время показывается в `TIMEZONE`, payload раскрывается как JSON, +ID пользователя ведёт в его карточку. ClickHouse читается отдельным ленивым клиентом; +если он недоступен, страница показывает ошибку, а не падает. + ## Аналитика (ClickHouse) Все значимые действия бота пишутся в ClickHouse в единую таблицу `analytics.events`. @@ -140,7 +145,7 @@ make backup E=test # на домашнем сервере: ./scripts/clickho ## Версионирование -- Единый источник версии — `common/version.py` (`__version__`), текущая — `0.4.7`. +- Единый источник версии — `common/version.py` (`__version__`), текущая — `0.4.8`. - Схема — SemVer-подобная (`MAJOR.MINOR.PATCH`); git-тег = `v` + версия (`v0.1`). - Docker-образы тегируются версией (Блок 2), события ClickHouse получают `bot_version` (Блок 3). - Выпуск: изменить `common/version.py` → коммит → аннотированный тег `vX.Y` → `git push --tags`. diff --git a/admin/app.py b/admin/app.py index efe5cf3..d8c514e 100644 --- a/admin/app.py +++ b/admin/app.py @@ -13,8 +13,9 @@ from fastapi.templating import Jinja2Templates from starlette.middleware.sessions import SessionMiddleware from admin.middleware import AdminAuthMiddleware -from admin.routes import auth, health, messages, pages, schedules, users +from admin.routes import auth, health, logs, messages, pages, schedules, users from common import __version__ +from common.analytics import read as analytics_read from common.config import AdminSettings, get_admin_settings from common.db.pool import close_pool, init_pool from common.logging_config import setup_logging @@ -53,6 +54,7 @@ def create_app(settings: AdminSettings | None = None, *, init_db: bool = True) - finally: if init_db: await close_pool() + await analytics_read.close_client() app = FastAPI( title="DispelEvilBot Admin", @@ -88,4 +90,5 @@ def create_app(settings: AdminSettings | None = None, *, init_db: bool = True) - app.include_router(messages.router) app.include_router(schedules.router) app.include_router(users.router) + app.include_router(logs.router) return app diff --git a/admin/routes/logs.py b/admin/routes/logs.py new file mode 100644 index 0000000..2fe0a25 --- /dev/null +++ b/admin/routes/logs.py @@ -0,0 +1,118 @@ +"""Раздел «Логи»: события из ClickHouse с фильтрами и пагинацией.""" + +from __future__ import annotations + +import json +import logging +from datetime import UTC, datetime, timedelta +from typing import Any +from urllib.parse import urlencode + +from fastapi import APIRouter, Request +from fastapi.responses import HTMLResponse + +from common import __version__ +from common.analytics import ALL_EVENT_TYPES +from common.analytics import read as analytics_read + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/logs", tags=["logs"]) + +#: Сколько событий показывать на странице. +PAGE_SIZE = 50 + + +def _pretty_payload(payload: str) -> str: + """Отформатировать JSON-payload для просмотра; битый JSON — как есть.""" + try: + return json.dumps(json.loads(payload), ensure_ascii=False, indent=2) + except (TypeError, ValueError): + return payload or "" + + +def _decorate(row: dict[str, Any], settings: Any) -> dict[str, Any]: + """Добавить локальное время и читаемый payload.""" + moment = row.get("event_time") + if isinstance(moment, datetime): + if moment.tzinfo is None: + moment = moment.replace(tzinfo=UTC) + time_local = moment.astimezone(settings.tzinfo).strftime("%d.%m.%Y %H:%M:%S") + else: + time_local = "—" + + return { + **row, + "time_local": time_local, + "payload_pretty": _pretty_payload(str(row.get("payload") or "")), + } + + +@router.get("", response_class=HTMLResponse) +async def logs_page( + request: Request, + event_type: str = "", + user: str = "", + date_from: str = "", + date_to: str = "", + page: int = 1, +) -> HTMLResponse: + """Показать события с фильтрами по типу, пользователю и датам.""" + settings = request.app.state.settings + today = datetime.now(settings.tzinfo).date() + date_from = date_from or (today - timedelta(days=6)).isoformat() + date_to = date_to or today.isoformat() + page = max(page, 1) + + events: list[dict[str, Any]] = [] + total = 0 + error: str | None = None + + try: + events, total = await analytics_read.fetch_events( + settings, + event_type=event_type, + user=user, + date_from=date_from, + date_to=date_to, + limit=PAGE_SIZE, + offset=(page - 1) * PAGE_SIZE, + ) + except ValueError: + error = "Даты должны быть в формате ГГГГ-ММ-ДД." + except Exception: + logger.exception("Не удалось прочитать события из ClickHouse") + error = "ClickHouse недоступен — логи временно не читаются." + + pages = max((total + PAGE_SIZE - 1) // PAGE_SIZE, 1) + query_base = urlencode( + { + key: value + for key, value in { + "event_type": event_type, + "user": user, + "date_from": date_from, + "date_to": date_to, + }.items() + if value + } + ) + + return request.app.state.templates.TemplateResponse( + request=request, + name="logs/list.html", + context={ + "version": __version__, + "events": [_decorate(row, settings) for row in events], + "event_types": sorted(ALL_EVENT_TYPES), + "event_type": event_type, + "user": user, + "date_from": date_from, + "date_to": date_to, + "page": page, + "pages": pages, + "total": total, + "error": error, + "query_base": query_base, + }, + ) diff --git a/admin/routes/pages.py b/admin/routes/pages.py index 162ca48..764e4f6 100644 --- a/admin/routes/pages.py +++ b/admin/routes/pages.py @@ -29,7 +29,7 @@ SECTIONS: tuple[dict[str, str | None], ...] = ( { "title": "Логи", "description": "События из ClickHouse с фильтрами", - "url": None, + "url": "/logs", }, ) diff --git a/admin/templates/base.html b/admin/templates/base.html index 7430314..e00e35a 100644 --- a/admin/templates/base.html +++ b/admin/templates/base.html @@ -16,6 +16,7 @@ Сообщения Расписания Пользователи + Логи {% if request.session.get('admin_id') %} {{ request.session.get('admin_username') or 'админ' }}
diff --git a/admin/templates/logs/list.html b/admin/templates/logs/list.html new file mode 100644 index 0000000..7928865 --- /dev/null +++ b/admin/templates/logs/list.html @@ -0,0 +1,133 @@ +{% extends "base.html" %} + +{% block title %}Логи{% endblock %} + +{% block content %} +
+
+

Логи

+

События из ClickHouse · найдено: {{ total }}

+
+
+ + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + Сбросить +
+ +{% if error %} +
{{ error }}
+{% endif %} + +
+ + + + + + + + + + + + + {% for event in events %} + + + + + + + + + {% else %} + + + + {% endfor %} + +
ВремяСобытиеПользовательЧатВерсияPayload
{{ event.time_local }} + {{ event.event_type }} + {% if event.prod %}prod{% endif %} + + {% if event.user_id %} + {% if event.username %}
@{{ event.username }}
{% endif %} + {{ event.user_id }} + {% else %} + — + {% endif %} +
{{ event.chat_id or "—" }}{{ event.bot_version }} + {% if event.payload_pretty %} +
+ JSON +
{{ event.payload_pretty }}
+
+ {% else %} + — + {% endif %} +
+ {% if error %}Данные не загрузились.{% else %}За выбранный период событий нет.{% endif %} +
+
+ +{% if pages > 1 %} +
+
Страница {{ page }} из {{ pages }}
+
+ {% if page > 1 %} + ← Назад + {% endif %} + {% if page < pages %} + Вперёд → + {% endif %} +
+
+{% endif %} +{% endblock %} diff --git a/common/analytics/read.py b/common/analytics/read.py new file mode 100644 index 0000000..aff0d04 --- /dev/null +++ b/common/analytics/read.py @@ -0,0 +1,125 @@ +"""Чтение событий ClickHouse для админки (Блок 4.3). + +Клиент создаётся лениво и только на чтение; недоступность ClickHouse не роняет +админку — страница покажет ошибку. Запись событий живёт в ``client.py``. +""" + +from __future__ import annotations + +import logging +from datetime import UTC, datetime, time, timedelta +from typing import Any + +from common.analytics.events import ALL_EVENT_TYPES +from common.config import Settings + +logger = logging.getLogger(__name__) + +#: Колонки, которые показываем в логах. +EVENT_FIELDS = ( + "event_time", + "event_type", + "user_id", + "username", + "chat_id", + "payload", + "bot_version", + "prod", +) + +_client: Any | None = None + + +async def _ensure_client(settings: Settings) -> Any: + """Создать (один раз) асинхронный клиент ClickHouse.""" + global _client + if _client is not None: + return _client + + import clickhouse_connect + + _client = await clickhouse_connect.get_async_client( + host=settings.clickhouse_host, + port=settings.clickhouse_port, + username=settings.clickhouse_user, + password=settings.clickhouse_password, + database=settings.clickhouse_db, + ) + return _client + + +async def close_client() -> None: + """Закрыть клиент при остановке админки.""" + global _client + if _client is not None: + client, _client = _client, None + try: + await client.close() + except Exception: + logger.warning("Не удалось закрыть клиент ClickHouse", exc_info=True) + + +def _where( + *, + event_type: str, + user: str, + start: datetime, + end: datetime, +) -> tuple[str, dict[str, Any]]: + clauses = ["event_time >= {start:DateTime}", "event_time < {end:DateTime}"] + params: dict[str, Any] = {"start": start, "end": end} + + if event_type and event_type in ALL_EVENT_TYPES: + clauses.append("event_type = {event_type:String}") + params["event_type"] = event_type + + user = user.strip() + if user: + if user.isdigit(): + clauses.append("user_id = {user_id:Int64}") + params["user_id"] = int(user) + else: + clauses.append("positionCaseInsensitive(username, {user_text:String}) > 0") + params["user_text"] = user + + return " WHERE " + " AND ".join(clauses), params + + +async def fetch_events( + settings: Settings, + *, + event_type: str = "", + user: str = "", + date_from: str = "", + date_to: str = "", + limit: int = 50, + offset: int = 0, +) -> tuple[list[dict[str, Any]], int]: + """Вернуть события и общее количество по фильтрам. + + Даты — ``YYYY-MM-DD`` в таймзоне админки; внутри переводятся в UTC. + """ + today = datetime.now(settings.tzinfo).date() + start_date = datetime.strptime(date_from, "%Y-%m-%d").date() if date_from else today - timedelta(days=6) + end_date = datetime.strptime(date_to, "%Y-%m-%d").date() if date_to else today + + start = datetime.combine(start_date, time.min, tzinfo=settings.tzinfo).astimezone(UTC).replace(tzinfo=None) + end = ( + datetime.combine(end_date + timedelta(days=1), time.min, tzinfo=settings.tzinfo) + .astimezone(UTC) + .replace(tzinfo=None) + ) + + where, params = _where(event_type=event_type, user=user, start=start, end=end) + client = await _ensure_client(settings) + + count_result = await client.query(f"SELECT count() FROM analytics.events{where}", parameters=params) + total = int(count_result.result_rows[0][0]) if count_result.result_rows else 0 + + result = await client.query( + f"SELECT {', '.join(EVENT_FIELDS)} FROM analytics.events{where}" + " ORDER BY event_time DESC LIMIT {limit:UInt32} OFFSET {offset:UInt32}", + parameters={**params, "limit": limit, "offset": offset}, + ) + rows = [dict(zip(result.column_names, row, strict=False)) for row in result.result_rows] + return rows, total diff --git a/common/version.py b/common/version.py index 4dd7a26..d6ff408 100644 --- a/common/version.py +++ b/common/version.py @@ -7,4 +7,4 @@ from __future__ import annotations -__version__ = "0.4.7" +__version__ = "0.4.8" diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index c77af0b..fb656cd 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -72,6 +72,8 @@ services: environment: DB_HOST: postgres DB_PORT: "5432" + CLICKHOUSE_HOST: clickhouse + CLICKHOUSE_PORT: "8123" ADMIN_HOST: 0.0.0.0 ADMIN_PORT: "8000" ports: diff --git a/docker-compose.yml b/docker-compose.yml index 93348d1..d916a1c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -80,6 +80,8 @@ services: environment: DB_HOST: postgres DB_PORT: "5432" + CLICKHOUSE_HOST: clickhouse + CLICKHOUSE_PORT: "8123" ADMIN_HOST: 0.0.0.0 ADMIN_PORT: "8000" ports: diff --git a/tests/test_admin_logs.py b/tests/test_admin_logs.py new file mode 100644 index 0000000..1aca86b --- /dev/null +++ b/tests/test_admin_logs.py @@ -0,0 +1,97 @@ +"""Тесты раздела «Логи» (без ClickHouse — чтение подменяется).""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any + +import pytest + +from common.analytics import read as analytics_read + +EVENT: dict[str, Any] = { + "event_time": datetime(2026, 10, 18, 17, 0, 0), # UTC, без tz — как отдаёт clickhouse-connect + "event_type": "command", + "user_id": 42, + "username": "test_user", + "chat_id": -100123, + "payload": '{"command": "инфо", "args_count": 0}', + "bot_version": "0.4.8", + "prod": 0, +} + + +@pytest.fixture +def fake_read(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]: + state: dict[str, Any] = {"total": 1} + + async def fetch_events(settings: Any, **kwargs: Any) -> tuple[list[dict[str, Any]], int]: + state["kwargs"] = kwargs + return [EVENT], state["total"] + + monkeypatch.setattr(analytics_read, "fetch_events", fetch_events) + return state + + +def test_logs_page(fake_read: dict[str, Any], client_factory) -> None: + response = client_factory().get("/logs") + + assert response.status_code == 200 + assert "command" in response.text + assert "@test_user" in response.text + assert "инфо" in response.text + assert "20:00:00" in response.text # 17:00 UTC -> 20:00 МСК + + +def test_logs_filters_passed(fake_read: dict[str, Any], client_factory) -> None: + client_factory().get( + "/logs", + params={ + "event_type": "command", + "user": "42", + "date_from": "2026-10-01", + "date_to": "2026-10-18", + }, + ) + + assert fake_read["kwargs"] == { + "event_type": "command", + "user": "42", + "date_from": "2026-10-01", + "date_to": "2026-10-18", + "limit": 50, + "offset": 0, + } + + +def test_logs_pagination(fake_read: dict[str, Any], client_factory) -> None: + fake_read["total"] = 120 + + response = client_factory().get("/logs", params={"page": "2"}) + + assert "Страница 2 из 3" in response.text + assert fake_read["kwargs"]["offset"] == 50 + + +def test_logs_clickhouse_error(client_factory, monkeypatch: pytest.MonkeyPatch) -> None: + async def fetch_events(settings: Any, **kwargs: Any) -> tuple[list[dict[str, Any]], int]: + raise RuntimeError("ClickHouse недоступен") + + monkeypatch.setattr(analytics_read, "fetch_events", fetch_events) + + response = client_factory().get("/logs") + + assert response.status_code == 200 + assert "ClickHouse недоступен" in response.text + + +def test_logs_bad_date(client_factory, monkeypatch: pytest.MonkeyPatch) -> None: + async def fetch_events(settings: Any, **kwargs: Any) -> tuple[list[dict[str, Any]], int]: + raise ValueError("bad date") + + monkeypatch.setattr(analytics_read, "fetch_events", fetch_events) + + response = client_factory().get("/logs", params={"date_from": "18.10.2026"}) + + assert response.status_code == 200 + assert "формате ГГГГ-ММ-ДД" in response.text diff --git a/tests/test_version.py b/tests/test_version.py index ccedb57..8c9c74b 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -7,4 +7,4 @@ from common import __version__ def test_version_is_set(): assert __version__ - assert __version__ == "0.4.7" + assert __version__ == "0.4.8"