- Clic 🔨 : lookup AS via ip-api.com, propose 🔨 IP ou 🔨 AS (N préfixes) - Ban AS : récupère les CIDRs via RIPE Stat, cache 30 j dans as_cache/ - IPs déjà bannies (global-blacklist) masquées du tableau et du détail AJAX - ignoreip fail2ban : 82.65.88.34 protégée sur toutes les jails - Sudoers : permission status global-blacklist pour static-cdn Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
296 lines
12 KiB
HTML
296 lines
12 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 }};
|
|
const ASINFO_URL = {{ url_for('errors_asinfo') | 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 — 2 étapes : affiche l'AS puis propose IP seule ou AS entier */
|
|
container.querySelectorAll('.btn-ban-ip').forEach(b => {
|
|
b.addEventListener('click', async () => {
|
|
const ip = b.dataset.ip;
|
|
const actionsDiv = b.closest('.row-actions');
|
|
b.disabled = true; b.textContent = '⏳';
|
|
|
|
let asInfo = {};
|
|
try {
|
|
asInfo = await fetch(ASINFO_URL + '?ip=' + encodeURIComponent(ip)).then(r => r.json());
|
|
} catch (_) {}
|
|
|
|
const asLabel = asInfo.asn
|
|
? `AS${asInfo.asn} · ${escHtml(asInfo.name)}${asInfo.country ? ' [' + asInfo.country + ']' : ''}`
|
|
: '<span style="color:var(--muted)">AS inconnu</span>';
|
|
const n = asInfo.prefix_count || 0;
|
|
|
|
actionsDiv.innerHTML =
|
|
`<div class="ban-panel">
|
|
<span class="ban-panel-as">${asLabel}</span>
|
|
<div class="ban-panel-btns">
|
|
<button class="btn-do-ban" data-ban-as="0" data-ip="${escHtml(ip)}" title="Bannir cette IP uniquement">🔨 IP</button>`
|
|
+ (n ? `<button class="btn-do-ban btn-do-ban--as" data-ban-as="1" data-ip="${escHtml(ip)}" data-count="${n}" title="Bannir les ${n} préfixes de cet AS">🔨 AS (${n})</button>` : '')
|
|
+ `<button class="btn-ban-cancel" title="Annuler">✕</button>
|
|
</div>
|
|
</div>`;
|
|
|
|
actionsDiv.querySelector('.btn-ban-cancel').addEventListener('click', () => {
|
|
loadDetail(d.path, container);
|
|
});
|
|
|
|
actionsDiv.querySelectorAll('.btn-do-ban').forEach(btn => {
|
|
btn.addEventListener('click', async () => {
|
|
const banAs = btn.dataset.banAs === '1';
|
|
if (banAs && !confirm(`Bannir les ${btn.dataset.count} préfixes de cet AS ?\nToutes les IPs de cet AS seront bloquées.`)) return;
|
|
actionsDiv.querySelectorAll('button').forEach(x => x.disabled = true);
|
|
const fd = new FormData();
|
|
fd.append('ip', btn.dataset.ip);
|
|
if (banAs) fd.append('ban_as', '1');
|
|
const r = await fetch(BAN_URL, { method: 'POST', body: fd });
|
|
const j = await r.json();
|
|
if (j.ok) {
|
|
const label = banAs ? `✓ AS banni (${j.count} préfixes)` : '✓ Banni';
|
|
actionsDiv.innerHTML = `<span class="ban-ok">${label}</span>`;
|
|
setTimeout(() => loadDetail(d.path, container), 1500);
|
|
} else {
|
|
actionsDiv.innerHTML = `<span class="ban-err" title="${escHtml(j.error||'')}">✗ ${escHtml(j.error||'Erreur')}</span>`;
|
|
}
|
|
});
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
/* ── 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
}
|
|
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 %}
|