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 'админ' }}
+
+{% if error %}
+{{ error }}
+{% endif %}
+
+
+
+
+
+ | Время |
+ Событие |
+ Пользователь |
+ Чат |
+ Версия |
+ Payload |
+
+
+
+ {% for event in events %}
+
+ | {{ 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 %}
+ |
+
+ {% else %}
+
+ |
+ {% if error %}Данные не загрузились.{% else %}За выбранный период событий нет.{% endif %}
+ |
+
+ {% endfor %}
+
+
+
+
+{% 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"