alpinux-static/app/templates/errors_404.html
Alpinux 3a6f363e1d feat(erreurs): recherche dynamique par chemin ou IP (#42)
Champ de filtre en temps réel au-dessus du tableau des 404 ;
ferme le panneau de détail des lignes masquées.
Corrige aussi rsync via sudo pour préserver les droits static-cdn.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 13:21:02 +02:00

265 lines
9.9 KiB
HTML

{% extends "base.html" %}
{% block title %}Erreurs 404{% endblock %}
{% block content %}
{% if not log_configured %}
<section class="card">
<p style="color:var(--muted)">STATS_LOG_FILE non configuré — impossible d'analyser les logs.</p>
</section>
{% else %}
<section class="card">
<div class="err-header">
<h2>Erreurs 404 <span class="err-total-badge">{{ total }} requêtes</span></h2>
<button type="button" class="btn btn-sm" id="refresh-btn" onclick="location.reload()">↺ Actualiser</button>
</div>
{% if not entries %}
<p style="color:var(--muted); margin-top:.5rem">Aucune erreur 404 dans les logs récents.</p>
{% else %}
<div class="err-search-wrap">
<input type="search" id="err-search" class="err-search" placeholder="Filtrer par chemin ou adresse IP…" autocomplete="off">
<span class="err-search-count" id="err-search-count"></span>
</div>
<table class="file-table err-table">
<thead>
<tr>
<th class="col-err-status"></th>
<th>Chemin</th>
<th class="col-err-count">Requêtes</th>
<th class="col-date">Dernière vue</th>
<th class="col-err-ips">IPs</th>
<th class="col-actions"></th>
</tr>
</thead>
<tbody>
{% for path, info in entries %}
<tr class="err-row" data-path="{{ path }}" data-ips="{{ info.ips.keys() | join(' ') }}">
<td class="col-err-status">
<span class="err-status-dot err-status-dot--{% if not info.last_seen %}unk{% elif (info.ips | length) > 0 %}active{% else %}ok{% endif %}"
data-path="{{ path }}"
title="Cliquer pour vérifier"></span>
</td>
<td class="err-path" title="{{ path }}"><code>{{ path }}</code></td>
<td class="col-err-count"><span class="hits-badge hits-active">{{ info.count }}</span></td>
<td class="col-date">{{ info.last_seen.strftime('%d/%m/%Y %H:%M') }}</td>
<td class="col-err-ips">{{ info.ips | length }}</td>
<td class="col-actions">
<button type="button" class="btn-detail" data-path="{{ path }}" title="Voir le détail"></button>
</td>
</tr>
<tr class="err-detail-row" id="detail-{{ loop.index }}" style="display:none">
<td colspan="6" class="err-detail-cell">
<div class="err-detail-content" id="detail-content-{{ loop.index }}">
<span class="err-loading">Chargement…</span>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</section>
{% if ignored_ips %}
<section class="card">
<h2>IPs ignorées</h2>
<div class="err-ignored-list">
{% for ip in ignored_ips %}
<span class="err-ignored-chip">
{{ ip }}
<button type="button" class="err-ignored-remove" data-ip="{{ ip }}" title="Retirer"></button>
</span>
{% endfor %}
</div>
</section>
{% endif %}
{% endif %}
<script>
(function () {
const DETAIL_URL = {{ url_for('errors_detail') | tojson }};
const IGNORE_URL = {{ url_for('errors_ignore') | tojson }};
const BAN_URL = {{ url_for('errors_ban') | tojson }};
/* ── Search / filter ── */
const searchInput = document.getElementById('err-search');
const searchCount = document.getElementById('err-search-count');
const allRows = [...document.querySelectorAll('.err-row')];
function applyFilter() {
const q = searchInput.value.trim().toLowerCase();
let visible = 0;
allRows.forEach(row => {
const match = !q
|| row.dataset.path.toLowerCase().includes(q)
|| row.dataset.ips.toLowerCase().includes(q);
const detailRow = row.nextElementSibling;
row.style.display = match ? '' : 'none';
if (!match && detailRow && detailRow.classList.contains('err-detail-row')) {
detailRow.style.display = 'none';
row.querySelector('.btn-detail').textContent = '▼';
if (openRow === detailRow) openRow = null;
}
if (match) visible++;
});
searchCount.textContent = q ? `${visible} / ${allRows.length}` : '';
}
if (searchInput) searchInput.addEventListener('input', applyFilter);
/* ── Expand/collapse detail ── */
let openRow = null;
document.querySelectorAll('.btn-detail').forEach(btn => {
btn.addEventListener('click', () => {
const path = btn.dataset.path;
const row = btn.closest('tr');
const idx = row.querySelector('.err-status-dot').dataset.path === path
? [...document.querySelectorAll('.err-row')].indexOf(row) + 1
: null;
const detailRow = row.nextElementSibling;
const detailContent = detailRow.querySelector('.err-detail-content');
const open = detailRow.style.display !== 'none';
if (openRow && openRow !== detailRow) {
openRow.style.display = 'none';
openRow.previousElementSibling.querySelector('.btn-detail').textContent = '▼';
}
if (open) {
detailRow.style.display = 'none';
btn.textContent = '▼';
openRow = null;
return;
}
detailRow.style.display = '';
btn.textContent = '▲';
openRow = detailRow;
loadDetail(path, detailContent);
});
});
async function loadDetail(path, container) {
container.innerHTML = '<span class="err-loading">Chargement…</span>';
try {
const d = await fetch(DETAIL_URL + '?path=' + encodeURIComponent(path)).then(r => r.json());
renderDetail(d, container);
} catch (_) {
container.innerHTML = '<span style="color:#b91c1c">Erreur réseau.</span>';
}
}
function renderDetail(d, container) {
const statusBadge = d.still_404
? '<span class="err-badge err-badge--active">✗ Toujours actif</span>'
: '<span class="err-badge err-badge--ok">✓ Résolu</span>';
let html = `<div class="err-detail-meta">${statusBadge}
<span class="err-detail-info">${d.count} requête(s) · dernière le ${d.last_seen}</span>
</div>`;
html += '<table class="err-ip-table"><thead><tr>'
+ '<th>IP</th><th>Requêtes</th><th>Dernière vue</th><th>Referers</th><th></th>'
+ '</tr></thead><tbody>';
for (const ip of d.ips) {
const referers = [...new Set(ip.hits.map(h => h.referer).filter(Boolean))];
const refHtml = referers.length
? referers.slice(0,3).map(r => `<span class="err-referer" title="${escHtml(r)}">${escHtml(trimUrl(r))}</span>`).join(' ')
: '<span style="color:var(--muted)">—</span>';
html += `<tr class="${ip.ignored ? 'err-ip-ignored' : ''}">
<td><code class="err-ip">${ip.ip}</code>${ip.ignored ? ' <span class="err-ignored-tag">ignorée</span>' : ''}</td>
<td><span class="hits-badge ${ip.count > 10 ? 'hits-active' : 'hits-zero'}">${ip.count}</span></td>
<td class="col-date">${ip.last_seen}</td>
<td class="err-referers">${refHtml}</td>
<td class="col-actions">
<div class="row-actions">
<button type="button" class="btn-ignore-ip ${ip.ignored ? 'btn-ignore-ip--active' : ''}"
data-ip="${ip.ip}" data-action="${ip.ignored ? 'remove' : 'add'}"
title="${ip.ignored ? 'Retirer de la liste d\'ignorées' : 'Ignorer cette IP'}">
${ip.ignored ? '👁' : '🙈'}
</button>
<button type="button" class="btn-ban-ip" data-ip="${ip.ip}" title="Bannir dans fail2ban">🔨</button>
</div>
</td>
</tr>`;
}
html += '</tbody></table>';
container.innerHTML = html;
/* Ignore buttons */
container.querySelectorAll('.btn-ignore-ip').forEach(b => {
b.addEventListener('click', async () => {
const fd = new FormData();
fd.append('ip', b.dataset.ip);
fd.append('action', b.dataset.action);
const r = await fetch(IGNORE_URL, { method: 'POST', body: fd });
const j = await r.json();
if (j.ok) loadDetail(d.path, container);
});
});
/* Ban buttons */
container.querySelectorAll('.btn-ban-ip').forEach(b => {
b.addEventListener('click', async () => {
if (!confirm(`Bannir ${b.dataset.ip} dans fail2ban ?`)) return;
b.disabled = true;
b.textContent = '⏳';
const fd = new FormData();
fd.append('ip', b.dataset.ip);
const r = await fetch(BAN_URL, { method: 'POST', body: fd });
const j = await r.json();
if (j.ok) {
b.textContent = '✓';
b.style.color = '#16a34a';
} else {
b.textContent = '✗';
b.title = j.error || 'Erreur';
b.style.color = '#dc2626';
b.disabled = false;
alert('Erreur : ' + (j.error || 'voir console'));
}
});
});
}
/* ── Remove from ignored list (top section) ── */
document.querySelectorAll('.err-ignored-remove').forEach(b => {
b.addEventListener('click', async () => {
const fd = new FormData();
fd.append('ip', b.dataset.ip);
fd.append('action', 'remove');
await fetch(IGNORE_URL, { method: 'POST', body: fd });
location.reload();
});
});
/* ── Check status dots ── */
document.querySelectorAll('.err-status-dot').forEach(dot => {
dot.style.cursor = 'pointer';
dot.addEventListener('click', async () => {
dot.textContent = '○';
dot.className = 'err-status-dot';
const r = await fetch({{ url_for('errors_detail') | tojson }} + '?path=' + encodeURIComponent(dot.dataset.path))
.then(r => r.json());
dot.textContent = '●';
dot.className = 'err-status-dot err-status-dot--' + (r.still_404 ? 'active' : 'ok');
dot.title = r.still_404 ? 'Toujours actif' : 'Résolu';
});
});
function escHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function trimUrl(u) {
try { const p = new URL(u); return p.hostname + (p.pathname !== '/' ? p.pathname : ''); }
catch { return u.length > 40 ? u.slice(0,40)+'…' : u; }
}
})();
</script>
{% endblock %}