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>
This commit is contained in:
parent
5ff97d50b5
commit
785a4639af
6 changed files with 319 additions and 218 deletions
|
|
@ -1,5 +1,15 @@
|
||||||
# Changelog — Alpinux Static
|
# Changelog — Alpinux Static
|
||||||
|
|
||||||
|
## [1.8.0] — 2026-05-06
|
||||||
|
|
||||||
|
### Modifié
|
||||||
|
- Fusion des pages **Erreurs 404** et **Bannis** en une seule page avec deux onglets (URL `/errors/`)
|
||||||
|
- L'onglet actif est mémorisé dans le hash d'URL (`#errors` / `#banned`)
|
||||||
|
- Suppression du lien « Bannis » dans la navigation (accessible via l'onglet de la page Erreurs)
|
||||||
|
- Route `/errors/banned/` redirige vers `/errors/#banned`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [1.7.0] — 2026-05-06
|
## [1.7.0] — 2026-05-06
|
||||||
|
|
||||||
### Ajouté
|
### Ajouté
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
1.7.0
|
1.8.0
|
||||||
|
|
|
||||||
42
app/app.py
42
app/app.py
|
|
@ -1023,6 +1023,8 @@ def errors_404():
|
||||||
redir = _require_admin()
|
redir = _require_admin()
|
||||||
if redir:
|
if redir:
|
||||||
return redir
|
return redir
|
||||||
|
|
||||||
|
# ── Onglet Erreurs 404 ──
|
||||||
data = _parse_404s()
|
data = _parse_404s()
|
||||||
filtered = {}
|
filtered = {}
|
||||||
for path, info in data.items():
|
for path, info in data.items():
|
||||||
|
|
@ -1031,11 +1033,28 @@ def errors_404():
|
||||||
filtered[path] = entry
|
filtered[path] = entry
|
||||||
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 ──
|
||||||
|
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)
|
||||||
|
|
||||||
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=len(all_banned),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1140,28 +1159,7 @@ def errors_ban():
|
||||||
|
|
||||||
@app.route("/errors/banned/")
|
@app.route("/errors/banned/")
|
||||||
def errors_banned():
|
def errors_banned():
|
||||||
redir = _require_admin()
|
return redirect(url_for('errors_404') + '#banned')
|
||||||
if redir:
|
|
||||||
return redir
|
|
||||||
banned_ips, banned_nets = _get_banned_ips()
|
|
||||||
all_entries = sorted(banned_ips) + sorted(str(n) for n in banned_nets)
|
|
||||||
|
|
||||||
by_asn: dict = {}
|
|
||||||
for entry in all_entries:
|
|
||||||
rep_ip = 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(entry)
|
|
||||||
|
|
||||||
groups = sorted(by_asn.values(), key=lambda g: len(g["entries"]), reverse=True)
|
|
||||||
return render_template("banned.html", groups=groups, total=len(all_entries))
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/errors/unban", methods=["POST"])
|
@app.route("/errors/unban", methods=["POST"])
|
||||||
|
|
|
||||||
|
|
@ -391,3 +391,13 @@ footer { background: var(--blue-dark); color: rgba(255,255,255,.6); margin-top:
|
||||||
.btn-unban { font-size: .75rem; padding: .2rem .5rem; background: #f0fdf4; color: #15803d; border: 1px solid #86efac; border-radius: 5px; cursor: pointer; }
|
.btn-unban { font-size: .75rem; padding: .2rem .5rem; background: #f0fdf4; color: #15803d; border: 1px solid #86efac; border-radius: 5px; cursor: pointer; }
|
||||||
.btn-unban:hover:not(:disabled) { filter: brightness(.92); }
|
.btn-unban:hover:not(:disabled) { filter: brightness(.92); }
|
||||||
.btn-unban-as { background: #fefce8; color: #854d0e; border-color: #fde68a; }
|
.btn-unban-as { background: #fefce8; color: #854d0e; border-color: #fde68a; }
|
||||||
|
|
||||||
|
/* ── Section tabs (Erreurs / Bannis) ───────────────────────────── */
|
||||||
|
.err-header { display: flex; align-items: center; gap: 1rem; margin-bottom: 1rem; flex-wrap: wrap; }
|
||||||
|
.err-header h2 { margin: 0; }
|
||||||
|
.sec-tabs { display: flex; gap: .25rem; }
|
||||||
|
.sec-tab { background: none; border: 1px solid var(--border); border-radius: 6px; padding: .35rem .9rem; font-size: .88rem; cursor: pointer; color: var(--muted); transition: background .15s, color .15s; }
|
||||||
|
.sec-tab:hover { background: var(--blue-light); color: var(--blue); }
|
||||||
|
.sec-tab--active { background: var(--blue); color: #fff; border-color: var(--blue); font-weight: 600; }
|
||||||
|
.tab-badge { display: inline-flex; align-items: center; justify-content: center; background: rgba(255,255,255,.25); border-radius: 9px; font-size: .72rem; font-weight: 700; min-width: 1.2rem; padding: 0 .3rem; margin-left: .3rem; vertical-align: middle; }
|
||||||
|
.sec-tab:not(.sec-tab--active) .tab-badge { background: #e5e7eb; color: #6b7280; }
|
||||||
|
|
|
||||||
|
|
@ -25,9 +25,7 @@
|
||||||
{% if request.endpoint in ('stats', 'stats_report') %}class="active"{% endif %}>Statistiques</a>
|
{% if request.endpoint in ('stats', 'stats_report') %}class="active"{% endif %}>Statistiques</a>
|
||||||
<a href="{{ url_for('errors_404') }}"
|
<a href="{{ url_for('errors_404') }}"
|
||||||
{% if request.endpoint == 'errors_404' %}class="active"{% endif %}>Erreurs</a>
|
{% if request.endpoint == 'errors_404' %}class="active"{% endif %}>Erreurs</a>
|
||||||
<a href="{{ url_for('errors_banned') }}"
|
<a href="{{ url_for('trash_list') }}"
|
||||||
{% if request.endpoint == 'errors_banned' %}class="active"{% endif %}>Bannis</a>
|
|
||||||
<a href="{{ url_for('trash_list') }}"
|
|
||||||
class="nav-trash{% if request.endpoint == 'trash_list' %} active{% endif %}">Corbeille{% if trash_count %}<span class="trash-badge">{{ trash_count }}</span>{% endif %}</a>
|
class="nav-trash{% if request.endpoint == 'trash_list' %} active{% endif %}">Corbeille{% if trash_count %}<span class="trash-badge">{{ trash_count }}</span>{% endif %}</a>
|
||||||
</nav>
|
</nav>
|
||||||
<form class="header-search" action="{{ url_for('search') }}" method="get" role="search">
|
<form class="header-search" action="{{ url_for('search') }}" method="get" role="search">
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,28 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Erreurs 404{% endblock %}
|
{% block title %}Erreurs & Bannis{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
{% if not log_configured %}
|
|
||||||
<section class="card">
|
<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">
|
<div class="err-header">
|
||||||
<h2>Erreurs 404 <span class="err-total-badge">{{ total }} requêtes</span></h2>
|
<h2>Sécurité</h2>
|
||||||
<button type="button" class="btn btn-sm" id="refresh-btn" onclick="location.reload()">↺ Actualiser</button>
|
<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>
|
</div>
|
||||||
|
|
||||||
{% if not entries %}
|
{# ─────────────── Onglet Erreurs 404 ─────────────── #}
|
||||||
<p style="color:var(--muted); margin-top:.5rem">Aucune erreur 404 dans les logs récents.</p>
|
<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 %}
|
{% else %}
|
||||||
<div class="err-search-wrap">
|
<div class="err-search-wrap">
|
||||||
<input type="search" id="err-search" class="err-search" placeholder="Filtrer par chemin ou adresse IP…" autocomplete="off">
|
<input type="search" id="err-search" class="err-search" placeholder="Filtrer par chemin ou adresse IP…" autocomplete="off">
|
||||||
|
|
@ -38,8 +44,7 @@
|
||||||
<tr class="err-row" data-path="{{ path }}" data-ips="{{ info.ips.keys() | join(' ') }}">
|
<tr class="err-row" data-path="{{ path }}" data-ips="{{ info.ips.keys() | join(' ') }}">
|
||||||
<td class="col-err-status">
|
<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 %}"
|
<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 }}"
|
data-path="{{ path }}" title="Cliquer pour vérifier">●</span>
|
||||||
title="Cliquer pour vérifier">●</span>
|
|
||||||
</td>
|
</td>
|
||||||
<td class="err-path" title="{{ path }}"><code>{{ path }}</code></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-err-count"><span class="hits-badge hits-active">{{ info.count }}</span></td>
|
||||||
|
|
@ -60,11 +65,10 @@
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</section>
|
|
||||||
|
|
||||||
{% if ignored_ips %}
|
{% if ignored_ips %}
|
||||||
<section class="card">
|
<div style="margin-top:1.25rem">
|
||||||
<h2>IPs ignorées</h2>
|
<h3 style="font-size:.9rem;margin-bottom:.5rem;color:var(--muted)">IPs ignorées</h3>
|
||||||
<div class="err-ignored-list">
|
<div class="err-ignored-list">
|
||||||
{% for ip in ignored_ips %}
|
{% for ip in ignored_ips %}
|
||||||
<span class="err-ignored-chip">
|
<span class="err-ignored-chip">
|
||||||
|
|
@ -73,74 +77,127 @@
|
||||||
</span>
|
</span>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
</div>{# /tab-errors #}
|
||||||
|
|
||||||
{% endif %}
|
{# ─────────────── 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>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
|
/* ── URLs ── */
|
||||||
const DETAIL_URL = {{ url_for('errors_detail') | tojson }};
|
const DETAIL_URL = {{ url_for('errors_detail') | tojson }};
|
||||||
const IGNORE_URL = {{ url_for('errors_ignore') | tojson }};
|
const IGNORE_URL = {{ url_for('errors_ignore') | tojson }};
|
||||||
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 }};
|
||||||
|
|
||||||
/* ── Search / filter ── */
|
/* ── Onglets ── */
|
||||||
const searchInput = document.getElementById('err-search');
|
const tabBtns = document.querySelectorAll('.sec-tab');
|
||||||
const searchCount = document.getElementById('err-search-count');
|
const tabPanels = document.querySelectorAll('.sec-tab-panel');
|
||||||
const allRows = [...document.querySelectorAll('.err-row')];
|
|
||||||
|
|
||||||
function applyFilter() {
|
function activateTab(name) {
|
||||||
const q = searchInput.value.trim().toLowerCase();
|
tabBtns.forEach(b => b.classList.toggle('sec-tab--active', b.dataset.tab === name));
|
||||||
let visible = 0;
|
tabPanels.forEach(p => p.style.display = p.id === 'tab-' + name ? '' : 'none');
|
||||||
allRows.forEach(row => {
|
history.replaceState(null, '', location.pathname + (name !== 'errors' ? '#' + name : ''));
|
||||||
const match = !q
|
}
|
||||||
|| row.dataset.path.toLowerCase().includes(q)
|
|
||||||
|| row.dataset.ips.toLowerCase().includes(q);
|
tabBtns.forEach(b => b.addEventListener('click', () => activateTab(b.dataset.tab)));
|
||||||
const detailRow = row.nextElementSibling;
|
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';
|
row.style.display = match ? '' : 'none';
|
||||||
if (!match && detailRow && detailRow.classList.contains('err-detail-row')) {
|
if (!match && dr?.classList.contains('err-detail-row')) {
|
||||||
detailRow.style.display = 'none';
|
dr.style.display = 'none';
|
||||||
row.querySelector('.btn-detail').textContent = '▼';
|
row.querySelector('.btn-detail').textContent = '▼';
|
||||||
if (openRow === detailRow) openRow = null;
|
if (openDetailRow === dr) openDetailRow = null;
|
||||||
}
|
}
|
||||||
if (match) visible++;
|
if (match) vis++;
|
||||||
});
|
});
|
||||||
searchCount.textContent = q ? `${visible} / ${allRows.length}` : '';
|
if (errCount) errCount.textContent = q ? `${vis} / ${errRows.length}` : '';
|
||||||
}
|
}
|
||||||
|
errSearch?.addEventListener('input', applyErrFilter);
|
||||||
|
|
||||||
if (searchInput) searchInput.addEventListener('input', applyFilter);
|
/* ── Expand/collapse détail ── */
|
||||||
|
let openDetailRow = null;
|
||||||
/* ── Expand/collapse detail ── */
|
|
||||||
let openRow = null;
|
|
||||||
|
|
||||||
document.querySelectorAll('.btn-detail').forEach(btn => {
|
document.querySelectorAll('.btn-detail').forEach(btn => {
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
const path = btn.dataset.path;
|
const detailRow = btn.closest('tr').nextElementSibling;
|
||||||
const row = btn.closest('tr');
|
const content = detailRow.querySelector('.err-detail-content');
|
||||||
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';
|
const open = detailRow.style.display !== 'none';
|
||||||
|
if (openDetailRow && openDetailRow !== detailRow) {
|
||||||
if (openRow && openRow !== detailRow) {
|
openDetailRow.style.display = 'none';
|
||||||
openRow.style.display = 'none';
|
openDetailRow.previousElementSibling.querySelector('.btn-detail').textContent = '▼';
|
||||||
openRow.previousElementSibling.querySelector('.btn-detail').textContent = '▼';
|
|
||||||
}
|
}
|
||||||
|
if (open) { detailRow.style.display = 'none'; btn.textContent = '▼'; openDetailRow = null; return; }
|
||||||
if (open) {
|
detailRow.style.display = ''; btn.textContent = '▲'; openDetailRow = detailRow;
|
||||||
detailRow.style.display = 'none';
|
loadDetail(btn.dataset.path, content);
|
||||||
btn.textContent = '▼';
|
|
||||||
openRow = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
detailRow.style.display = '';
|
|
||||||
btn.textContent = '▲';
|
|
||||||
openRow = detailRow;
|
|
||||||
loadDetail(path, detailContent);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -149,109 +206,74 @@
|
||||||
try {
|
try {
|
||||||
const d = await fetch(DETAIL_URL + '?path=' + encodeURIComponent(path)).then(r => r.json());
|
const d = await fetch(DETAIL_URL + '?path=' + encodeURIComponent(path)).then(r => r.json());
|
||||||
renderDetail(d, container);
|
renderDetail(d, container);
|
||||||
} catch (_) {
|
} catch { container.innerHTML = '<span style="color:#b91c1c">Erreur réseau.</span>'; }
|
||||||
container.innerHTML = '<span style="color:#b91c1c">Erreur réseau.</span>';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderDetail(d, container) {
|
function renderDetail(d, container) {
|
||||||
const statusBadge = d.still_404
|
const statusBadge = d.still_404
|
||||||
? '<span class="err-badge err-badge--active">✗ Toujours actif</span>'
|
? '<span class="err-badge err-badge--active">✗ Toujours actif</span>'
|
||||||
: '<span class="err-badge err-badge--ok">✓ Résolu</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>`;
|
||||||
let html = `<div class="err-detail-meta">${statusBadge}
|
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>';
|
||||||
<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) {
|
for (const ip of d.ips) {
|
||||||
const referers = [...new Set(ip.hits.map(h => h.referer).filter(Boolean))];
|
const referers = [...new Set(ip.hits.map(h => h.referer).filter(Boolean))];
|
||||||
const refHtml = referers.length
|
const refHtml = referers.length
|
||||||
? referers.slice(0,3).map(r => `<span class="err-referer" title="${escHtml(r)}">${escHtml(trimUrl(r))}</span>`).join(' ')
|
? referers.slice(0,3).map(r => `<span class="err-referer" title="${escHtml(r)}">${escHtml(trimUrl(r))}</span>`).join(' ')
|
||||||
: '<span style="color:var(--muted)">—</span>';
|
: '<span style="color:var(--muted)">—</span>';
|
||||||
|
|
||||||
html += `<tr class="${ip.ignored ? 'err-ip-ignored' : ''}">
|
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><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><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="col-date">${ip.last_seen}</td>
|
||||||
<td class="err-referers">${refHtml}</td>
|
<td class="err-referers">${refHtml}</td>
|
||||||
<td class="col-actions">
|
<td class="col-actions"><div class="row-actions">
|
||||||
<div class="row-actions">
|
|
||||||
<button type="button" class="btn-ignore-ip ${ip.ignored ? 'btn-ignore-ip--active' : ''}"
|
<button type="button" class="btn-ignore-ip ${ip.ignored ? 'btn-ignore-ip--active' : ''}"
|
||||||
data-ip="${ip.ip}" data-action="${ip.ignored ? 'remove' : 'add'}"
|
data-ip="${ip.ip}" data-action="${ip.ignored ? 'remove' : 'add'}"
|
||||||
title="${ip.ignored ? 'Retirer de la liste d\'ignorées' : 'Ignorer cette IP'}">
|
title="${ip.ignored ? "Retirer des ignorées" : "Ignorer cette IP"}">${ip.ignored ? '👁' : '🙈'}</button>
|
||||||
${ip.ignored ? '👁' : '🙈'}
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn-ban-ip" data-ip="${ip.ip}" title="Bannir dans fail2ban">🔨</button>
|
<button type="button" class="btn-ban-ip" data-ip="${ip.ip}" title="Bannir dans fail2ban">🔨</button>
|
||||||
</div>
|
</div></td>
|
||||||
</td>
|
|
||||||
</tr>`;
|
</tr>`;
|
||||||
}
|
}
|
||||||
html += '</tbody></table>';
|
html += '</tbody></table>';
|
||||||
container.innerHTML = html;
|
container.innerHTML = html;
|
||||||
|
|
||||||
/* Ignore buttons */
|
|
||||||
container.querySelectorAll('.btn-ignore-ip').forEach(b => {
|
container.querySelectorAll('.btn-ignore-ip').forEach(b => {
|
||||||
b.addEventListener('click', async () => {
|
b.addEventListener('click', async () => {
|
||||||
const fd = new FormData();
|
const fd = new FormData(); fd.append('ip', b.dataset.ip); fd.append('action', b.dataset.action);
|
||||||
fd.append('ip', b.dataset.ip);
|
const j = await fetch(IGNORE_URL, { method: 'POST', body: fd }).then(r => r.json());
|
||||||
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);
|
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 => {
|
container.querySelectorAll('.btn-ban-ip').forEach(b => {
|
||||||
b.addEventListener('click', async () => {
|
b.addEventListener('click', async () => {
|
||||||
const ip = b.dataset.ip;
|
const ip = b.dataset.ip;
|
||||||
const actionsDiv = b.closest('.row-actions');
|
const div = b.closest('.row-actions');
|
||||||
b.disabled = true; b.textContent = '⏳';
|
b.disabled = true; b.textContent = '⏳';
|
||||||
|
|
||||||
let asInfo = {};
|
let asInfo = {};
|
||||||
try {
|
try { asInfo = await fetch(ASINFO_URL + '?ip=' + encodeURIComponent(ip)).then(r => r.json()); } catch {}
|
||||||
asInfo = await fetch(ASINFO_URL + '?ip=' + encodeURIComponent(ip)).then(r => r.json());
|
|
||||||
} catch (_) {}
|
|
||||||
|
|
||||||
const asLabel = asInfo.asn
|
const asLabel = asInfo.asn
|
||||||
? `AS${asInfo.asn} · ${escHtml(asInfo.name)}${asInfo.country ? ' [' + asInfo.country + ']' : ''}`
|
? `AS${asInfo.asn} · ${escHtml(asInfo.name)}${asInfo.country ? ' ['+asInfo.country+']' : ''}`
|
||||||
: '<span style="color:var(--muted)">AS inconnu</span>';
|
: '<span style="color:var(--muted)">AS inconnu</span>';
|
||||||
const n = asInfo.prefix_count || 0;
|
const n = asInfo.prefix_count || 0;
|
||||||
|
div.innerHTML =
|
||||||
actionsDiv.innerHTML =
|
`<div class="ban-panel"><span class="ban-panel-as">${asLabel}</span><div class="ban-panel-btns">
|
||||||
`<div class="ban-panel">
|
<button class="btn-do-ban" data-ban-as="0" data-ip="${escHtml(ip)}">🔨 IP</button>`
|
||||||
<span class="ban-panel-as">${asLabel}</span>
|
+ (n ? `<button class="btn-do-ban btn-do-ban--as" data-ban-as="1" data-ip="${escHtml(ip)}" data-count="${n}">🔨 AS (${n})</button>` : '')
|
||||||
<div class="ban-panel-btns">
|
+ `<button class="btn-ban-cancel">✕</button></div></div>`;
|
||||||
<button class="btn-do-ban" data-ban-as="0" data-ip="${escHtml(ip)}" title="Bannir cette IP uniquement">🔨 IP</button>`
|
div.querySelector('.btn-ban-cancel').addEventListener('click', () => loadDetail(d.path, container));
|
||||||
+ (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>` : '')
|
div.querySelectorAll('.btn-do-ban').forEach(btn => {
|
||||||
+ `<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 () => {
|
btn.addEventListener('click', async () => {
|
||||||
const banAs = btn.dataset.banAs === '1';
|
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;
|
if (banAs && !confirm(`Bannir les ${btn.dataset.count} préfixes de cet AS ?`)) return;
|
||||||
actionsDiv.querySelectorAll('button').forEach(x => x.disabled = true);
|
div.querySelectorAll('button').forEach(x => x.disabled = true);
|
||||||
const fd = new FormData();
|
const fd = new FormData(); fd.append('ip', btn.dataset.ip);
|
||||||
fd.append('ip', btn.dataset.ip);
|
|
||||||
if (banAs) fd.append('ban_as', '1');
|
if (banAs) fd.append('ban_as', '1');
|
||||||
const r = await fetch(BAN_URL, { method: 'POST', body: fd });
|
const j = await fetch(BAN_URL, { method: 'POST', body: fd }).then(r => r.json());
|
||||||
const j = await r.json();
|
|
||||||
if (j.ok) {
|
if (j.ok) {
|
||||||
const label = banAs ? `✓ AS banni (${j.count} préfixes)` : '✓ Banni';
|
div.innerHTML = `<span class="ban-ok">${banAs ? '✓ AS banni (' + j.count + ' préfixes)' : '✓ Banni'}</span>`;
|
||||||
actionsDiv.innerHTML = `<span class="ban-ok">${label}</span>`;
|
|
||||||
setTimeout(() => loadDetail(d.path, container), 1500);
|
setTimeout(() => loadDetail(d.path, container), 1500);
|
||||||
} else {
|
} else {
|
||||||
actionsDiv.innerHTML = `<span class="ban-err" title="${escHtml(j.error||'')}">✗ ${escHtml(j.error||'Erreur')}</span>`;
|
div.innerHTML = `<span class="ban-err" title="${escHtml(j.error||'')}">✗ ${escHtml(j.error||'Erreur')}</span>`;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -259,31 +281,94 @@
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Remove from ignored list (top section) ── */
|
|
||||||
document.querySelectorAll('.err-ignored-remove').forEach(b => {
|
document.querySelectorAll('.err-ignored-remove').forEach(b => {
|
||||||
b.addEventListener('click', async () => {
|
b.addEventListener('click', async () => {
|
||||||
const fd = new FormData();
|
const fd = new FormData(); fd.append('ip', b.dataset.ip); fd.append('action', 'remove');
|
||||||
fd.append('ip', b.dataset.ip);
|
|
||||||
fd.append('action', 'remove');
|
|
||||||
await fetch(IGNORE_URL, { method: 'POST', body: fd });
|
await fetch(IGNORE_URL, { method: 'POST', body: fd });
|
||||||
location.reload();
|
location.reload();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/* ── Check status dots ── */
|
|
||||||
document.querySelectorAll('.err-status-dot').forEach(dot => {
|
document.querySelectorAll('.err-status-dot').forEach(dot => {
|
||||||
dot.style.cursor = 'pointer';
|
dot.style.cursor = 'pointer';
|
||||||
dot.addEventListener('click', async () => {
|
dot.addEventListener('click', async () => {
|
||||||
dot.textContent = '○';
|
dot.textContent = '○'; dot.className = 'err-status-dot';
|
||||||
dot.className = 'err-status-dot';
|
const r = await fetch(DETAIL_URL + '?path=' + encodeURIComponent(dot.dataset.path)).then(r => r.json());
|
||||||
const r = await fetch({{ url_for('errors_detail') | tojson }} + '?path=' + encodeURIComponent(dot.dataset.path))
|
|
||||||
.then(r => r.json());
|
|
||||||
dot.textContent = '●';
|
dot.textContent = '●';
|
||||||
dot.className = 'err-status-dot err-status-dot--' + (r.still_404 ? 'active' : 'ok');
|
dot.className = 'err-status-dot err-status-dot--' + (r.still_404 ? 'active' : 'ok');
|
||||||
dot.title = r.still_404 ? 'Toujours actif' : 'Résolu';
|
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) {
|
function escHtml(s) {
|
||||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue