feat: page Erreurs 404 — logs, détail IP/date/referer, ignore, ban fail2ban
- Onglet "Erreurs" dans la navigation - Analyse des logs Apache des 7 derniers jours (.gz inclus) - Tableau trié par nombre de requêtes avec badge statut (résolu/actif) - Détail AJAX par chemin : IPs, compteurs, referers - Vérification live au clic sur le point de statut - Ignorer une IP (persisté dans ignored_ips.json, cache invalidé) - Bannir une IP via fail2ban-client (global-blacklist) - Section IPs ignorées avec suppression depuis la page Closes #37 #38 #39 #40 #41 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
11a3ae72ee
commit
0513afdbb4
4 changed files with 458 additions and 1 deletions
172
app/app.py
172
app/app.py
|
|
@ -1,3 +1,4 @@
|
||||||
|
import gzip
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
|
@ -5,6 +6,7 @@ import re
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
|
@ -51,6 +53,15 @@ except Exception:
|
||||||
_APP_VERSION = "—"
|
_APP_VERSION = "—"
|
||||||
|
|
||||||
_CHANGELOG_FILE = Path(__file__).parent / "CHANGELOG.md"
|
_CHANGELOG_FILE = Path(__file__).parent / "CHANGELOG.md"
|
||||||
|
_IGNORED_IPS_FILE = Path(__file__).parent / "ignored_ips.json"
|
||||||
|
|
||||||
|
_LOG_RE = re.compile(
|
||||||
|
r'(\S+) \S+ \S+ \[(\d{2}/\w+/\d{4}:\d{2}:\d{2}:\d{2} [+-]\d{4})\] '
|
||||||
|
r'"[A-Z-]+ ([^\s"]+)[^"]*" (\d{3}) \d+ "([^"]*)"'
|
||||||
|
)
|
||||||
|
_404_CACHE: dict = {}
|
||||||
|
_404_CACHE_TS: float = 0
|
||||||
|
_404_CACHE_TTL = 300 # 5 min
|
||||||
|
|
||||||
|
|
||||||
_HIDDEN = frozenset({
|
_HIDDEN = frozenset({
|
||||||
|
|
@ -480,6 +491,79 @@ def _parse_changelog():
|
||||||
return sections
|
return sections
|
||||||
|
|
||||||
|
|
||||||
|
# ── Erreurs 404 — helpers ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _load_ignored_ips() -> set:
|
||||||
|
try:
|
||||||
|
return set(json.loads(_IGNORED_IPS_FILE.read_text()).get("ips", []))
|
||||||
|
except Exception:
|
||||||
|
return set()
|
||||||
|
|
||||||
|
def _save_ignored_ips(ips: set) -> None:
|
||||||
|
_IGNORED_IPS_FILE.write_text(json.dumps({"ips": sorted(ips)}, indent=2))
|
||||||
|
|
||||||
|
def _log_files_404(days: int = 7) -> list:
|
||||||
|
if not STATS_LOG_FILE:
|
||||||
|
return []
|
||||||
|
log_dir = Path(STATS_LOG_FILE).parent
|
||||||
|
files = []
|
||||||
|
for f in sorted(log_dir.glob("*-access.log*"), reverse=True)[:days]:
|
||||||
|
files.append((f, f.name.endswith(".gz")))
|
||||||
|
return files
|
||||||
|
|
||||||
|
def _parse_404s(days: int = 7) -> dict:
|
||||||
|
global _404_CACHE, _404_CACHE_TS
|
||||||
|
now = time.time()
|
||||||
|
if now - _404_CACHE_TS < _404_CACHE_TTL and _404_CACHE:
|
||||||
|
return _404_CACHE
|
||||||
|
|
||||||
|
ignored = _load_ignored_ips()
|
||||||
|
results: dict = {}
|
||||||
|
|
||||||
|
for fpath, is_gz in _log_files_404(days):
|
||||||
|
try:
|
||||||
|
opener = gzip.open(fpath, "rt", errors="replace") if is_gz \
|
||||||
|
else open(fpath, errors="replace")
|
||||||
|
with opener as f:
|
||||||
|
for line in f:
|
||||||
|
m = _LOG_RE.match(line)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
ip, dt_str, path, status, referer = m.groups()
|
||||||
|
if status != "404":
|
||||||
|
continue
|
||||||
|
if ip in ignored:
|
||||||
|
continue
|
||||||
|
path = path.split("?")[0]
|
||||||
|
try:
|
||||||
|
dt = datetime.strptime(dt_str, "%d/%b/%Y:%H:%M:%S %z")
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if path not in results:
|
||||||
|
results[path] = {"count": 0, "last_seen": dt, "ips": {}}
|
||||||
|
r = results[path]
|
||||||
|
r["count"] += 1
|
||||||
|
if dt > r["last_seen"]:
|
||||||
|
r["last_seen"] = dt
|
||||||
|
if ip not in r["ips"]:
|
||||||
|
r["ips"][ip] = []
|
||||||
|
r["ips"][ip].append({"dt": dt, "referer": referer if referer != "-" else ""})
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
_404_CACHE = results
|
||||||
|
_404_CACHE_TS = now
|
||||||
|
return results
|
||||||
|
|
||||||
|
def _is_still_404(path: str) -> bool:
|
||||||
|
clean = path.lstrip("/")
|
||||||
|
return not (ASSETS_ROOT / clean).exists()
|
||||||
|
|
||||||
|
def _invalidate_404_cache():
|
||||||
|
global _404_CACHE_TS
|
||||||
|
_404_CACHE_TS = 0
|
||||||
|
|
||||||
|
|
||||||
# ── Navigateur de fichiers ────────────────────────────────────────────
|
# ── Navigateur de fichiers ────────────────────────────────────────────
|
||||||
|
|
||||||
@app.route("/browse/")
|
@app.route("/browse/")
|
||||||
|
|
@ -733,6 +817,94 @@ def delete_file():
|
||||||
return redirect(url_for("browse", subpath=parent) if parent != "." else url_for("browse"))
|
return redirect(url_for("browse", subpath=parent) if parent != "." else url_for("browse"))
|
||||||
|
|
||||||
|
|
||||||
|
# ── Erreurs 404 ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@app.route("/errors/")
|
||||||
|
def errors_404():
|
||||||
|
redir = _require_admin()
|
||||||
|
if redir:
|
||||||
|
return redir
|
||||||
|
data = _parse_404s()
|
||||||
|
entries = sorted(data.items(), key=lambda x: x[1]["count"], reverse=True)
|
||||||
|
ignored = _load_ignored_ips()
|
||||||
|
return render_template("errors_404.html",
|
||||||
|
entries=entries,
|
||||||
|
ignored_ips=sorted(ignored),
|
||||||
|
total=sum(v["count"] for v in data.values()),
|
||||||
|
log_configured=bool(STATS_LOG_FILE),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/errors/detail")
|
||||||
|
def errors_detail():
|
||||||
|
redir = _require_admin()
|
||||||
|
if redir:
|
||||||
|
return redir
|
||||||
|
path = request.args.get("path", "")
|
||||||
|
data = _parse_404s()
|
||||||
|
entry = data.get(path)
|
||||||
|
if not entry:
|
||||||
|
return jsonify({"error": "introuvable"}), 404
|
||||||
|
ignored = _load_ignored_ips()
|
||||||
|
ip_list = []
|
||||||
|
for ip, hits in sorted(entry["ips"].items(), key=lambda x: len(x[1]), reverse=True):
|
||||||
|
last = max(h["dt"] for h in hits)
|
||||||
|
ip_list.append({
|
||||||
|
"ip": ip,
|
||||||
|
"count": len(hits),
|
||||||
|
"last_seen": last.strftime("%d/%m/%Y %H:%M"),
|
||||||
|
"ignored": ip in ignored,
|
||||||
|
"hits": [
|
||||||
|
{"dt": h["dt"].strftime("%d/%m/%Y %H:%M"), "referer": h["referer"]}
|
||||||
|
for h in sorted(hits, key=lambda x: x["dt"], reverse=True)[:30]
|
||||||
|
],
|
||||||
|
})
|
||||||
|
return jsonify({
|
||||||
|
"path": path,
|
||||||
|
"count": entry["count"],
|
||||||
|
"still_404": _is_still_404(path),
|
||||||
|
"last_seen": entry["last_seen"].strftime("%d/%m/%Y %H:%M"),
|
||||||
|
"ips": ip_list,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/errors/ignore", methods=["POST"])
|
||||||
|
def errors_ignore():
|
||||||
|
redir = _require_admin()
|
||||||
|
if redir:
|
||||||
|
return redir
|
||||||
|
ip = request.form.get("ip", "").strip()
|
||||||
|
action = request.form.get("action", "add")
|
||||||
|
if not re.match(r"^[\d\.a-fA-F:]+$", ip):
|
||||||
|
return jsonify({"error": "IP invalide"}), 400
|
||||||
|
ips = _load_ignored_ips()
|
||||||
|
ips.add(ip) if action == "add" else ips.discard(ip)
|
||||||
|
_save_ignored_ips(ips)
|
||||||
|
_invalidate_404_cache()
|
||||||
|
return jsonify({"ok": True, "action": action, "ip": ip})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/errors/ban", methods=["POST"])
|
||||||
|
def errors_ban():
|
||||||
|
redir = _require_admin()
|
||||||
|
if redir:
|
||||||
|
return redir
|
||||||
|
ip = request.form.get("ip", "").strip()
|
||||||
|
jail = request.form.get("jail", "global-blacklist")
|
||||||
|
if not re.match(r"^[\d\.a-fA-F:]+$", ip):
|
||||||
|
return jsonify({"error": "IP invalide"}), 400
|
||||||
|
try:
|
||||||
|
r = subprocess.run(
|
||||||
|
["sudo", "fail2ban-client", "set", jail, "banip", ip],
|
||||||
|
capture_output=True, text=True, timeout=10
|
||||||
|
)
|
||||||
|
if r.returncode == 0:
|
||||||
|
return jsonify({"ok": True, "ip": ip, "jail": jail})
|
||||||
|
return jsonify({"error": r.stderr.strip() or "Erreur fail2ban"}), 500
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
# ── Corbeille ────────────────────────────────────────────────────────
|
# ── Corbeille ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@app.route("/trash")
|
@app.route("/trash")
|
||||||
|
|
|
||||||
|
|
@ -320,3 +320,51 @@ footer { background: var(--blue-dark); color: rgba(255,255,255,.6); margin-top:
|
||||||
.btn-del--perm { color: #b91c1c; }
|
.btn-del--perm { color: #b91c1c; }
|
||||||
.btn-danger { background: #b91c1c; color: #fff; border: none; border-radius: 6px; padding: .45rem .9rem; font-size: .88rem; cursor: pointer; font-weight: 600; transition: background .15s; }
|
.btn-danger { background: #b91c1c; color: #fff; border: none; border-radius: 6px; padding: .45rem .9rem; font-size: .88rem; cursor: pointer; font-weight: 600; transition: background .15s; }
|
||||||
.btn-danger:hover { background: #991b1b; }
|
.btn-danger:hover { background: #991b1b; }
|
||||||
|
|
||||||
|
/* ── Erreurs 404 ────────────────────────────────────────────────────── */
|
||||||
|
.err-header { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: .75rem; margin-bottom: .5rem; }
|
||||||
|
.err-header h2 { margin: 0; }
|
||||||
|
.err-total-badge { font-size: .75rem; font-weight: 400; color: var(--muted); margin-left: .5rem; }
|
||||||
|
.btn-sm { font-size: .8rem; padding: .3rem .7rem; background: var(--blue-light); color: var(--blue); border: 1px solid var(--border); border-radius: 6px; cursor: pointer; }
|
||||||
|
|
||||||
|
.err-table .err-path code { font-size: .82rem; color: var(--text); word-break: break-all; }
|
||||||
|
.col-err-status { width: 1.5rem; text-align: center; }
|
||||||
|
.col-err-count { width: 6rem; text-align: right; }
|
||||||
|
.col-err-ips { width: 4rem; text-align: right; }
|
||||||
|
|
||||||
|
.err-status-dot { font-size: 1rem; cursor: pointer; transition: color .2s; }
|
||||||
|
.err-status-dot--active { color: #ef4444; }
|
||||||
|
.err-status-dot--ok { color: #22c55e; }
|
||||||
|
.err-status-dot--unk { color: #d1d5db; }
|
||||||
|
|
||||||
|
.err-detail-cell { padding: 0 !important; background: #f8fafd; }
|
||||||
|
.err-detail-content { padding: .75rem 1.2rem; }
|
||||||
|
.err-detail-meta { display: flex; align-items: center; gap: 1rem; margin-bottom: .75rem; flex-wrap: wrap; }
|
||||||
|
.err-detail-info { font-size: .82rem; color: var(--muted); }
|
||||||
|
|
||||||
|
.err-badge { font-size: .72rem; font-weight: 700; padding: .15rem .55rem; border-radius: 20px; }
|
||||||
|
.err-badge--active { background: #fee2e2; color: #991b1b; }
|
||||||
|
.err-badge--ok { background: #dcfce7; color: #166534; }
|
||||||
|
|
||||||
|
.err-ip-table { width: 100%; border-collapse: collapse; font-size: .85rem; }
|
||||||
|
.err-ip-table th { text-align: left; padding: .3rem .5rem; font-size: .72rem; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); border-bottom: 1px solid var(--border); }
|
||||||
|
.err-ip-table td { padding: .4rem .5rem; border-bottom: 1px solid var(--border); vertical-align: top; }
|
||||||
|
.err-ip-table tr:last-child td { border-bottom: none; }
|
||||||
|
.err-ip-ignored td { opacity: .45; }
|
||||||
|
.err-ip { font-size: .82rem; }
|
||||||
|
.err-ignored-tag { font-size: .68rem; background: #e5e7eb; color: #6b7280; padding: .1rem .35rem; border-radius: 3px; margin-left: .3rem; }
|
||||||
|
|
||||||
|
.err-referers { display: flex; flex-wrap: wrap; gap: .3rem; max-width: 28rem; }
|
||||||
|
.err-referer { font-size: .75rem; background: var(--blue-light); color: var(--blue); border-radius: 3px; padding: .1rem .4rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 18rem; }
|
||||||
|
|
||||||
|
.btn-ignore-ip { background: none; border: none; cursor: pointer; font-size: .95rem; opacity: .5; padding: .15rem; transition: opacity .15s; }
|
||||||
|
.btn-ignore-ip:hover, .btn-ignore-ip--active { opacity: 1; }
|
||||||
|
.btn-ban-ip { background: none; border: none; cursor: pointer; font-size: .95rem; opacity: .5; padding: .15rem; transition: opacity .15s; }
|
||||||
|
.btn-ban-ip:hover { opacity: 1; }
|
||||||
|
|
||||||
|
.err-loading { color: var(--muted); font-size: .85rem; font-style: italic; }
|
||||||
|
|
||||||
|
.err-ignored-list { display: flex; flex-wrap: wrap; gap: .5rem; margin-top: .5rem; }
|
||||||
|
.err-ignored-chip { display: inline-flex; align-items: center; gap: .35rem; background: #f3f4f6; border: 1px solid #e5e7eb; border-radius: 20px; padding: .2rem .6rem .2rem .8rem; font-size: .82rem; font-family: monospace; }
|
||||||
|
.err-ignored-remove { background: none; border: none; cursor: pointer; font-size: .75rem; color: #9ca3af; line-height: 1; padding: 0; transition: color .15s; }
|
||||||
|
.err-ignored-remove:hover { color: #ef4444; }
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,8 @@
|
||||||
{% if request.endpoint == 'browse' %}class="active"{% endif %}>Parcourir</a>
|
{% if request.endpoint == 'browse' %}class="active"{% endif %}>Parcourir</a>
|
||||||
<a href="{{ url_for('stats') }}"
|
<a href="{{ url_for('stats') }}"
|
||||||
{% 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') }}"
|
||||||
|
{% if request.endpoint == 'errors_404' %}class="active"{% endif %}>Erreurs</a>
|
||||||
<a href="{{ url_for('trash_list') }}"
|
<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>
|
||||||
|
|
|
||||||
235
app/templates/errors_404.html
Normal file
235
app/templates/errors_404.html
Normal file
|
|
@ -0,0 +1,235 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Erreurs 404{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
{% if not log_configured %}
|
||||||
|
<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">
|
||||||
|
<h2>Erreurs 404 <span class="err-total-badge">{{ total }} requêtes</span></h2>
|
||||||
|
<button type="button" class="btn btn-sm" id="refresh-btn" onclick="location.reload()">↺ Actualiser</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if not entries %}
|
||||||
|
<p style="color:var(--muted); margin-top:.5rem">Aucune erreur 404 dans les logs récents.</p>
|
||||||
|
{% else %}
|
||||||
|
<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 }}">
|
||||||
|
<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 %}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% if ignored_ips %}
|
||||||
|
<section class="card">
|
||||||
|
<h2>IPs ignorées</h2>
|
||||||
|
<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>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const DETAIL_URL = {{ url_for('errors_detail') | tojson }};
|
||||||
|
const IGNORE_URL = {{ url_for('errors_ignore') | tojson }};
|
||||||
|
const BAN_URL = {{ url_for('errors_ban') | tojson }};
|
||||||
|
|
||||||
|
/* ── Expand/collapse detail ── */
|
||||||
|
let openRow = null;
|
||||||
|
|
||||||
|
document.querySelectorAll('.btn-detail').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const path = btn.dataset.path;
|
||||||
|
const row = btn.closest('tr');
|
||||||
|
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';
|
||||||
|
|
||||||
|
if (openRow && openRow !== detailRow) {
|
||||||
|
openRow.style.display = 'none';
|
||||||
|
openRow.previousElementSibling.querySelector('.btn-detail').textContent = '▼';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (open) {
|
||||||
|
detailRow.style.display = 'none';
|
||||||
|
btn.textContent = '▼';
|
||||||
|
openRow = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
detailRow.style.display = '';
|
||||||
|
btn.textContent = '▲';
|
||||||
|
openRow = detailRow;
|
||||||
|
loadDetail(path, detailContent);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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 de la liste d\'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;
|
||||||
|
|
||||||
|
/* Ignore buttons */
|
||||||
|
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 r = await fetch(IGNORE_URL, { method: 'POST', body: fd });
|
||||||
|
const j = await r.json();
|
||||||
|
if (j.ok) loadDetail(d.path, container);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Ban buttons */
|
||||||
|
container.querySelectorAll('.btn-ban-ip').forEach(b => {
|
||||||
|
b.addEventListener('click', async () => {
|
||||||
|
if (!confirm(`Bannir ${b.dataset.ip} dans fail2ban ?`)) return;
|
||||||
|
b.disabled = true;
|
||||||
|
b.textContent = '⏳';
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('ip', b.dataset.ip);
|
||||||
|
const r = await fetch(BAN_URL, { method: 'POST', body: fd });
|
||||||
|
const j = await r.json();
|
||||||
|
if (j.ok) {
|
||||||
|
b.textContent = '✓';
|
||||||
|
b.style.color = '#16a34a';
|
||||||
|
} else {
|
||||||
|
b.textContent = '✗';
|
||||||
|
b.title = j.error || 'Erreur';
|
||||||
|
b.style.color = '#dc2626';
|
||||||
|
b.disabled = false;
|
||||||
|
alert('Erreur : ' + (j.error || 'voir console'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Remove from ignored list (top section) ── */
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── Check status dots ── */
|
||||||
|
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({{ url_for('errors_detail') | tojson }} + '?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';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function escHtml(s) {
|
||||||
|
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
|
}
|
||||||
|
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 %}
|
||||||
Loading…
Reference in a new issue