feat(dashboard): page Listes de référence — visualisation CSV/dictionnaires

Nouvelle page /reflists pour visualiser les 9 dictionnaires ClickHouse :
- bot_ip (3.5K entrées) : IP/CIDR de bots connus
- bot_ja4 (31) : fingerprints JA4 de bots
- browser_ja4 (1.2K) : fingerprints JA4 navigateurs → famille, lib TLS
- asn_reputation (82.5K) : ASN → réputation (isp, datacenter, cdn…)
- iplocate_asn (714K) : géolocalisation IP → ASN, pays, nom
- anubis_ua_rules, anubis_ip_rules, anubis_asn_rules, anubis_country_rules

Fonctionnalités :
- 9 onglets de navigation entre les listes
- Recherche textuelle avec filtrage côté ClickHouse
- Pagination (200 entrées/page)
- Tri par colonne (ASC/DESC)
- Graphique de répartition (ECharts) par catégorie
- KPIs dictionnaires en haut de page
- Infobulles de documentation

API : /api/dictionaries, /api/reflist/{name}, /api/reflist/{name}/stats
Helpers : esc() (HTML escape) ajouté à base.html

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
toto
2026-04-09 14:56:54 +02:00
parent 039086a0b3
commit 98abbc80c7
4 changed files with 531 additions and 0 deletions

View File

@ -1470,3 +1470,180 @@ async def cluster_detail(cid: int) -> dict[str, Any]:
except Exception as exc:
logger.exception("cluster detail query failed for %s", cid)
raise HTTPException(status_code=500, detail=str(exc))
# ═══════════════════════════════════════════════════════════════════════════════
# Listes de référence (CSV / dictionnaires ClickHouse)
# ═══════════════════════════════════════════════════════════════════════════════
@router.get("/dictionaries")
async def dictionaries_meta():
"""Métadonnées de tous les dictionnaires ClickHouse."""
try:
rows = query(
"SELECT name, type, status, element_count, "
" arrayStringConcat(attribute.names, ', ') AS attributes "
"FROM system.dictionaries "
f"WHERE database = '{_DB}' "
"ORDER BY name",
)
return {"dictionaries": rows}
except Exception as exc:
logger.exception("dictionaries meta query failed")
raise HTTPException(status_code=500, detail=str(exc))
_REFLIST_SORT = {
"bot_ip": {"prefix", "bot_name"},
"bot_ja4": {"ja4", "bot_name"},
"browser_ja4": {"ja4", "browser_family", "tls_library"},
"asn_reputation": {"src_asn", "label"},
"iplocate_asn": {"asn", "country_code", "name", "network"},
"anubis_ua_rules": {"id", "regexp", "bot_name", "action", "category"},
"anubis_ip_rules": {"prefix", "bot_name", "action", "category"},
"anubis_asn_rules": {"asn", "bot_name", "action", "category"},
"anubis_country_rules": {"country_code", "bot_name", "action", "category"},
}
_REFLIST_SEARCH_COLS: dict[str, list[str]] = {
"bot_ip": ["prefix", "bot_name"],
"bot_ja4": ["ja4", "bot_name"],
"browser_ja4": ["ja4", "browser_family", "tls_library", "context"],
"asn_reputation": ["toString(src_asn)", "label"],
"iplocate_asn": ["network", "toString(asn)", "country_code", "name"],
"anubis_ua_rules": ["regexp", "bot_name", "action", "category"],
"anubis_ip_rules": ["prefix", "bot_name", "action", "category"],
"anubis_asn_rules": ["toString(asn)", "bot_name", "action", "category"],
"anubis_country_rules": ["country_code", "bot_name", "action", "category"],
}
_REFLIST_QUERIES: dict[str, str] = {
"bot_ip": f"SELECT prefix, bot_name FROM dictionary('{_DB}.dict_bot_ip')",
"bot_ja4": f"SELECT ja4, bot_name FROM dictionary('{_DB}.dict_bot_ja4')",
"browser_ja4": (
f"SELECT ja4, browser_family, tls_library, context "
f"FROM dictionary('{_DB}.dict_browser_ja4')"
),
"asn_reputation": (
f"SELECT src_asn, label FROM dictionary('{_DB}.dict_asn_reputation')"
),
"iplocate_asn": (
f"SELECT network, asn, country_code, name "
f"FROM dictionary('{_DB}.dict_iplocate_asn')"
),
"anubis_ua_rules": (
f"SELECT id, parent_id, regexp, "
f" arrayElement(values, indexOf(keys, 'bot_name')) AS bot_name, "
f" arrayElement(values, indexOf(keys, 'action')) AS action, "
f" arrayElement(values, indexOf(keys, 'category')) AS category "
f"FROM {_DB}.anubis_ua_rules"
),
"anubis_ip_rules": (
f"SELECT prefix, bot_name, action, category FROM {_DB}.anubis_ip_rules"
),
"anubis_asn_rules": (
f"SELECT asn, bot_name, action, category FROM {_DB}.anubis_asn_rules"
),
"anubis_country_rules": (
f"SELECT country_code, bot_name, action, category FROM {_DB}.anubis_country_rules"
),
}
@router.get("/reflist/{name}")
async def reflist(
name: str,
limit: int = Query(default=200, ge=1, le=10000),
offset: int = Query(default=0, ge=0),
sort: str = Query(default=""),
order: str = Query(default="ASC"),
search: str = Query(default=""),
):
"""Contenu paginé d'une liste de référence / dictionnaire."""
if name not in _REFLIST_QUERIES:
raise HTTPException(status_code=404, detail=f"Unknown reflist: {name}")
base_q = _REFLIST_QUERIES[name]
order_clause = ""
if sort and sort in _REFLIST_SORT.get(name, set()):
direction = "DESC" if order.upper() == "DESC" else "ASC"
order_clause = f" ORDER BY {sort} {direction}"
where_clause = ""
params: dict = {}
if search:
params["_q"] = f"%{search}%"
cols = _REFLIST_SEARCH_COLS.get(name, [])
if cols:
conditions = " OR ".join(f"{c} LIKE {{_q:String}}" for c in cols)
where_clause = f" WHERE ({conditions})"
try:
wrapped = f"SELECT * FROM ({base_q}){where_clause}"
count_q = f"SELECT count() AS total FROM ({wrapped})"
total_row = query(count_q, params or None)
total = total_row[0]["total"] if total_row else 0
data_q = f"{wrapped}{order_clause} LIMIT {int(limit)} OFFSET {int(offset)}"
rows = query(data_q, params or None)
return {"name": name, "total": total, "limit": limit, "offset": offset, "rows": rows}
except Exception as exc:
logger.exception("reflist query failed for %s", name)
raise HTTPException(status_code=500, detail=str(exc))
@router.get("/reflist/{name}/stats")
async def reflist_stats(name: str):
"""Statistiques agrégées pour une liste de référence."""
if name not in _REFLIST_QUERIES:
raise HTTPException(status_code=404, detail=f"Unknown reflist: {name}")
base_q = _REFLIST_QUERIES[name]
try:
count_q = f"SELECT count() AS total FROM ({base_q})"
total_row = query(count_q)
total = total_row[0]["total"] if total_row else 0
agg: list = []
if name == "bot_ip":
agg = query(
f"SELECT bot_name, count() AS cnt FROM ({base_q}) "
"GROUP BY bot_name ORDER BY cnt DESC LIMIT 20"
)
elif name == "bot_ja4":
agg = query(
f"SELECT bot_name, count() AS cnt FROM ({base_q}) "
"GROUP BY bot_name ORDER BY cnt DESC LIMIT 20"
)
elif name == "browser_ja4":
agg = query(
f"SELECT browser_family, count() AS cnt FROM ({base_q}) "
"GROUP BY browser_family ORDER BY cnt DESC LIMIT 20"
)
elif name == "asn_reputation":
agg = query(
f"SELECT label, count() AS cnt FROM ({base_q}) "
"GROUP BY label ORDER BY cnt DESC"
)
elif name == "iplocate_asn":
agg = query(
f"SELECT country_code, count() AS cnt FROM ({base_q}) "
"GROUP BY country_code ORDER BY cnt DESC LIMIT 20"
)
elif name == "anubis_ip_rules":
agg = query(
f"SELECT action, count() AS cnt FROM ({base_q}) "
"GROUP BY action ORDER BY cnt DESC"
)
elif name == "anubis_asn_rules":
agg = query(
f"SELECT action, count() AS cnt FROM ({base_q}) "
"GROUP BY action ORDER BY cnt DESC"
)
return {"name": name, "total": total, "breakdown": agg}
except Exception as exc:
logger.exception("reflist stats query failed for %s", name)
raise HTTPException(status_code=500, detail=str(exc))

View File

@ -76,3 +76,8 @@ async def cluster_detail_page(request: Request, cid: int):
@router.get("/tactics")
async def tactics_page(request: Request):
return templates.TemplateResponse("tactics.html", _ctx(request, "tactics"))
@router.get("/reflists")
async def reflists_page(request: Request):
return templates.TemplateResponse("reflists.html", _ctx(request, "reflists"))

View File

@ -179,6 +179,10 @@
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/></svg>
<span class="nav-text">Classifier</span>
</a>
<a href="/reflists" class="nav-item {% if active_page == 'reflists' %}active{% endif %}" title="Listes de référence CSV / Dictionnaires">
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.58 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.58 4 8 4s8-1.79 8-4M4 7c0-2.21 3.58-4 8-4s8 1.79 8 4m0 5c0 2.21-3.58 4-8 4s-8-1.79-8-4"/></svg>
<span class="nav-text">Listes réf.</span>
</a>
</nav>
<!-- Footer -->
<div class="px-3 py-3 border-t border-gray-800 shrink-0">
@ -247,6 +251,13 @@
document.querySelectorAll('.doc-panel.show').forEach(p => p.classList.remove('show'));
});
// ── HTML escape ──
function esc(s) {
const d = document.createElement('div');
d.appendChild(document.createTextNode(s));
return d.innerHTML;
}
// ── Number formatting ──
function fmtNum(n) {
if (n == null) return '—';

View File

@ -0,0 +1,338 @@
{% extends "base.html" %}
{% block page_title %}
Listes de référence
<div class="relative inline-block ml-2">
<button class="doc-btn text-gray-400 hover:text-brand-300 text-sm" onclick="docToggle(this)"></button>
<div class="doc-panel w-96">
<h3 class="font-semibold text-brand-300 mb-2">Listes de référence</h3>
<p class="text-xs text-gray-300 leading-relaxed mb-2">
Visualisation des fichiers CSV et dictionnaires chargés dans ClickHouse.
Ces listes alimentent les enrichissements en temps réel du pipeline :
identification de bots connus, résolution ASN/géo, classification navigateur,
et règles Anubis de filtrage.
</p>
<table class="text-[10px] text-gray-400 w-full">
<tr><td class="pr-2 font-mono text-brand-400">bot_ip</td><td>IP/CIDR de bots connus → nom du bot</td></tr>
<tr><td class="pr-2 font-mono text-brand-400">bot_ja4</td><td>Fingerprints JA4 de bots → nom du bot</td></tr>
<tr><td class="pr-2 font-mono text-brand-400">browser_ja4</td><td>Fingerprints JA4 navigateurs → famille, lib TLS</td></tr>
<tr><td class="pr-2 font-mono text-brand-400">asn_reputation</td><td>ASN → label de réputation (isp, datacenter, cdn…)</td></tr>
<tr><td class="pr-2 font-mono text-brand-400">iplocate_asn</td><td>IP/CIDR → ASN, pays, nom (géolocalisation)</td></tr>
<tr><td class="pr-2 font-mono text-brand-400">anubis_*</td><td>Règles Anubis : UA, IP, ASN, pays (filtrage crawlers)</td></tr>
</table>
</div>
</div>
{% endblock %}
{% block content %}
<!-- KPI bar -->
<div id="kpi-bar" class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-3 mb-5">
<div class="bg-gray-900/60 border border-gray-800 rounded-lg p-3">
<div class="text-[10px] text-gray-500 uppercase tracking-wider">Dictionnaires</div>
<div id="kpi-dicts" class="text-xl font-bold text-white mt-1"></div>
</div>
<div class="bg-gray-900/60 border border-gray-800 rounded-lg p-3">
<div class="text-[10px] text-gray-500 uppercase tracking-wider">Bot IPs</div>
<div id="kpi-botip" class="text-xl font-bold text-red-400 mt-1"></div>
</div>
<div class="bg-gray-900/60 border border-gray-800 rounded-lg p-3">
<div class="text-[10px] text-gray-500 uppercase tracking-wider">Browser JA4</div>
<div id="kpi-browser" class="text-xl font-bold text-green-400 mt-1"></div>
</div>
<div class="bg-gray-900/60 border border-gray-800 rounded-lg p-3">
<div class="text-[10px] text-gray-500 uppercase tracking-wider">ASN Réputation</div>
<div id="kpi-asn" class="text-xl font-bold text-blue-400 mt-1"></div>
</div>
<div class="bg-gray-900/60 border border-gray-800 rounded-lg p-3">
<div class="text-[10px] text-gray-500 uppercase tracking-wider">IPLocate ASN</div>
<div id="kpi-iplocate" class="text-xl font-bold text-purple-400 mt-1"></div>
</div>
</div>
<!-- Tab bar -->
<div class="flex flex-wrap gap-1 mb-4 border-b border-gray-800 pb-2">
<button class="tab-btn active" data-tab="bot_ip">🤖 Bot IP</button>
<button class="tab-btn" data-tab="bot_ja4">🔑 Bot JA4</button>
<button class="tab-btn" data-tab="browser_ja4">🌐 Browser JA4</button>
<button class="tab-btn" data-tab="asn_reputation">🏢 ASN Réputation</button>
<button class="tab-btn" data-tab="iplocate_asn">🌍 IPLocate</button>
<button class="tab-btn" data-tab="anubis_ua_rules">🕷 Anubis UA</button>
<button class="tab-btn" data-tab="anubis_ip_rules">🕷 Anubis IP</button>
<button class="tab-btn" data-tab="anubis_asn_rules">🕷 Anubis ASN</button>
<button class="tab-btn" data-tab="anubis_country_rules">🕷 Anubis Pays</button>
</div>
<!-- Controls row -->
<div class="flex flex-wrap items-center gap-3 mb-4">
<input id="search-input" type="text" placeholder="Rechercher…"
class="bg-gray-900 border border-gray-700 rounded px-3 py-1.5 text-sm text-gray-200 placeholder-gray-500 w-64 focus:border-brand-500 focus:outline-none">
<span id="result-count" class="text-xs text-gray-500"></span>
<div class="flex-1"></div>
<div class="flex items-center gap-2">
<button id="btn-prev" onclick="prevPage()" class="px-2 py-1 text-xs bg-gray-800 border border-gray-700 rounded text-gray-400 hover:bg-gray-700 disabled:opacity-30" disabled>← Préc.</button>
<span id="page-info" class="text-xs text-gray-500">Page 1</span>
<button id="btn-next" onclick="nextPage()" class="px-2 py-1 text-xs bg-gray-800 border border-gray-700 rounded text-gray-400 hover:bg-gray-700 disabled:opacity-30" disabled>Suiv. →</button>
</div>
</div>
<!-- Stats panel (chart) -->
<div id="stats-panel" class="bg-gray-900/60 border border-gray-800 rounded-lg p-4 mb-4 hidden">
<div class="flex items-center gap-2 mb-3">
<h3 id="stats-title" class="text-sm font-semibold text-gray-200"></h3>
<div class="relative inline-block">
<button class="doc-btn text-gray-400 hover:text-brand-300 text-xs" onclick="docToggle(this)"></button>
<div class="doc-panel w-72">
<p class="text-xs text-gray-300 leading-relaxed">
Distribution des entrées par catégorie principale.
Cliquer sur une barre pour filtrer le tableau.
</p>
</div>
</div>
</div>
<div id="stats-chart" style="height:200px;"></div>
</div>
<!-- Data table -->
<div class="bg-gray-900/60 border border-gray-800 rounded-lg overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-xs">
<thead>
<tr id="table-head" class="border-b border-gray-800 text-gray-400 text-left"></tr>
</thead>
<tbody id="table-body" class="divide-y divide-gray-800/50"></tbody>
</table>
</div>
</div>
{% endblock %}
{% block scripts %}
<style>
.tab-btn {
padding: 6px 14px;
font-size: 12px;
font-weight: 500;
color: #9ca3af;
background: transparent;
border: 1px solid transparent;
border-radius: 6px;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
}
.tab-btn:hover { color: #e5e7eb; background: rgba(255,255,255,0.04); }
.tab-btn.active {
color: #93c5fd;
background: rgba(59,130,246,0.1);
border-color: rgba(59,130,246,0.3);
}
</style>
<script>
const PAGE_SIZE = 200;
let currentTab = 'bot_ip';
let currentOffset = 0;
let currentTotal = 0;
let currentSort = '';
let currentOrder = 'ASC';
let searchTimeout = null;
// Descriptions par liste
const LIST_DOC = {
bot_ip: 'Plages IP/CIDR de bots et scanners connus. Source : dict_bot_ip (IP_TRIE). Utilisé par la feature is_known_bot dans le scoring ML.',
bot_ja4: 'Fingerprints JA4 de clients automatisés connus (curl, python-requests, scrapy…). Source : dict_bot_ja4. Utilisé pour le triage pre-ML.',
browser_ja4:'Fingerprints JA4 de navigateurs légitimes avec famille (Chromium, Firefox, Safari…), librairie TLS et contexte. Source : dict_browser_ja4.',
asn_reputation:'Réputation de chaque ASN (isp, datacenter, cdn, hosting, education…). Source : dict_asn_reputation. Utilisé pour la baseline ISP humaine.',
iplocate_asn:'Géolocalisation IP→ASN : réseau, numéro ASN, code pays, nom opérateur. Source : dict_iplocate_asn (IP_TRIE, ~714K entrées).',
anubis_ua_rules:'Règles Anubis de détection par User-Agent (REGEXP_TREE). Chaque règle associe un pattern regex à un bot_name et une action (ALLOW/DENY/WEIGH).',
anubis_ip_rules:'Règles Anubis de détection par plage IP (IP_TRIE). Associe des CIDR à des bots connus avec action de filtrage.',
anubis_asn_rules:'Règles Anubis par ASN : certains ASN sont associés à des botnets ou services automatisés connus.',
anubis_country_rules:'Règles Anubis par pays : politique de filtrage par code pays (ex: bloquer le trafic de certaines régions).',
};
// Colonnes par liste
const COLUMNS = {
bot_ip: [{k:'prefix',f:'IP/CIDR'},{k:'bot_name',f:'Bot'}],
bot_ja4: [{k:'ja4',f:'JA4'},{k:'bot_name',f:'Bot'}],
browser_ja4:[{k:'ja4',f:'JA4'},{k:'browser_family',f:'Famille'},{k:'tls_library',f:'Lib TLS'},{k:'context',f:'Contexte'}],
asn_reputation:[{k:'src_asn',f:'ASN'},{k:'label',f:'Réputation'}],
iplocate_asn:[{k:'network',f:'Réseau'},{k:'asn',f:'ASN'},{k:'country_code',f:'Pays'},{k:'name',f:'Nom'}],
anubis_ua_rules:[{k:'id',f:'ID'},{k:'regexp',f:'Regex'},{k:'bot_name',f:'Bot'},{k:'action',f:'Action'},{k:'category',f:'Catégorie'}],
anubis_ip_rules:[{k:'prefix',f:'IP/CIDR'},{k:'bot_name',f:'Bot'},{k:'action',f:'Action'},{k:'category',f:'Catégorie'}],
anubis_asn_rules:[{k:'asn',f:'ASN'},{k:'bot_name',f:'Bot'},{k:'action',f:'Action'},{k:'category',f:'Catégorie'}],
anubis_country_rules:[{k:'country_code',f:'Pays'},{k:'bot_name',f:'Bot'},{k:'action',f:'Action'},{k:'category',f:'Catégorie'}],
};
// Tab click
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
currentTab = btn.dataset.tab;
currentOffset = 0;
currentSort = '';
currentOrder = 'ASC';
document.getElementById('search-input').value = '';
loadStats();
loadData();
});
});
// Search
document.getElementById('search-input').addEventListener('input', (e) => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => { currentOffset = 0; loadData(); }, 350);
});
function prevPage() { if (currentOffset >= PAGE_SIZE) { currentOffset -= PAGE_SIZE; loadData(); } }
function nextPage() { if (currentOffset + PAGE_SIZE < currentTotal) { currentOffset += PAGE_SIZE; loadData(); } }
function sortBy(col) {
if (currentSort === col) {
currentOrder = currentOrder === 'ASC' ? 'DESC' : 'ASC';
} else {
currentSort = col;
currentOrder = 'ASC';
}
currentOffset = 0;
loadData();
}
function renderHead() {
const cols = COLUMNS[currentTab] || [];
const thead = document.getElementById('table-head');
thead.innerHTML = cols.map(c =>
`<th class="px-3 py-2 text-[11px] cursor-pointer hover:text-brand-300 select-none whitespace-nowrap" onclick="sortBy('${c.k}')">` +
`${c.f} ${currentSort === c.k ? (currentOrder === 'ASC' ? '↑' : '↓') : '<span class=&quot;text-gray-600&quot;>↕</span>'}</th>`
).join('');
}
function cellHtml(col, val) {
if (val == null || val === '') return '<span class="text-gray-600">—</span>';
const s = String(val);
if (col === 'ja4') return fmtJA4Full(s);
if (col === 'prefix' || col === 'network') return `<span class="font-mono text-gray-300">${esc(s)}</span>`;
if (col === 'bot_name') return s ? `<span class="text-red-400">${esc(s)}</span>` : '—';
if (col === 'browser_family') {
const colors = {Chromium:'text-blue-400',Firefox:'text-orange-400',Safari:'text-cyan-400',Edge:'text-green-400',Opera:'text-red-400'};
return `<span class="${colors[s]||'text-gray-300'}">${esc(s)}</span>`;
}
if (col === 'action') {
const cls = {DENY:'bg-red-500/20 text-red-300',ALLOW:'bg-green-500/20 text-green-300',WEIGH:'bg-yellow-500/20 text-yellow-300'};
return `<span class="px-1.5 py-0.5 rounded text-[10px] font-medium ${cls[s]||'text-gray-400'}">${esc(s)||'—'}</span>`;
}
if (col === 'label') {
const cls = {isp:'text-green-400',datacenter:'text-yellow-400',cdn:'text-blue-400',hosting:'text-orange-400',education:'text-purple-400',government:'text-cyan-400',enterprise:'text-indigo-400'};
return `<span class="${cls[s]||'text-gray-300'}">${esc(s)}</span>`;
}
if (col === 'country_code' && s.length === 2) return fmtCountry(s);
if (col === 'src_asn' || col === 'asn') return `<span class="font-mono text-gray-300">AS${s}</span>`;
if (col === 'regexp') return `<code class="text-[10px] text-yellow-300 bg-gray-800 px-1 rounded max-w-xs truncate inline-block">${esc(s)}</code>`;
return `<span class="text-gray-300">${esc(s)}</span>`;
}
function renderBody(rows) {
const cols = COLUMNS[currentTab] || [];
const tbody = document.getElementById('table-body');
if (!rows.length) {
tbody.innerHTML = `<tr><td colspan="${cols.length}" class="px-4 py-8 text-center text-gray-500">Aucune entrée</td></tr>`;
return;
}
tbody.innerHTML = rows.map(r =>
'<tr class="hover:bg-gray-800/40 transition-colors">' +
cols.map(c => `<td class="px-3 py-1.5 whitespace-nowrap">${cellHtml(c.k, r[c.k])}</td>`).join('') +
'</tr>'
).join('');
}
function updatePagination() {
const pageNum = Math.floor(currentOffset / PAGE_SIZE) + 1;
const totalPages = Math.max(1, Math.ceil(currentTotal / PAGE_SIZE));
document.getElementById('page-info').textContent = `Page ${pageNum} / ${totalPages}`;
document.getElementById('btn-prev').disabled = currentOffset === 0;
document.getElementById('btn-next').disabled = currentOffset + PAGE_SIZE >= currentTotal;
document.getElementById('result-count').textContent = `${fmtNum(currentTotal)} entrée${currentTotal > 1 ? 's' : ''}`;
}
async function loadData() {
const search = document.getElementById('search-input').value.trim();
renderHead();
let url = `/api/reflist/${currentTab}?limit=${PAGE_SIZE}&offset=${currentOffset}`;
if (currentSort) url += `&sort=${currentSort}&order=${currentOrder}`;
if (search) url += `&search=${encodeURIComponent(search)}`;
try {
const resp = await fetch(url);
if (!resp.ok) throw new Error(resp.statusText);
const data = await resp.json();
currentTotal = data.total;
renderBody(data.rows);
updatePagination();
} catch (e) {
console.error('Reflist load error:', e);
document.getElementById('table-body').innerHTML =
`<tr><td colspan="10" class="px-4 py-8 text-center text-red-400">Erreur de chargement : ${esc(e.message)}</td></tr>`;
}
}
async function loadStats() {
const panel = document.getElementById('stats-panel');
try {
const resp = await fetch(`/api/reflist/${currentTab}/stats`);
if (!resp.ok) throw new Error(resp.statusText);
const data = await resp.json();
if (!data.breakdown || !data.breakdown.length) { panel.classList.add('hidden'); return; }
panel.classList.remove('hidden');
document.getElementById('stats-title').textContent =
`Répartition — ${currentTab.replace(/_/g, ' ')} (${fmtNum(data.total)} entrées)`;
const labels = data.breakdown.map(r => Object.values(r)[0] || '—');
const values = data.breakdown.map(r => r.cnt);
renderBarChart('stats-chart', labels, values);
} catch (e) {
panel.classList.add('hidden');
console.error('Stats load error:', e);
}
}
async function loadMeta() {
try {
const resp = await fetch('/api/dictionaries');
if (!resp.ok) return;
const data = await resp.json();
const dicts = data.dictionaries || [];
document.getElementById('kpi-dicts').textContent = dicts.length;
for (const d of dicts) {
if (d.name === 'dict_bot_ip') document.getElementById('kpi-botip').textContent = fmtNum(d.element_count);
if (d.name === 'dict_browser_ja4') document.getElementById('kpi-browser').textContent = fmtNum(d.element_count);
if (d.name === 'dict_asn_reputation') document.getElementById('kpi-asn').textContent = fmtNum(d.element_count);
if (d.name === 'dict_iplocate_asn') document.getElementById('kpi-iplocate').textContent = fmtNum(d.element_count);
}
} catch (e) { console.error('Meta load error:', e); }
}
function renderBarChart(id, labels, values) {
const el = document.getElementById(id);
const ch = echarts.getInstanceByDom(el);
if (ch) ch.dispose();
const chart = echarts.init(el);
chart.setOption({
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
grid: { left: 80, right: 20, top: 10, bottom: 30 },
xAxis: { type: 'value', axisLabel: { color: '#6b7280', fontSize: 10 }, splitLine: { lineStyle: { color: '#1f2937' } } },
yAxis: { type: 'category', data: labels.slice().reverse(), axisLabel: { color: '#9ca3af', fontSize: 10 }, axisTick: { show: false } },
series: [{
type: 'bar',
data: values.slice().reverse(),
itemStyle: { color: '#3b82f6', borderRadius: [0, 3, 3, 0] },
barMaxWidth: 20,
}],
});
window.addEventListener('resize', () => chart.resize());
}
document.addEventListener('DOMContentLoaded', () => {
loadMeta();
loadStats();
loadData();
});
</script>
{% endblock %}