119 lines
3.7 KiB
Python
119 lines
3.7 KiB
Python
"""Раздел «Логи»: события из 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,
|
||
},
|
||
)
|