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:
parent
785a4639af
commit
259e8d5f3f
2 changed files with 137 additions and 69 deletions
72
app/app.py
72
app/app.py
|
|
@ -731,6 +731,36 @@ def _lookup_ip_asn(ip: str) -> dict:
|
|||
|
||||
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:
|
||||
"""Returns IPv4 CIDRs for an ASN via RIPE Stat. Cached 30 days."""
|
||||
_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)
|
||||
ignored = _load_ignored_ips()
|
||||
|
||||
# ── Onglet Bannissements ──
|
||||
# Count only — ASN lookup is deferred to /errors/banned-groups (AJAX)
|
||||
banned_ips, banned_nets = _get_banned_ips()
|
||||
all_banned = sorted(banned_ips) + sorted(str(n) for n in 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)
|
||||
banned_total = len(banned_ips) + len(banned_nets)
|
||||
|
||||
return render_template("errors_404.html",
|
||||
entries=entries,
|
||||
ignored_ips=sorted(ignored),
|
||||
total=sum(v["count"] for v in filtered.values()),
|
||||
log_configured=bool(STATS_LOG_FILE),
|
||||
banned_groups=banned_groups,
|
||||
banned_total=len(all_banned),
|
||||
banned_total=banned_total,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -1162,6 +1181,31 @@ def errors_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"])
|
||||
def errors_unban():
|
||||
global _BANNED_CACHE_TS
|
||||
|
|
|
|||
|
|
@ -81,11 +81,12 @@
|
|||
{% endif %}
|
||||
</div>{# /tab-errors #}
|
||||
|
||||
{# ─────────────── Onglet Bannissements ─────────────── #}
|
||||
{# ─────────────── Onglet Bannissements (chargé en AJAX) ─────────────── #}
|
||||
<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 %}
|
||||
<p id="ban-loading" style="color:var(--muted);margin-top:.5rem;font-style:italic">Chargement…</p>
|
||||
<p id="ban-empty" style="display:none;color:var(--muted);margin-top:.5rem">Aucune IP bannie dans global-blacklist.</p>
|
||||
<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">
|
||||
<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>
|
||||
|
|
@ -100,37 +101,9 @@
|
|||
<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>
|
||||
<tbody id="ban-tbody"></tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>{# /tab-banned #}
|
||||
|
||||
</section>
|
||||
|
|
@ -143,6 +116,7 @@
|
|||
const BAN_URL = {{ url_for('errors_ban') | tojson }};
|
||||
const ASINFO_URL = {{ url_for('errors_asinfo') | tojson }};
|
||||
const UNBAN_URL = {{ url_for('errors_unban') | tojson }};
|
||||
const BANNED_GROUPS_URL = {{ url_for('errors_banned_groups') | tojson }};
|
||||
|
||||
/* ── Onglets ── */
|
||||
const tabBtns = document.querySelectorAll('.sec-tab');
|
||||
|
|
@ -152,6 +126,7 @@
|
|||
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 : ''));
|
||||
if (name === 'banned') loadBannedGroups();
|
||||
}
|
||||
|
||||
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 ── */
|
||||
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() || '';
|
||||
|
|
|
|||
Loading…
Reference in a new issue