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:
147
web/templates/admin/logs.html
Normal file
147
web/templates/admin/logs.html
Normal 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 %}
|
||||
@@ -22,6 +22,7 @@
|
||||
<li><a href="/sync">Синхронизация</a></li>
|
||||
{% if user.role in ('admin', 'system') %}
|
||||
<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 %}
|
||||
<li><a href="/profile"><i class="bi bi-person-circle"></i> Личный кабинет</a></li>
|
||||
<li><a href="/logout" class="secondary">Выход</a></li>
|
||||
@@ -40,6 +41,7 @@
|
||||
<li><a href="/sync">Синхронизация</a></li>
|
||||
{% if user.role in ('admin', 'system') %}
|
||||
<li><a href="/admin/users">Админ</a></li>
|
||||
<li><a href="/admin/logs">Логи</a></li>
|
||||
{% endif %}
|
||||
<li><a href="/profile">Личный кабинет</a></li>
|
||||
<li><a href="/logout">Выход</a></li>
|
||||
|
||||
Reference in New Issue
Block a user