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
|
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
|
||||||
|
|
|
||||||
|
|
@ -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() || '';
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue