feat: API request/response logging with admin log viewer

- Add api_logs table (migration 0007) and ApiLog model
- Add web/lib/api_logger.py — httpx wrapper that records every outbound call
- Wire api_logger into vk_sync, vk_catalog, and connections test endpoints
- Add /admin/logs page with filters (service, method, status, time range, URL search) and expandable request/response detail
- Add "Логи" nav link for admin users

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
mguschin
2026-05-12 22:00:14 +03:00
parent cad0b10fbb
commit 9960d760a0
10 changed files with 392 additions and 21 deletions

82
web/lib/api_logger.py Normal file
View File

@@ -0,0 +1,82 @@
"""Thin wrapper around httpx that logs every outbound API call to api_logs."""
import json
import time
import urllib.parse
from typing import Any
import httpx
from web.database import SessionLocal
from web.models.connections import ApiLog
_MAX_BODY = 8000 # truncate stored bodies beyond this
def _service_from_url(url: str) -> str:
host = urllib.parse.urlparse(url).netloc
if "evotor" in host:
return "evotor"
if "vk.com" in host:
return "vk"
return "other"
def _truncate(text: str | None) -> str | None:
if text and len(text) > _MAX_BODY:
return text[:_MAX_BODY] + ""
return text
def _record(
user_id: int | None,
method: str,
url: str,
request_body: str | None,
response_status: int | None,
response_body: str | None,
duration_ms: int,
) -> None:
try:
db = SessionLocal()
db.add(ApiLog(
user_id=user_id,
service=_service_from_url(url),
method=method.upper(),
url=url,
request_body=_truncate(request_body),
response_status=response_status,
response_body=_truncate(response_body),
duration_ms=duration_ms,
))
db.commit()
db.close()
except Exception:
pass # never let logging crash the caller
def get(url: str, *, user_id: int | None = None, **kwargs) -> httpx.Response:
t0 = time.monotonic()
resp = httpx.get(url, **kwargs)
ms = int((time.monotonic() - t0) * 1000)
try:
body = resp.text
except Exception:
body = None
_record(user_id, "GET", url, None, resp.status_code, body, ms)
return resp
def post(url: str, *, user_id: int | None = None, data: Any = None, json: Any = None, **kwargs) -> httpx.Response:
t0 = time.monotonic()
resp = httpx.post(url, data=data, json=json, **kwargs)
ms = int((time.monotonic() - t0) * 1000)
try:
req_body = resp.request.content.decode("utf-8", errors="replace") if resp.request.content else None
except Exception:
req_body = None
try:
body = resp.text
except Exception:
body = None
_record(user_id, "POST", url, req_body, resp.status_code, body, ms)
return resp

View File

@@ -38,6 +38,7 @@ from web.routes.admin import router as admin_router # noqa: E402
from web.routes.catalog import router as catalog_router # noqa: E402 from web.routes.catalog import router as catalog_router # noqa: E402
from web.routes.connections import router as connections_router # noqa: E402 from web.routes.connections import router as connections_router # noqa: E402
from web.routes.vk_catalog import router as vk_catalog_router # noqa: E402 from web.routes.vk_catalog import router as vk_catalog_router # noqa: E402
from web.routes.logs import router as logs_router # noqa: E402
app.include_router(auth_router) app.include_router(auth_router)
app.include_router(reset_router) app.include_router(reset_router)
@@ -48,6 +49,7 @@ app.include_router(admin_router)
app.include_router(catalog_router) app.include_router(catalog_router)
app.include_router(connections_router) app.include_router(connections_router)
app.include_router(vk_catalog_router) app.include_router(vk_catalog_router)
app.include_router(logs_router)
# ── Catalog redirect ───────────────────────────────────────────────────────── # ── Catalog redirect ─────────────────────────────────────────────────────────

View File

@@ -0,0 +1,32 @@
"""Add api_logs table for request/response logging."""
revision = "0007"
down_revision = "0006"
branch_labels = None
depends_on = None
import sqlalchemy as sa
from alembic import op
def upgrade():
op.create_table(
"api_logs",
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
sa.Column("user_id", sa.Integer, sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
sa.Column("service", sa.String(20), nullable=False),
sa.Column("method", sa.String(10), nullable=False),
sa.Column("url", sa.String(1024), nullable=False),
sa.Column("request_body", sa.Text, nullable=True),
sa.Column("response_status", sa.Integer, nullable=True),
sa.Column("response_body", sa.Text, nullable=True),
sa.Column("duration_ms", sa.Integer, nullable=True),
sa.Column("created_at", sa.DateTime, nullable=False, server_default=sa.func.now()),
)
op.create_index("ix_api_logs_user_service", "api_logs", ["user_id", "service"])
op.create_index("ix_api_logs_created_at", "api_logs", ["created_at"])
def downgrade():
op.drop_index("ix_api_logs_created_at", "api_logs")
op.drop_index("ix_api_logs_user_service", "api_logs")
op.drop_table("api_logs")

View File

@@ -3,6 +3,7 @@ from sqlalchemy import (
Numeric, String, Text, UniqueConstraint, func, Numeric, String, Text, UniqueConstraint, func,
) )
from web.database import Base from web.database import Base
@@ -175,3 +176,23 @@ class CachedProduct(Base):
UniqueConstraint("user_id", "evotor_id", name="uq_cached_products_user_evotor"), UniqueConstraint("user_id", "evotor_id", name="uq_cached_products_user_evotor"),
Index("ix_cached_products_user_store_group", "user_id", "store_evotor_id", "group_evotor_id"), Index("ix_cached_products_user_store_group", "user_id", "store_evotor_id", "group_evotor_id"),
) )
class ApiLog(Base):
__tablename__ = "api_logs"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
service = Column(String(20), nullable=False) # "evotor" | "vk"
method = Column(String(10), nullable=False) # "GET" | "POST"
url = Column(String(1024), nullable=False)
request_body = Column(Text, nullable=True)
response_status = Column(Integer, nullable=True)
response_body = Column(Text, nullable=True)
duration_ms = Column(Integer, nullable=True)
created_at = Column(DateTime, nullable=False, server_default=func.now())
__table_args__ = (
Index("ix_api_logs_user_service", "user_id", "service"),
Index("ix_api_logs_created_at", "created_at"),
)

View File

@@ -3,6 +3,7 @@ from datetime import datetime, timedelta, timezone
from urllib.parse import urlencode from urllib.parse import urlencode
import httpx import httpx
import web.lib.api_logger as api_logger
from fastapi import APIRouter, Depends, Request from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -306,8 +307,9 @@ async def connections_evotor_test(request: Request, db: Session = Depends(get_db
return JSONResponse({"ok": False, "message": "Подключение не настроено"}) return JSONResponse({"ok": False, "message": "Подключение не настроено"})
try: try:
r = httpx.get( r = api_logger.get(
"https://api.evotor.ru/stores", "https://api.evotor.ru/stores",
user_id=user.id,
headers={ headers={
"Authorization": f"Bearer {conn.access_token}", "Authorization": f"Bearer {conn.access_token}",
"Accept": "application/vnd.evotor.v2+json", "Accept": "application/vnd.evotor.v2+json",
@@ -344,8 +346,9 @@ async def connections_vk_test(request: Request, db: Session = Depends(get_db)):
if not conn.vk_user_id: if not conn.vk_user_id:
return JSONResponse({"ok": False, "message": "Укажите ID сообщества для проверки подключения."}) return JSONResponse({"ok": False, "message": "Укажите ID сообщества для проверки подключения."})
r = httpx.get( r = api_logger.get(
"https://api.vk.com/method/groups.getById", "https://api.vk.com/method/groups.getById",
user_id=user.id,
params={ params={
"group_id": conn.vk_user_id, "group_id": conn.vk_user_id,
"fields": "market", "fields": "market",

81
web/routes/logs.py Normal file
View File

@@ -0,0 +1,81 @@
"""API request/response log viewer (admin only)."""
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, Request
from fastapi.responses import RedirectResponse
from sqlalchemy.orm import Session
from web.auth.session import get_current_user
from web.database import get_db
from web.models.connections import ApiLog
from web.templates_env import templates
router = APIRouter()
PAGE_SIZE = 50
def _render(request, template, ctx):
return templates.TemplateResponse(template, {"request": request, **ctx})
@router.get("/admin/logs")
async def admin_logs(
request: Request,
db: Session = Depends(get_db),
service: str = "",
method: str = "",
status: str = "",
q: str = "",
page: int = 1,
hours: int = 168,
):
try:
user = get_current_user(request, db)
except Exception:
return RedirectResponse("/login", 303)
since = datetime.utcnow() - timedelta(hours=hours)
query = db.query(ApiLog).filter(ApiLog.created_at >= since)
if service:
query = query.filter(ApiLog.service == service)
if method:
query = query.filter(ApiLog.method == method)
if status:
try:
st = int(status)
query = query.filter(ApiLog.response_status == st)
except ValueError:
if status == "error":
query = query.filter(ApiLog.response_status >= 400)
elif status == "ok":
query = query.filter(ApiLog.response_status < 400)
if q:
like = f"%{q}%"
query = query.filter(
ApiLog.url.like(like) | ApiLog.response_body.like(like)
)
total = query.count()
logs = (
query.order_by(ApiLog.created_at.desc())
.offset((page - 1) * PAGE_SIZE)
.limit(PAGE_SIZE)
.all()
)
total_pages = max(1, (total + PAGE_SIZE - 1) // PAGE_SIZE)
return _render(request, "admin/logs.html", {
"user": user,
"logs": logs,
"total": total,
"page": page,
"total_pages": total_pages,
"page_size": PAGE_SIZE,
"filter_service": service,
"filter_method": method,
"filter_status": status,
"filter_q": q,
"filter_hours": hours,
})

View File

@@ -5,9 +5,9 @@ for every connected user and upsert into vk_cached_* tables.
import logging import logging
from datetime import datetime, timezone from datetime import datetime, timezone
import httpx
from celery import shared_task from celery import shared_task
import web.lib.api_logger as api_logger
from web.config import settings from web.config import settings
from web.database import SessionLocal from web.database import SessionLocal
from web.models.connections import VkCachedAlbum, VkCachedProduct, VkConnection from web.models.connections import VkCachedAlbum, VkCachedProduct, VkConnection
@@ -21,9 +21,9 @@ def _now() -> datetime:
return datetime.now(timezone.utc).replace(tzinfo=None) return datetime.now(timezone.utc).replace(tzinfo=None)
def _vk_get(method: str, params: dict, token: str) -> dict: def _vk_get(method: str, params: dict, token: str, user_id: int | None = None) -> dict:
params = {**params, "access_token": token, "v": settings.VK_API_VERSION} params = {**params, "access_token": token, "v": settings.VK_API_VERSION}
r = httpx.get(f"{VK_API}/{method}", params=params, timeout=20) r = api_logger.get(f"{VK_API}/{method}", user_id=user_id, params=params, timeout=20)
r.raise_for_status() r.raise_for_status()
return r.json() return r.json()
@@ -34,7 +34,7 @@ def _sync_user(db, user_id: int, token: str, group_id: str) -> None:
# ── albums ──────────────────────────────────────────────────────────────── # ── albums ────────────────────────────────────────────────────────────────
try: try:
data = _vk_get("market.getAlbums", {"owner_id": owner_id, "count": 100}, token) data = _vk_get("market.getAlbums", {"owner_id": owner_id, "count": 100}, token, user_id=user_id)
except Exception as e: except Exception as e:
logger.warning("user=%s vk fetch albums failed: %s", user_id, e) logger.warning("user=%s vk fetch albums failed: %s", user_id, e)
return return
@@ -83,6 +83,7 @@ def _sync_user(db, user_id: int, token: str, group_id: str) -> None:
"market.get", "market.get",
{"owner_id": owner_id, "count": 200, "offset": offset, "extended": 1}, {"owner_id": owner_id, "count": 200, "offset": offset, "extended": 1},
token, token,
user_id=user_id,
) )
except Exception as e: except Exception as e:
logger.warning("user=%s vk fetch products (extended) failed: %s", user_id, e) logger.warning("user=%s vk fetch products (extended) failed: %s", user_id, e)

View File

@@ -10,9 +10,9 @@ import os
from datetime import datetime, timezone from datetime import datetime, timezone
from decimal import Decimal from decimal import Decimal
import httpx
from celery import shared_task from celery import shared_task
import web.lib.api_logger as api_logger
from web.config import settings from web.config import settings
from web.database import SessionLocal from web.database import SessionLocal
from web.models.connections import ( from web.models.connections import (
@@ -63,14 +63,14 @@ def _name_for_vk(name: str) -> str:
return name.replace(";", ",") return name.replace(";", ",")
def _vk_post(method: str, data: dict, token: str) -> dict: def _vk_post(method: str, data: dict, token: str, user_id: int | None = None) -> dict:
data = {**data, "access_token": token, "v": settings.VK_API_VERSION} data = {**data, "access_token": token, "v": settings.VK_API_VERSION}
r = httpx.post(f"{VK_API}/{method}", data=data, timeout=30) r = api_logger.post(f"{VK_API}/{method}", user_id=user_id, data=data, timeout=30)
r.raise_for_status() r.raise_for_status()
return r.json() return r.json()
def _upload_photo(token: str, group_id: str) -> str | None: def _upload_photo(token: str, group_id: str, user_id: int | None = None) -> str | None:
"""Upload the default product photo and return photo_id, or None on failure.""" """Upload the default product photo and return photo_id, or None on failure."""
photo_path = settings.VK_DEFAULT_PHOTO_PATH photo_path = settings.VK_DEFAULT_PHOTO_PATH
if not os.path.exists(photo_path): if not os.path.exists(photo_path):
@@ -78,7 +78,7 @@ def _upload_photo(token: str, group_id: str) -> str | None:
return None return None
try: try:
# Step 1: get upload URL # Step 1: get upload URL
resp = _vk_post("market.getProductPhotoUploadServer", {"group_id": group_id}, token) resp = _vk_post("market.getProductPhotoUploadServer", {"group_id": group_id}, token, user_id=user_id)
if "error" in resp: if "error" in resp:
logger.warning("getProductPhotoUploadServer error: %s", resp["error"]) logger.warning("getProductPhotoUploadServer error: %s", resp["error"])
return None return None
@@ -86,12 +86,12 @@ def _upload_photo(token: str, group_id: str) -> str | None:
# Step 2: upload file # Step 2: upload file
with open(photo_path, "rb") as f: with open(photo_path, "rb") as f:
up = httpx.post(upload_url, files={"file": f}, timeout=30) up = api_logger.post(upload_url, user_id=user_id, files={"file": f}, timeout=30)
up.raise_for_status() up.raise_for_status()
upload_obj = up.text upload_obj = up.text
# Step 3: save # Step 3: save
resp2 = _vk_post("market.saveProductPhoto", {"upload_response": upload_obj}, token) resp2 = _vk_post("market.saveProductPhoto", {"upload_response": upload_obj}, token, user_id=user_id)
if "error" in resp2: if "error" in resp2:
logger.warning("saveProductPhoto error: %s", resp2["error"]) logger.warning("saveProductPhoto error: %s", resp2["error"])
return None return None
@@ -104,7 +104,7 @@ def _upload_photo(token: str, group_id: str) -> str | None:
def _get_photo_id(user_id: int, token: str, group_id: str) -> str | None: def _get_photo_id(user_id: int, token: str, group_id: str) -> str | None:
"""Upload photo once per sync run per user, cache the result.""" """Upload photo once per sync run per user, cache the result."""
if user_id not in _PHOTO_CACHE: if user_id not in _PHOTO_CACHE:
_PHOTO_CACHE[user_id] = _upload_photo(token, group_id) _PHOTO_CACHE[user_id] = _upload_photo(token, group_id, user_id=user_id)
return _PHOTO_CACHE[user_id] return _PHOTO_CACHE[user_id]
@@ -142,7 +142,7 @@ def _ensure_album(db, user_id: int, vk_group_id: str, group_name: str, token: st
resp = _vk_post("market.addAlbum", { resp = _vk_post("market.addAlbum", {
"owner_id": f"-{vk_group_id}", "owner_id": f"-{vk_group_id}",
"title": group_name, "title": group_name,
}, token) }, token, user_id=user_id)
if "error" in resp: if "error" in resp:
logger.warning("market.addAlbum error for '%s': %s", group_name, resp["error"]) logger.warning("market.addAlbum error for '%s': %s", group_name, resp["error"])
return None return None
@@ -212,7 +212,7 @@ def _sync_product(
"category_id": settings.VK_CATEGORY_ID, "category_id": settings.VK_CATEGORY_ID,
"price": price_kopecks, "price": price_kopecks,
"stock_amount": stock, "stock_amount": stock,
}, token) }, token, user_id=user_id)
if "error" in resp: if "error" in resp:
logger.warning("market.edit error product=%s: %s", product.evotor_id, resp["error"]) logger.warning("market.edit error product=%s: %s", product.evotor_id, resp["error"])
return return
@@ -223,12 +223,12 @@ def _sync_product(
"owner_id": owner_id, "owner_id": owner_id,
"item_id": product.vk_product_id, "item_id": product.vk_product_id,
"album_ids": old_album_id, "album_ids": old_album_id,
}, token) }, token, user_id=user_id)
resp_album = _vk_post("market.addToAlbum", { resp_album = _vk_post("market.addToAlbum", {
"owner_id": owner_id, "owner_id": owner_id,
"item_ids": product.vk_product_id, "item_ids": product.vk_product_id,
"album_ids": album_id, "album_ids": album_id,
}, token) }, token, user_id=user_id)
if "error" in resp_album: if "error" in resp_album:
logger.warning("market.addToAlbum error product=%s: %s", product.evotor_id, resp_album["error"]) logger.warning("market.addToAlbum error product=%s: %s", product.evotor_id, resp_album["error"])
else: else:
@@ -255,7 +255,7 @@ def _sync_product(
"price": price_kopecks, "price": price_kopecks,
"main_photo_id": photo_id, "main_photo_id": photo_id,
"stock_amount": stock, "stock_amount": stock,
}, token) }, token, user_id=user_id)
if "error" in resp: if "error" in resp:
logger.warning("market.add error product=%s: %s", product.evotor_id, resp["error"]) logger.warning("market.add error product=%s: %s", product.evotor_id, resp["error"])
return return
@@ -269,7 +269,7 @@ def _sync_product(
"owner_id": owner_id, "owner_id": owner_id,
"item_ids": vk_item_id, "item_ids": vk_item_id,
"album_ids": album_id, "album_ids": album_id,
}, token) }, token, user_id=user_id)
if "error" in resp2: if "error" in resp2:
logger.warning("market.addToAlbum error product=%s: %s", product.evotor_id, resp2["error"]) logger.warning("market.addToAlbum error product=%s: %s", product.evotor_id, resp2["error"])
@@ -310,7 +310,7 @@ def _delete_orphans(db, user_id, vk_group_id, owned_ids, token, results):
resp = _vk_post("market.delete", { resp = _vk_post("market.delete", {
"owner_id": owner_id, "owner_id": owner_id,
"item_id": vk_p.vk_product_id, "item_id": vk_p.vk_product_id,
}, token) }, token, user_id=user_id)
if "error" in resp: if "error" in resp:
logger.warning("market.delete error id=%s: %s", vk_p.vk_product_id, resp["error"]) logger.warning("market.delete error id=%s: %s", vk_p.vk_product_id, resp["error"])
results["errors"] += 1 results["errors"] += 1

View File

@@ -0,0 +1,147 @@
{% extends "base.html" %}
{% block title %}API Логи — ЭВОСИНК{% endblock %}
{% block content %}
<div class="d-flex justify-between align-center mb-3">
<h1 style="font-size:1.3rem; margin:0;"><i class="bi bi-journal-text me-2"></i>API Логи</h1>
<span class="text-muted small">Найдено: {{ total }}</span>
</div>
{# ── filters ── #}
<form method="get" action="/admin/logs" class="mb-3" style="display:flex; flex-wrap:wrap; gap:0.5rem; align-items:center;">
<select name="service" style="width:auto;">
<option value="" {% if not filter_service %}selected{% endif %}>Все сервисы</option>
<option value="evotor" {% if filter_service == 'evotor' %}selected{% endif %}>Эвотор</option>
<option value="vk" {% if filter_service == 'vk' %}selected{% endif %}>ВКонтакте</option>
<option value="other" {% if filter_service == 'other' %}selected{% endif %}>Другое</option>
</select>
<select name="method" style="width:auto;">
<option value="" {% if not filter_method %}selected{% endif %}>Все методы</option>
<option value="GET" {% if filter_method == 'GET' %}selected{% endif %}>GET</option>
<option value="POST" {% if filter_method == 'POST' %}selected{% endif %}>POST</option>
</select>
<select name="status" style="width:auto;">
<option value="" {% if not filter_status %}selected{% endif %}>Любой статус</option>
<option value="ok" {% if filter_status == 'ok' %}selected{% endif %}>2xx / 3xx</option>
<option value="error" {% if filter_status == 'error' %}selected{% endif %}>4xx / 5xx</option>
<option value="200" {% if filter_status == '200' %}selected{% endif %}>200</option>
<option value="401" {% if filter_status == '401' %}selected{% endif %}>401</option>
<option value="403" {% if filter_status == '403' %}selected{% endif %}>403</option>
<option value="429" {% if filter_status == '429' %}selected{% endif %}>429</option>
<option value="500" {% if filter_status == '500' %}selected{% endif %}>500</option>
</select>
<select name="hours" style="width:auto;">
<option value="1" {% if filter_hours == 1 %}selected{% endif %}>Последний час</option>
<option value="6" {% if filter_hours == 6 %}selected{% endif %}>6 часов</option>
<option value="24" {% if filter_hours == 24 %}selected{% endif %}>24 часа</option>
<option value="168" {% if filter_hours == 168 or (not filter_hours) %}selected{% endif %}>7 дней</option>
<option value="720" {% if filter_hours == 720 %}selected{% endif %}>30 дней</option>
</select>
<input type="search" name="q" value="{{ filter_q }}" placeholder="URL или тело ответа…" style="flex:1; min-width:160px;">
<button type="submit">Применить</button>
{% if filter_service or filter_method or filter_status or filter_q or filter_hours != 24 %}
<a href="/admin/logs" role="button" class="outline secondary">Сбросить</a>
{% endif %}
</form>
<article class="card" style="padding:0;">
{% if logs %}
<div class="table-scroll">
<table class="align-middle" style="font-size:0.82rem;">
<thead>
<tr>
<th style="width:140px;">Время</th>
<th style="width:60px;">Сервис</th>
<th style="width:40px;">Метод</th>
<th style="width:50px;">Статус</th>
<th style="width:60px;">Мс</th>
<th>URL</th>
<th style="width:30px;"></th>
</tr>
</thead>
<tbody>
{% for log in logs %}
{% set is_error = log.response_status and log.response_status >= 400 %}
<tr class="{{ 'text-danger' if is_error else '' }}" style="cursor:pointer;" onclick="toggleDetail({{ log.id }})">
<td class="text-muted">{{ log.created_at | datefmt }}</td>
<td>
<span class="badge {{ 'badge-evotor' if log.service == 'evotor' else 'badge-vk' if log.service == 'vk' else '' }}">
{{ log.service }}
</span>
</td>
<td><code>{{ log.method }}</code></td>
<td>
{% if log.response_status %}
<span class="{{ 'text-danger' if is_error else 'text-muted' }}">{{ log.response_status }}</span>
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
<td class="text-muted">{{ log.duration_ms if log.duration_ms is not none else '—' }}</td>
<td style="max-width:400px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">
<span title="{{ log.url }}">{{ log.url }}</span>
</td>
<td class="text-muted"><i class="bi bi-chevron-down"></i></td>
</tr>
<tr id="detail-{{ log.id }}" style="display:none; background:var(--pico-card-background-color);">
<td colspan="7" style="padding:0.75rem 1rem;">
<div style="display:grid; grid-template-columns:1fr 1fr; gap:1rem;">
<div>
<div class="text-muted small mb-1"><strong>URL</strong></div>
<code style="word-break:break-all; font-size:0.78rem;">{{ log.url }}</code>
{% if log.request_body %}
<div class="text-muted small mt-2 mb-1"><strong>Request body</strong></div>
<pre style="font-size:0.75rem; max-height:200px; overflow:auto; margin:0; background:var(--pico-code-background-color); padding:0.5rem; border-radius:4px;">{{ log.request_body }}</pre>
{% endif %}
</div>
<div>
<div class="text-muted small mb-1"><strong>Response ({{ log.response_status }})</strong></div>
{% if log.response_body %}
<pre style="font-size:0.75rem; max-height:200px; overflow:auto; margin:0; background:var(--pico-code-background-color); padding:0.5rem; border-radius:4px;">{{ log.response_body }}</pre>
{% else %}
<span class="text-muted"></span>
{% endif %}
</div>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{# ── pagination ── #}
{% if total_pages > 1 %}
<div style="display:flex; justify-content:center; gap:0.5rem; padding:1rem;">
{% if page > 1 %}
<a href="?service={{ filter_service }}&method={{ filter_method }}&status={{ filter_status }}&q={{ filter_q }}&hours={{ filter_hours }}&page={{ page - 1 }}" role="button" class="outline secondary sm">← Назад</a>
{% endif %}
<span class="text-muted" style="line-height:2.2rem;">Стр. {{ page }} / {{ total_pages }}</span>
{% if page < total_pages %}
<a href="?service={{ filter_service }}&method={{ filter_method }}&status={{ filter_status }}&q={{ filter_q }}&hours={{ filter_hours }}&page={{ page + 1 }}" role="button" class="outline secondary sm">Вперёд →</a>
{% endif %}
</div>
{% endif %}
{% else %}
<div class="text-center py-5 text-muted">
<i class="bi bi-journal-x" style="font-size:2rem;"></i>
<p class="mt-2">Записей не найдено за выбранный период.</p>
</div>
{% endif %}
</article>
<style>
.badge { display:inline-block; padding:0.1rem 0.4rem; border-radius:4px; font-size:0.75rem; font-weight:600; }
.badge-evotor { background:#e8f4fd; color:#0986E2; }
.badge-vk { background:#e8f0fe; color:#3b5998; }
.text-danger { color:#dc3545; }
</style>
<script>
function toggleDetail(id) {
const row = document.getElementById('detail-' + id);
row.style.display = row.style.display === 'none' ? 'table-row' : 'none';
}
</script>
{% endblock %}

View File

@@ -22,6 +22,7 @@
<li><a href="/sync">Синхронизация</a></li> <li><a href="/sync">Синхронизация</a></li>
{% if user.role in ('admin', 'system') %} {% if user.role in ('admin', 'system') %}
<li><a href="/admin/users"><i class="bi bi-shield-lock"></i> Админ</a></li> <li><a href="/admin/users"><i class="bi bi-shield-lock"></i> Админ</a></li>
<li><a href="/admin/logs"><i class="bi bi-journal-text"></i> Логи</a></li>
{% endif %} {% endif %}
<li><a href="/profile"><i class="bi bi-person-circle"></i> Личный кабинет</a></li> <li><a href="/profile"><i class="bi bi-person-circle"></i> Личный кабинет</a></li>
<li><a href="/logout" class="secondary">Выход</a></li> <li><a href="/logout" class="secondary">Выход</a></li>
@@ -40,6 +41,7 @@
<li><a href="/sync">Синхронизация</a></li> <li><a href="/sync">Синхронизация</a></li>
{% if user.role in ('admin', 'system') %} {% if user.role in ('admin', 'system') %}
<li><a href="/admin/users">Админ</a></li> <li><a href="/admin/users">Админ</a></li>
<li><a href="/admin/logs">Логи</a></li>
{% endif %} {% endif %}
<li><a href="/profile">Личный кабинет</a></li> <li><a href="/profile">Личный кабинет</a></li>
<li><a href="/logout">Выход</a></li> <li><a href="/logout">Выход</a></li>