Files
evo-sync/web/templates/admin/logs.html
mguschin 9960d760a0 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>
2026-05-12 22:00:14 +03:00

148 lines
8.3 KiB
HTML
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.
{% 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 %}