Логи стали видны из админки, а не только через SQL

Раздел «Логи» читает analytics.events из ClickHouse: фильтры по типу события, пользователю (ID или username) и датам, по 50 строк на страницу, время в часовом поясе админки. Payload раскрывается как JSON, ID ведёт в карточку игрока. Чтение идёт отдельным ленивым клиентом: если ClickHouse лежит, страница сообщает об этом и не роняет админку. Боту для этого ничего не меняли.
This commit is contained in:
MpakobecEx 2026-10-19 04:00:30 +05:00
parent 92791f1ba9
commit 107a79d05e
12 changed files with 491 additions and 5 deletions

View File

@ -79,6 +79,11 @@ HttpOnly-cookie (`ADMIN_SESSION_SECRET`), все формы защищены CSR
журнал `money` с действием «Корректировка админом»). Каждое ручное изменение пишется в журнал `money` с действием «Корректировка админом»). Каждое ручное изменение пишется в
`bot.user_changes`: кто, когда, было → стало и причина. `bot.user_changes`: кто, когда, было → стало и причина.
Раздел «Логи» — события из ClickHouse: фильтры по типу, пользователю (ID или username)
и датам, пагинация по 50. Время показывается в `TIMEZONE`, payload раскрывается как JSON,
ID пользователя ведёт в его карточку. ClickHouse читается отдельным ленивым клиентом;
если он недоступен, страница показывает ошибку, а не падает.
## Аналитика (ClickHouse) ## Аналитика (ClickHouse)
Все значимые действия бота пишутся в ClickHouse в единую таблицу `analytics.events`. Все значимые действия бота пишутся в 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`). - Схема — SemVer-подобная (`MAJOR.MINOR.PATCH`); git-тег = `v` + версия (`v0.1`).
- Docker-образы тегируются версией (Блок 2), события ClickHouse получают `bot_version` (Блок 3). - Docker-образы тегируются версией (Блок 2), события ClickHouse получают `bot_version` (Блок 3).
- Выпуск: изменить `common/version.py` → коммит → аннотированный тег `vX.Y` → `git push --tags`. - Выпуск: изменить `common/version.py` → коммит → аннотированный тег `vX.Y` → `git push --tags`.

View File

@ -13,8 +13,9 @@ from fastapi.templating import Jinja2Templates
from starlette.middleware.sessions import SessionMiddleware from starlette.middleware.sessions import SessionMiddleware
from admin.middleware import AdminAuthMiddleware 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 import __version__
from common.analytics import read as analytics_read
from common.config import AdminSettings, get_admin_settings from common.config import AdminSettings, get_admin_settings
from common.db.pool import close_pool, init_pool from common.db.pool import close_pool, init_pool
from common.logging_config import setup_logging from common.logging_config import setup_logging
@ -53,6 +54,7 @@ def create_app(settings: AdminSettings | None = None, *, init_db: bool = True) -
finally: finally:
if init_db: if init_db:
await close_pool() await close_pool()
await analytics_read.close_client()
app = FastAPI( app = FastAPI(
title="DispelEvilBot Admin", 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(messages.router)
app.include_router(schedules.router) app.include_router(schedules.router)
app.include_router(users.router) app.include_router(users.router)
app.include_router(logs.router)
return app return app

118
admin/routes/logs.py Normal file
View File

@ -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,
},
)

View File

@ -29,7 +29,7 @@ SECTIONS: tuple[dict[str, str | None], ...] = (
{ {
"title": "Логи", "title": "Логи",
"description": "События из ClickHouse с фильтрами", "description": "События из ClickHouse с фильтрами",
"url": None, "url": "/logs",
}, },
) )

View File

@ -16,6 +16,7 @@
<a href="/messages" class="text-slate-600 hover:text-slate-900">Сообщения</a> <a href="/messages" class="text-slate-600 hover:text-slate-900">Сообщения</a>
<a href="/schedules" class="text-slate-600 hover:text-slate-900">Расписания</a> <a href="/schedules" class="text-slate-600 hover:text-slate-900">Расписания</a>
<a href="/users" class="text-slate-600 hover:text-slate-900">Пользователи</a> <a href="/users" class="text-slate-600 hover:text-slate-900">Пользователи</a>
<a href="/logs" class="text-slate-600 hover:text-slate-900">Логи</a>
{% if request.session.get('admin_id') %} {% if request.session.get('admin_id') %}
<span class="text-slate-500">{{ request.session.get('admin_username') or 'админ' }}</span> <span class="text-slate-500">{{ request.session.get('admin_username') or 'админ' }}</span>
<form method="post" action="/logout"> <form method="post" action="/logout">

View File

@ -0,0 +1,133 @@
{% extends "base.html" %}
{% block title %}Логи{% endblock %}
{% block content %}
<div class="flex flex-wrap items-end justify-between gap-4">
<div>
<h1 class="text-2xl font-semibold">Логи</h1>
<p class="mt-1 text-sm text-slate-600">События из ClickHouse · найдено: {{ total }}</p>
</div>
</div>
<form method="get" action="/logs" class="mt-4 flex flex-wrap items-end gap-3 rounded-lg border border-slate-200 bg-white p-4">
<div>
<label for="event_type" class="text-xs text-slate-500">Тип</label>
<select
id="event_type"
name="event_type"
class="mt-1 block rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none"
>
<option value="">Все типы</option>
{% for type in event_types %}
<option value="{{ type }}" {% if type == event_type %}selected{% endif %}>{{ type }}</option>
{% endfor %}
</select>
</div>
<div>
<label for="user" class="text-xs text-slate-500">Пользователь</label>
<input
id="user"
name="user"
type="text"
value="{{ user }}"
placeholder="ID или @username"
class="mt-1 block w-48 rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none"
/>
</div>
<div>
<label for="date_from" class="text-xs text-slate-500">С</label>
<input
id="date_from"
name="date_from"
type="date"
value="{{ date_from }}"
class="mt-1 block rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none"
/>
</div>
<div>
<label for="date_to" class="text-xs text-slate-500">По</label>
<input
id="date_to"
name="date_to"
type="date"
value="{{ date_to }}"
class="mt-1 block rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none"
/>
</div>
<button type="submit" class="rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700">
Показать
</button>
<a href="/logs" class="text-sm text-slate-600 hover:underline">Сбросить</a>
</form>
{% if error %}
<div class="mt-4 rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">{{ error }}</div>
{% endif %}
<div class="mt-6 overflow-x-auto rounded-lg border border-slate-200 bg-white">
<table class="min-w-full divide-y divide-slate-200 text-sm">
<thead class="bg-slate-50 text-left text-slate-600">
<tr>
<th class="px-4 py-3 font-medium">Время</th>
<th class="px-4 py-3 font-medium">Событие</th>
<th class="px-4 py-3 font-medium">Пользователь</th>
<th class="px-4 py-3 font-medium">Чат</th>
<th class="px-4 py-3 font-medium">Версия</th>
<th class="px-4 py-3 font-medium">Payload</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
{% for event in events %}
<tr class="align-top hover:bg-slate-50">
<td class="whitespace-nowrap px-4 py-3 text-slate-600">{{ event.time_local }}</td>
<td class="px-4 py-3">
<span class="rounded bg-slate-100 px-2 py-0.5 font-mono text-xs">{{ event.event_type }}</span>
{% if event.prod %}<span class="ml-1 rounded bg-indigo-100 px-2 py-0.5 text-xs text-indigo-800">prod</span>{% endif %}
</td>
<td class="px-4 py-3">
{% if event.user_id %}
{% if event.username %}<div>@{{ event.username }}</div>{% endif %}
<a href="/users/{{ event.user_id }}" class="font-mono text-xs text-sky-700 hover:underline">{{ event.user_id }}</a>
{% else %}
<span class="text-slate-400">—</span>
{% endif %}
</td>
<td class="px-4 py-3 font-mono text-xs text-slate-500">{{ event.chat_id or "—" }}</td>
<td class="px-4 py-3 text-xs text-slate-500">{{ event.bot_version }}</td>
<td class="px-4 py-3">
{% if event.payload_pretty %}
<details>
<summary class="cursor-pointer text-xs text-slate-500">JSON</summary>
<pre class="mt-1 max-h-64 max-w-xl overflow-auto rounded bg-slate-50 p-2 text-xs">{{ event.payload_pretty }}</pre>
</details>
{% else %}
<span class="text-slate-400">—</span>
{% endif %}
</td>
</tr>
{% else %}
<tr>
<td colspan="6" class="px-4 py-6 text-center text-slate-500">
{% if error %}Данные не загрузились.{% else %}За выбранный период событий нет.{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if pages > 1 %}
<div class="mt-4 flex items-center justify-between text-sm text-slate-600">
<div>Страница {{ page }} из {{ pages }}</div>
<div class="flex gap-3">
{% if page > 1 %}
<a href="/logs?{{ query_base }}&page={{ page - 1 }}" class="hover:underline">← Назад</a>
{% endif %}
{% if page < pages %}
<a href="/logs?{{ query_base }}&page={{ page + 1 }}" class="hover:underline">Вперёд →</a>
{% endif %}
</div>
</div>
{% endif %}
{% endblock %}

125
common/analytics/read.py Normal file
View File

@ -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

View File

@ -7,4 +7,4 @@
from __future__ import annotations from __future__ import annotations
__version__ = "0.4.7" __version__ = "0.4.8"

View File

@ -72,6 +72,8 @@ services:
environment: environment:
DB_HOST: postgres DB_HOST: postgres
DB_PORT: "5432" DB_PORT: "5432"
CLICKHOUSE_HOST: clickhouse
CLICKHOUSE_PORT: "8123"
ADMIN_HOST: 0.0.0.0 ADMIN_HOST: 0.0.0.0
ADMIN_PORT: "8000" ADMIN_PORT: "8000"
ports: ports:

View File

@ -80,6 +80,8 @@ services:
environment: environment:
DB_HOST: postgres DB_HOST: postgres
DB_PORT: "5432" DB_PORT: "5432"
CLICKHOUSE_HOST: clickhouse
CLICKHOUSE_PORT: "8123"
ADMIN_HOST: 0.0.0.0 ADMIN_HOST: 0.0.0.0
ADMIN_PORT: "8000" ADMIN_PORT: "8000"
ports: ports:

97
tests/test_admin_logs.py Normal file
View File

@ -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

View File

@ -7,4 +7,4 @@ from common import __version__
def test_version_is_set(): def test_version_is_set():
assert __version__ assert __version__
assert __version__ == "0.4.7" assert __version__ == "0.4.8"