Files
remnawave-bedolaga-telegram…/app/webapi/routes/logs.py

268 lines
9.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Маршруты административного API для просмотра логов."""
from __future__ import annotations
import logging
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Security
from fastapi.concurrency import run_in_threadpool
from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.ticket import TicketCRUD
from app.services.monitoring_service import monitoring_service
from ..dependencies import get_db_session, require_api_token
from ..schemas.logs import (
MonitoringLogEntry,
MonitoringLogListResponse,
MonitoringLogTypeListResponse,
SupportAuditActionsResponse,
SupportAuditLogEntry,
SupportAuditLogListResponse,
SystemLogPreviewResponse,
SystemLogFullResponse,
)
router = APIRouter()
logger = logging.getLogger(__name__)
SYSTEM_LOG_PREVIEW_LIMIT_DEFAULT = 4000
SYSTEM_LOG_PREVIEW_LIMIT_MAX = 20000
def _resolve_system_log_path() -> Path:
path = Path(settings.LOG_FILE)
if not path.is_absolute():
path = Path.cwd() / path
return path
async def _read_system_log(path: Path) -> tuple[str, int, Optional[float]]:
def _read() -> tuple[str, int, float]:
content = path.read_text(encoding="utf-8", errors="ignore")
stats = path.stat()
return content, stats.st_size, stats.st_mtime
return await run_in_threadpool(_read)
def _format_timestamp(timestamp: Optional[float]) -> Optional[datetime]:
if timestamp is None:
return None
return datetime.fromtimestamp(timestamp, tz=timezone.utc)
@router.get("/system", response_model=SystemLogPreviewResponse)
async def get_system_log_preview(
_: Any = Security(require_api_token),
preview_limit: int = Query(
SYSTEM_LOG_PREVIEW_LIMIT_DEFAULT,
ge=500,
le=SYSTEM_LOG_PREVIEW_LIMIT_MAX,
description="Количество символов предпросмотра от конца файла",
),
) -> SystemLogPreviewResponse:
"""Получить предпросмотр системного лог-файла бота."""
log_path = _resolve_system_log_path()
if not log_path.exists() or not log_path.is_file():
return SystemLogPreviewResponse(
path=str(log_path),
exists=False,
updated_at=None,
size_bytes=0,
size_chars=0,
preview="",
preview_chars=0,
preview_truncated=False,
download_url="/logs/system/download",
)
try:
content, size_bytes, mtime = await _read_system_log(log_path)
except FileNotFoundError:
logger.warning("Лог-файл %s исчез во время чтения", log_path)
return SystemLogPreviewResponse(
path=str(log_path),
exists=False,
updated_at=None,
size_bytes=0,
size_chars=0,
preview="",
preview_chars=0,
preview_truncated=False,
download_url="/logs/system/download",
)
except Exception as error: # pragma: no cover - защита от неожиданных ошибок чтения
logger.error("Ошибка чтения лог-файла %s: %s", log_path, error)
raise HTTPException(status_code=500, detail="Не удалось прочитать лог-файл") from error
preview_text = content[-preview_limit:] if preview_limit > 0 else ""
truncated = len(content) > len(preview_text)
return SystemLogPreviewResponse(
path=str(log_path),
exists=True,
updated_at=_format_timestamp(mtime),
size_bytes=size_bytes,
size_chars=len(content),
preview=preview_text,
preview_chars=len(preview_text),
preview_truncated=truncated,
download_url="/logs/system/download",
)
@router.get("/system/download")
async def download_system_log(
_: Any = Security(require_api_token),
) -> FileResponse:
"""Скачать полный лог-файл бота."""
log_path = _resolve_system_log_path()
if not log_path.exists() or not log_path.is_file():
raise HTTPException(status_code=404, detail="Лог-файл не найден")
try:
return FileResponse(
log_path,
media_type="text/plain",
filename=log_path.name,
)
except Exception as error: # pragma: no cover - защита от неожиданных ошибок отдачи файла
logger.error("Ошибка отправки лог-файла %s: %s", log_path, error)
raise HTTPException(status_code=500, detail="Не удалось отправить лог-файл") from error
@router.get("/system/full", response_model=SystemLogFullResponse)
async def get_system_log_full(
_: Any = Security(require_api_token),
) -> SystemLogFullResponse:
"""Получить полный системный лог-файл бота."""
log_path = _resolve_system_log_path()
if not log_path.exists() or not log_path.is_file():
raise HTTPException(status_code=404, detail="Лог-файл не найден")
try:
content, size_bytes, mtime = await _read_system_log(log_path)
except Exception as error: # pragma: no cover - защита от неожиданных ошибок чтения
logger.error("Ошибка чтения лог-файла %s: %s", log_path, error)
raise HTTPException(status_code=500, detail="Не удалось прочитать лог-файл") from error
return SystemLogFullResponse(
path=str(log_path),
exists=True,
updated_at=_format_timestamp(mtime),
size_bytes=size_bytes,
size_chars=len(content),
content=content,
)
@router.get("/monitoring", response_model=MonitoringLogListResponse)
async def list_monitoring_logs(
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
limit: int = Query(50, ge=1, le=200, description="Количество записей на странице"),
offset: int = Query(0, ge=0, description="Смещение от начала списка"),
event_type: Optional[str] = Query(
default=None,
max_length=100,
description="Фильтр по типу события",
),
) -> MonitoringLogListResponse:
"""Получить список логов мониторинга с пагинацией."""
per_page = limit
page = (offset // per_page) + 1
raw_logs = await monitoring_service.get_monitoring_logs(
db,
event_type=event_type,
page=page,
per_page=per_page,
)
total = await monitoring_service.get_monitoring_logs_count(db, event_type=event_type)
return MonitoringLogListResponse(
total=total,
limit=limit,
offset=offset,
items=[MonitoringLogEntry(**entry) for entry in raw_logs],
)
@router.get("/monitoring/event-types", response_model=MonitoringLogTypeListResponse)
async def list_monitoring_event_types(
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> MonitoringLogTypeListResponse:
"""Получить список доступных типов событий мониторинга."""
event_types = await monitoring_service.get_monitoring_event_types(db)
return MonitoringLogTypeListResponse(items=event_types)
@router.get("/support", response_model=SupportAuditLogListResponse)
async def list_support_audit_logs(
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
limit: int = Query(50, ge=1, le=200, description="Количество записей на странице"),
offset: int = Query(0, ge=0, description="Смещение от начала списка"),
action: Optional[str] = Query(
default=None,
max_length=50,
description="Фильтр по типу действия модератора",
),
) -> SupportAuditLogListResponse:
"""Получить список аудита действий модераторов поддержки."""
logs = await TicketCRUD.list_support_audit(
db,
limit=limit,
offset=offset,
action=action,
)
total = await TicketCRUD.count_support_audit(db, action=action)
return SupportAuditLogListResponse(
total=total,
limit=limit,
offset=offset,
items=[
SupportAuditLogEntry(
id=log.id,
actor_user_id=log.actor_user_id,
actor_telegram_id=log.actor_telegram_id,
is_moderator=log.is_moderator,
action=log.action,
ticket_id=log.ticket_id,
target_user_id=log.target_user_id,
details=log.details,
created_at=log.created_at,
)
for log in logs
],
)
@router.get("/support/actions", response_model=SupportAuditActionsResponse)
async def list_support_audit_actions(
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> SupportAuditActionsResponse:
"""Получить список действий, доступных в аудите поддержки."""
actions = await TicketCRUD.list_support_audit_actions(db)
return SupportAuditActionsResponse(items=actions)