alpinux-static/app/templates/errors_404.html
Alpinux 785a4639af v1.8.0 — Fusion pages Bannis et Erreurs en un seul onglet
La page /errors/ intègre désormais deux onglets (Erreurs 404 et Bannissements)
activés via hash URL (#errors / #banned). Le lien "Bannis" disparaît de la nav,
la route /errors/banned/ redirige vers /errors/#banned.

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

381 lines
18 KiB
HTML

{% extends "base.html" %}
{% block title %}Erreurs & Bannis{% endblock %}
{% block content %}
<section class="card">
<div class="err-header">
<h2>Sécurité</h2>
<div class="sec-tabs">
<button type="button" class="sec-tab" data-tab="errors">
Erreurs 404 <span class="tab-badge">{{ total }}</span>
</button>
<button type="button" class="sec-tab" data-tab="banned">
Bannissements <span class="tab-badge" id="banned-tab-badge">{{ banned_total }}</span>
</button>
</div>
<button type="button" class="btn btn-sm" onclick="location.reload()">↺ Actualiser</button>
</div>
{# ─────────────── Onglet Erreurs 404 ─────────────── #}
<div id="tab-errors" class="sec-tab-panel">
{% if not log_configured %}
<p style="color:var(--muted);margin-top:.5rem">STATS_LOG_FILE non configuré — impossible d'analyser les logs.</p>
{% elif 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 %}
{% if ignored_ips %}
<div style="margin-top:1.25rem">
<h3 style="font-size:.9rem;margin-bottom:.5rem;color:var(--muted)">IPs ignorées</h3>
<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>
</div>
{% endif %}
</div>{# /tab-errors #}
{# ─────────────── Onglet Bannissements ─────────────── #}
<div id="tab-banned" class="sec-tab-panel" style="display:none">
{% if not banned_groups %}
<p style="color:var(--muted);margin-top:.5rem">Aucune IP bannie dans global-blacklist.</p>
{% else %}
<div class="err-search-wrap" style="margin-top:.5rem">
<input type="search" id="ban-search" class="err-search" placeholder="Filtrer par IP, CIDR ou nom d'AS…" autocomplete="off">
<span class="err-search-count" id="ban-search-count"></span>
</div>
<table class="file-table banned-table">
<thead>
<tr>
<th>IP / CIDR</th>
<th class="col-asn">AS</th>
<th>Opérateur</th>
<th class="col-country">Pays</th>
<th class="col-actions"></th>
</tr>
</thead>
<tbody id="ban-tbody">
{% for group in banned_groups %}
<tr class="banned-as-row" data-asn="{{ group.asn }}" data-name="{{ group.name | lower }}">
<td colspan="4" class="banned-as-cell">
<span class="banned-as-label">
{% if group.asn %}<strong>AS{{ group.asn }}</strong>{% else %}<strong>AS inconnu</strong>{% endif %}
{% if group.name %} · {{ group.name }}{% endif %}
{% if group.country %} <span class="banned-country">[{{ group.country }}]</span>{% endif %}
</span>
<span class="err-total-badge" style="margin-left:.5rem">{{ group.entries|length }} entrée{{ 's' if group.entries|length != 1 }}</span>
</td>
<td class="col-actions">
<button type="button" class="btn-unban-as btn btn-sm"
data-asn="{{ group.asn }}"
data-entries="{{ group.entries | tojson | e }}"
title="Débloquer tout l'AS">🔓 AS</button>
</td>
</tr>
{% for entry in group.entries %}
<tr class="banned-entry-row" data-asn="{{ group.asn }}" data-entry="{{ entry }}">
<td colspan="4"><code class="err-ip">{{ entry }}</code></td>
<td class="col-actions">
<button type="button" class="btn-unban btn btn-sm"
data-entry="{{ entry }}" title="Débloquer {{ entry }}">🔓</button>
</td>
</tr>
{% endfor %}
{% endfor %}
</tbody>
</table>
{% endif %}
</div>{# /tab-banned #}
</section>
<script>
(function () {
/* ── URLs ── */
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 }};
const UNBAN_URL = {{ url_for('errors_unban') | tojson }};
/* ── Onglets ── */
const tabBtns = document.querySelectorAll('.sec-tab');
const tabPanels = document.querySelectorAll('.sec-tab-panel');
function activateTab(name) {
tabBtns.forEach(b => b.classList.toggle('sec-tab--active', b.dataset.tab === name));
tabPanels.forEach(p => p.style.display = p.id === 'tab-' + name ? '' : 'none');
history.replaceState(null, '', location.pathname + (name !== 'errors' ? '#' + name : ''));
}
tabBtns.forEach(b => b.addEventListener('click', () => activateTab(b.dataset.tab)));
activateTab(location.hash === '#banned' ? 'banned' : 'errors');
/* ═══════════════════════════════════════════════
ONGLET ERREURS 404
═══════════════════════════════════════════════ */
/* ── Filtre erreurs ── */
const errSearch = document.getElementById('err-search');
const errCount = document.getElementById('err-search-count');
const errRows = [...document.querySelectorAll('#tab-errors .err-row')];
function applyErrFilter() {
const q = errSearch?.value.trim().toLowerCase() || '';
let vis = 0;
errRows.forEach(row => {
const match = !q || row.dataset.path.toLowerCase().includes(q) || row.dataset.ips.toLowerCase().includes(q);
const dr = row.nextElementSibling;
row.style.display = match ? '' : 'none';
if (!match && dr?.classList.contains('err-detail-row')) {
dr.style.display = 'none';
row.querySelector('.btn-detail').textContent = '▼';
if (openDetailRow === dr) openDetailRow = null;
}
if (match) vis++;
});
if (errCount) errCount.textContent = q ? `${vis} / ${errRows.length}` : '';
}
errSearch?.addEventListener('input', applyErrFilter);
/* ── Expand/collapse détail ── */
let openDetailRow = null;
document.querySelectorAll('.btn-detail').forEach(btn => {
btn.addEventListener('click', () => {
const detailRow = btn.closest('tr').nextElementSibling;
const content = detailRow.querySelector('.err-detail-content');
const open = detailRow.style.display !== 'none';
if (openDetailRow && openDetailRow !== detailRow) {
openDetailRow.style.display = 'none';
openDetailRow.previousElementSibling.querySelector('.btn-detail').textContent = '▼';
}
if (open) { detailRow.style.display = 'none'; btn.textContent = '▼'; openDetailRow = null; return; }
detailRow.style.display = ''; btn.textContent = '▲'; openDetailRow = detailRow;
loadDetail(btn.dataset.path, content);
});
});
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 des 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;
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 j = await fetch(IGNORE_URL, { method: 'POST', body: fd }).then(r => r.json());
if (j.ok) loadDetail(d.path, container);
});
});
container.querySelectorAll('.btn-ban-ip').forEach(b => {
b.addEventListener('click', async () => {
const ip = b.dataset.ip;
const div = 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;
div.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)}">🔨 IP</button>`
+ (n ? `<button class="btn-do-ban btn-do-ban--as" data-ban-as="1" data-ip="${escHtml(ip)}" data-count="${n}">🔨 AS (${n})</button>` : '')
+ `<button class="btn-ban-cancel">✕</button></div></div>`;
div.querySelector('.btn-ban-cancel').addEventListener('click', () => loadDetail(d.path, container));
div.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 ?`)) return;
div.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 j = await fetch(BAN_URL, { method: 'POST', body: fd }).then(r => r.json());
if (j.ok) {
div.innerHTML = `<span class="ban-ok">${banAs ? '✓ AS banni (' + j.count + ' préfixes)' : '✓ Banni'}</span>`;
setTimeout(() => loadDetail(d.path, container), 1500);
} else {
div.innerHTML = `<span class="ban-err" title="${escHtml(j.error||'')}">✗ ${escHtml(j.error||'Erreur')}</span>`;
}
});
});
});
});
}
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();
});
});
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(DETAIL_URL + '?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';
});
});
/* ═══════════════════════════════════════════════
ONGLET BANNISSEMENTS
═══════════════════════════════════════════════ */
/* ── Filtre bannis ── */
const banSearch = document.getElementById('ban-search');
const banCount = document.getElementById('ban-search-count');
const banTotal = {{ banned_total }};
function applyBanFilter() {
const q = banSearch?.value.trim().toLowerCase() || '';
let vis = 0;
document.querySelectorAll('#tab-banned .banned-as-row').forEach(asRow => {
const asn = (asRow.dataset.asn || '').toLowerCase();
const name = (asRow.dataset.name || '').toLowerCase();
const rows = [...document.querySelectorAll(`#tab-banned .banned-entry-row[data-asn="${asRow.dataset.asn}"]`)];
let any = false;
rows.forEach(er => {
const match = !q || er.dataset.entry.toLowerCase().includes(q) || asn.includes(q) || name.includes(q);
er.style.display = match ? '' : 'none';
if (match) { any = true; vis++; }
});
asRow.style.display = (!q || any) ? '' : 'none';
});
if (banCount) banCount.textContent = q ? `${vis} / ${banTotal}` : '';
}
banSearch?.addEventListener('input', applyBanFilter);
/* ── Débloquer IP/CIDR ── */
document.addEventListener('click', async e => {
const btn = e.target.closest('.btn-unban:not(.btn-unban-as)');
if (!btn) return;
if (!confirm(`Débloquer ${btn.dataset.entry} ?`)) return;
await doUnban(btn, [btn.dataset.entry]);
});
/* ── Débloquer AS entier ── */
document.addEventListener('click', async e => {
const btn = e.target.closest('.btn-unban-as');
if (!btn) return;
let entries; try { entries = JSON.parse(btn.dataset.entries); } catch { return; }
if (!confirm(`Débloquer les ${entries.length} entrée(s) de cet AS ?`)) return;
await doUnban(btn, entries, btn.dataset.asn);
});
async function doUnban(btn, entries, asn) {
btn.disabled = true; btn.textContent = '⏳';
const fd = new FormData(); entries.forEach(e => fd.append('entries', e));
try {
const j = await fetch(UNBAN_URL, { method: 'POST', body: fd }).then(r => r.json());
if (j.ok) {
if (asn !== undefined) {
document.querySelectorAll(`[data-asn="${asn}"]`).forEach(row => row.remove());
} else {
entries.forEach(entry => document.querySelector(`.banned-entry-row[data-entry="${CSS.escape(entry)}"]`)?.remove());
document.querySelectorAll('#tab-banned .banned-as-row').forEach(ar => {
if (!document.querySelector(`.banned-entry-row[data-asn="${ar.dataset.asn}"]`)) ar.remove();
});
}
const rem = document.querySelectorAll('#tab-banned .banned-entry-row').length;
document.getElementById('banned-tab-badge').textContent = rem;
} else {
btn.disabled = false; btn.textContent = asn !== undefined ? '🔓 AS' : '🔓';
alert('Erreur : ' + (j.error || 'voir console'));
}
} catch { btn.disabled = false; btn.textContent = asn !== undefined ? '🔓 AS' : '🔓'; }
}
/* ── Utilitaires ── */
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 %}