Perf : tab Bannis chargé en AJAX, batch SQL pour les ASN

La route /errors/ ne calculait plus les groupes ASN au chargement (N×SQL
pour chaque CIDR banni). Le tab Bannissements est désormais lazy-chargé via
/errors/banned-groups avec un unique SELECT ANY() en PostgreSQL.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Alpinux 2026-05-06 14:09:12 +02:00
parent 785a4639af
commit 259e8d5f3f
2 changed files with 137 additions and 69 deletions

View file

@ -731,6 +731,36 @@ def _lookup_ip_asn(ip: str) -> dict:
return result return result
def _batch_lookup_ip_asn(ips: list) -> dict:
"""Single SQL query for all IPs in cache. Falls back to individual lookup for misses (capped at 20)."""
if not ips:
return {}
unique = list(dict.fromkeys(ips))
result: dict = {}
with _pg() as conn:
if conn:
try:
with conn.cursor() as cur:
cur.execute(
"SELECT ip, asn, name, country FROM ip_asn_cache "
"WHERE ip = ANY(%s) AND fetched_at > now() - interval '30 days'",
(unique,)
)
for row in cur.fetchall():
result[row[0]] = {"asn": row[1], "name": row[2], "country": row[3]}
except Exception:
try: conn.rollback()
except: pass
misses = [ip for ip in unique if ip not in result]
for ip in misses[:20]:
result[ip] = _lookup_ip_asn(ip)
for ip in misses[20:]:
result.setdefault(ip, {"asn": "", "name": "", "country": ""})
return result
def _lookup_as_prefixes(asn: str) -> list: def _lookup_as_prefixes(asn: str) -> list:
"""Returns IPv4 CIDRs for an ASN via RIPE Stat. Cached 30 days.""" """Returns IPv4 CIDRs for an ASN via RIPE Stat. Cached 30 days."""
_AS_CACHE_DIR.mkdir(exist_ok=True) _AS_CACHE_DIR.mkdir(exist_ok=True)
@ -1034,27 +1064,16 @@ def errors_404():
entries = sorted(filtered.items(), key=lambda x: x[1]["count"], reverse=True) entries = sorted(filtered.items(), key=lambda x: x[1]["count"], reverse=True)
ignored = _load_ignored_ips() ignored = _load_ignored_ips()
# ── Onglet Bannissements ── # Count only — ASN lookup is deferred to /errors/banned-groups (AJAX)
banned_ips, banned_nets = _get_banned_ips() banned_ips, banned_nets = _get_banned_ips()
all_banned = sorted(banned_ips) + sorted(str(n) for n in banned_nets) banned_total = len(banned_ips) + len(banned_nets)
by_asn: dict = {}
for b_entry in all_banned:
rep_ip = b_entry.split("/")[0]
info = _lookup_ip_asn(rep_ip)
key = info.get("asn") or "?"
if key not in by_asn:
by_asn[key] = {"asn": info.get("asn", ""), "name": info.get("name", ""),
"country": info.get("country", ""), "entries": []}
by_asn[key]["entries"].append(b_entry)
banned_groups = sorted(by_asn.values(), key=lambda g: len(g["entries"]), reverse=True)
return render_template("errors_404.html", return render_template("errors_404.html",
entries=entries, entries=entries,
ignored_ips=sorted(ignored), ignored_ips=sorted(ignored),
total=sum(v["count"] for v in filtered.values()), total=sum(v["count"] for v in filtered.values()),
log_configured=bool(STATS_LOG_FILE), log_configured=bool(STATS_LOG_FILE),
banned_groups=banned_groups, banned_total=banned_total,
banned_total=len(all_banned),
) )
@ -1162,6 +1181,31 @@ def errors_banned():
return redirect(url_for('errors_404') + '#banned') return redirect(url_for('errors_404') + '#banned')
@app.route("/errors/banned-groups")
def errors_banned_groups():
redir = _require_admin()
if redir:
return jsonify({"error": "not authorized"}), 403
banned_ips, banned_nets = _get_banned_ips()
all_banned = sorted(banned_ips) + sorted(str(n) for n in banned_nets)
rep_ips = [b.split("/")[0] for b in all_banned]
asn_map = _batch_lookup_ip_asn(rep_ips)
by_asn: dict = {}
for b_entry, rep_ip in zip(all_banned, rep_ips):
info = asn_map.get(rep_ip, {})
key = info.get("asn") or "?"
if key not in by_asn:
by_asn[key] = {"asn": info.get("asn", ""), "name": info.get("name", ""),
"country": info.get("country", ""), "entries": []}
by_asn[key]["entries"].append(b_entry)
groups = sorted(by_asn.values(), key=lambda g: len(g["entries"]), reverse=True)
return jsonify({"groups": groups, "total": len(all_banned)})
@app.route("/errors/unban", methods=["POST"]) @app.route("/errors/unban", methods=["POST"])
def errors_unban(): def errors_unban():
global _BANNED_CACHE_TS global _BANNED_CACHE_TS

View file

@ -81,11 +81,12 @@
{% endif %} {% endif %}
</div>{# /tab-errors #} </div>{# /tab-errors #}
{# ─────────────── Onglet Bannissements ─────────────── #} {# ─────────────── Onglet Bannissements (chargé en AJAX) ─────────────── #}
<div id="tab-banned" class="sec-tab-panel" style="display:none"> <div id="tab-banned" class="sec-tab-panel" style="display:none">
{% if not banned_groups %} <p id="ban-loading" style="color:var(--muted);margin-top:.5rem;font-style:italic">Chargement…</p>
<p style="color:var(--muted);margin-top:.5rem">Aucune IP bannie dans global-blacklist.</p> <p id="ban-empty" style="display:none;color:var(--muted);margin-top:.5rem">Aucune IP bannie dans global-blacklist.</p>
{% else %} <p id="ban-error" style="display:none;color:#b91c1c;margin-top:.5rem">Erreur lors du chargement des bannissements.</p>
<div id="ban-content" style="display:none">
<div class="err-search-wrap" style="margin-top:.5rem"> <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"> <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> <span class="err-search-count" id="ban-search-count"></span>
@ -100,37 +101,9 @@
<th class="col-actions"></th> <th class="col-actions"></th>
</tr> </tr>
</thead> </thead>
<tbody id="ban-tbody"> <tbody id="ban-tbody"></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> </table>
{% endif %} </div>
</div>{# /tab-banned #} </div>{# /tab-banned #}
</section> </section>
@ -143,6 +116,7 @@
const BAN_URL = {{ url_for('errors_ban') | tojson }}; const BAN_URL = {{ url_for('errors_ban') | tojson }};
const ASINFO_URL = {{ url_for('errors_asinfo') | tojson }}; const ASINFO_URL = {{ url_for('errors_asinfo') | tojson }};
const UNBAN_URL = {{ url_for('errors_unban') | tojson }}; const UNBAN_URL = {{ url_for('errors_unban') | tojson }};
const BANNED_GROUPS_URL = {{ url_for('errors_banned_groups') | tojson }};
/* ── Onglets ── */ /* ── Onglets ── */
const tabBtns = document.querySelectorAll('.sec-tab'); const tabBtns = document.querySelectorAll('.sec-tab');
@ -152,6 +126,7 @@
tabBtns.forEach(b => b.classList.toggle('sec-tab--active', b.dataset.tab === 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'); tabPanels.forEach(p => p.style.display = p.id === 'tab-' + name ? '' : 'none');
history.replaceState(null, '', location.pathname + (name !== 'errors' ? '#' + name : '')); history.replaceState(null, '', location.pathname + (name !== 'errors' ? '#' + name : ''));
if (name === 'banned') loadBannedGroups();
} }
tabBtns.forEach(b => b.addEventListener('click', () => activateTab(b.dataset.tab))); tabBtns.forEach(b => b.addEventListener('click', () => activateTab(b.dataset.tab)));
@ -301,13 +276,62 @@
}); });
/* ═══════════════════════════════════════════════ /* ═══════════════════════════════════════════════
ONGLET BANNISSEMENTS ONGLET BANNISSEMENTS (chargement AJAX)
═══════════════════════════════════════════════ */ ═══════════════════════════════════════════════ */
let bannedLoaded = false;
let banTotal = {{ banned_total }};
async function loadBannedGroups() {
if (bannedLoaded) return;
bannedLoaded = true;
try {
const data = await fetch(BANNED_GROUPS_URL).then(r => r.json());
renderBannedGroups(data);
} catch {
document.getElementById('ban-loading').style.display = 'none';
document.getElementById('ban-error').style.display = '';
bannedLoaded = false;
}
}
function renderBannedGroups(data) {
document.getElementById('ban-loading').style.display = 'none';
if (!data.groups || data.groups.length === 0) {
document.getElementById('ban-empty').style.display = '';
return;
}
banTotal = data.total;
document.getElementById('banned-tab-badge').textContent = data.total;
let html = '';
for (const g of data.groups) {
const asnLabel = g.asn ? `<strong>AS${escHtml(g.asn)}</strong>` : '<strong>AS inconnu</strong>';
const n = g.entries.length;
html += `<tr class="banned-as-row" data-asn="${escHtml(g.asn)}" data-name="${escHtml((g.name||'').toLowerCase())}">
<td colspan="4" class="banned-as-cell">
<span class="banned-as-label">${asnLabel}${g.name ? ' · '+escHtml(g.name) : ''}${g.country ? ' <span class="banned-country">['+escHtml(g.country)+']</span>' : ''}</span>
<span class="err-total-badge" style="margin-left:.5rem">${n} entrée${n!==1?'s':''}</span>
</td>
<td class="col-actions">
<button type="button" class="btn-unban-as btn btn-sm" data-asn="${escHtml(g.asn)}" data-entries="${escHtml(JSON.stringify(g.entries))}" title="Débloquer tout l'AS">🔓 AS</button>
</td>
</tr>`;
for (const entry of g.entries) {
html += `<tr class="banned-entry-row" data-asn="${escHtml(g.asn)}" data-entry="${escHtml(entry)}">
<td colspan="4"><code class="err-ip">${escHtml(entry)}</code></td>
<td class="col-actions">
<button type="button" class="btn-unban btn btn-sm" data-entry="${escHtml(entry)}" title="Débloquer ${escHtml(entry)}">🔓</button>
</td>
</tr>`;
}
}
document.getElementById('ban-tbody').innerHTML = html;
document.getElementById('ban-content').style.display = '';
}
/* ── Filtre bannis ── */ /* ── Filtre bannis ── */
const banSearch = document.getElementById('ban-search'); const banSearch = document.getElementById('ban-search');
const banCount = document.getElementById('ban-search-count'); const banCount = document.getElementById('ban-search-count');
const banTotal = {{ banned_total }};
function applyBanFilter() { function applyBanFilter() {
const q = banSearch?.value.trim().toLowerCase() || ''; const q = banSearch?.value.trim().toLowerCase() || '';