Логи стали видны из админки, а не только через SQL
Раздел «Логи» читает analytics.events из ClickHouse: фильтры по типу события, пользователю (ID или username) и датам, по 50 строк на страницу, время в часовом поясе админки. Payload раскрывается как JSON, ID ведёт в карточку игрока. Чтение идёт отдельным ленивым клиентом: если ClickHouse лежит, страница сообщает об этом и не роняет админку. Боту для этого ничего не меняли.
This commit is contained in:
parent
92791f1ba9
commit
107a79d05e
|
|
@ -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`.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
@ -29,7 +29,7 @@ SECTIONS: tuple[dict[str, str | None], ...] = (
|
||||||
{
|
{
|
||||||
"title": "Логи",
|
"title": "Логи",
|
||||||
"description": "События из ClickHouse с фильтрами",
|
"description": "События из ClickHouse с фильтрами",
|
||||||
"url": None,
|
"url": "/logs",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -7,4 +7,4 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
__version__ = "0.4.7"
|
__version__ = "0.4.8"
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue