'
- + '
' + esc(ipInfo.ip) + ''
- + agentsHtml
+ function buildIpRow(ipInfo) {
+ // Agents sous l'IP avec badge bot (UA en entier)
+ var agentsHtml = '';
+ (ipInfo.agents || []).forEach(function (ua) {
+ agentsHtml += '
'
+ + botBadge(ua) + esc(ua) + '
';
+ });
+
+ // Chemins triés : /post/ et /book/ avec ts, reste sans ts
+ var postBook = [], other = [];
+ Object.keys(ipInfo.paths || {}).forEach(function (path) {
+ var p = ipInfo.paths[path];
+ var cnt = (p && typeof p === 'object') ? p.n : p;
+ var ts = (p && typeof p === 'object') ? p.ts : 0;
+ if (ts > 0) { postBook.push({ path: path, cnt: cnt, ts: ts }); }
+ else { other.push({ path: path, cnt: cnt }); }
+ });
+ postBook.sort(function (a, b) { return b.ts - a.ts; });
+ other.sort(function (a, b) { return b.cnt - a.cnt; });
+
+ function pathLine(p, prefix) {
+ var raw = p.path.replace(prefix, '');
+ var slug = '';
+ try { slug = decodeURIComponent(raw); } catch (e) { slug = raw; }
+ return '
';
+ }
+ function otherLine(p) {
+ return '
'
+ + '' + esc(trunc(p.path, 44)) + ''
+ + ' (' + p.cnt + ')
';
+ }
+
+ var pathsHtml = '';
+ var articles = postBook.filter(function (p) { return p.path.indexOf('/post/') === 0; });
+ var books = postBook.filter(function (p) { return p.path.indexOf('/book/') === 0; });
+ if (articles.length) {
+ pathsHtml += '
Articles
'
+ + articles.map(function (p) { return pathLine(p, '/post/'); }).join('');
+ }
+ if (books.length) {
+ pathsHtml += '
Livres
'
+ + books.map(function (p) { return pathLine(p, '/book/'); }).join('');
+ }
+ if (other.length) {
+ pathsHtml += '
Autres chemins
'
+ + other.map(otherLine).join('');
+ }
+ if (!pathsHtml) { pathsHtml = '
— '; }
+
+ return '
'
+ + '
'
+ + '' + esc(ipInfo.ip) + ''
+ + agentsHtml
+ + '
'
+ + '
' + ipSparkline(ipInfo.daily || []) + '
'
+ + '
' + pathsHtml + '
'
+ + '
'
+ + (ipInfo.hits || 0).toLocaleString('fr-FR') + '
'
+ + '
';
+ }
+
+ function renderCountry() {
+ // Filtrer les AS actifs / exclus
+ var activeLists = asList.filter(function (as) { return _excludedAs.indexOf(as.asn) === -1; });
+ var excludedLists = asList.filter(function (as) { return _excludedAs.indexOf(as.asn) !== -1; });
+
+ // Agréger par pays (AS actifs uniquement)
+ var byCountry = {}, asByCountry = {};
+ activeLists.forEach(function (as) {
+ var c = as.country || '??';
+ byCountry[c] = (byCountry[c] || 0) + as.hits;
+ if (!asByCountry[c]) { asByCountry[c] = []; }
+ asByCountry[c].push(as);
+ });
+
+ var countries = Object.keys(byCountry).map(function (c) {
+ return { code: c, hits: byCountry[c], networks: asByCountry[c] };
+ }).sort(function (a, b) { return b.hits - a.hits; }).slice(0, 20);
+
+ // En-tête avec alerte IPs sans AS
+ var headerExtra = '';
+ if (noAsCount > 0) {
+ headerExtra = '
'
+ + noAsCount + ' IP(s) sans AS ';
+ }
+
+ var countryCard = document.querySelector('#stats-country-container');
+ if (countryCard) {
+ var hdr = countryCard.closest('.card')
+ ? countryCard.closest('.card').querySelector('.card-header')
+ : null;
+ if (hdr && !hdr.querySelector('.no-as-badge')) {
+ var span = document.createElement('span');
+ span.className = 'no-as-badge';
+ span.innerHTML = headerExtra;
+ hdr.appendChild(span);
+ }
+ }
+
+ if (!countries.length) { el.innerHTML = '
Aucune donnée.
'; return; }
+
+ var maxH = countries[0].hits || 1;
+ var html = '
';
+
+ countries.forEach(function (c, ci) {
+ var pct = Math.round(c.hits / maxH * 100);
+ var cname = flag(c.code) + countryName(c.code);
+ var vis = c.hits.toLocaleString('fr-FR');
+ var accId = 'acc-country-' + ci;
+ var nets = c.networks.slice().sort(function (a, b) { return b.hits - a.hits; });
+ var maxN = nets[0] ? nets[0].hits : 1;
+
+ var netRows = nets.map(function (n, ni) {
+ var npct = Math.round(n.hits / maxN * 100);
+ var asId = 'acc-as-' + ci + '-' + ni;
+ var asnKey = n.asn || '__unknown__';
+ var ips = ipsByAsn[asnKey] || [];
+
+ var ipRows = ips.slice(0, 20).map(buildIpRow).join('');
+
+ var hasIps = ips.length > 0;
+ var toggleAttrs = hasIps ? ' data-bs-toggle="collapse" data-bs-target="#' + asId + '" role="button"' : '';
+ var chevron = hasIps ? '
▾ ' : '';
+ var excludeBtn = n.asn
+ ? '
✕ '
+ : '';
+
+ return '
'
+ + '
'
+ + '
'
+ + esc(n.name || '?')
+ + (n.asn ? ' AS' + esc(n.asn) + ' ' : '')
+ + chevron
+ + excludeBtn
+ '
'
- + '
' + ipSparkline(ipInfo.daily || []) + '
'
- + '
' + pathsHtml + '
'
- + '
'
- + (ipInfo.hits || 0).toLocaleString('fr-FR') + '
'
+ + '
'
+ + '
'
+ + n.hits.toLocaleString('fr-FR') + '
'
+ + '
'
+ + (hasIps ? '
'
+ + '
' + ipRows + '
'
+ + '
' : '')
+ '
';
}).join('');
- var hasIps = ips.length > 0;
- var toggleAttrs = hasIps ? ' data-bs-toggle="collapse" data-bs-target="#' + asId + '" role="button"' : '';
- var chevron = hasIps ? '
▾ ' : '';
-
- return '
'
- + '
'
- + '
'
- + esc(n.name || '?')
- + (n.asn ? ' AS' + esc(n.asn) + ' ' : '')
- + chevron + '
'
- + '
'
- + '
'
+ html += '
'
+ + '
'
+ + '
' + cname + '
'
+ + '
'
- + '
'
- + n.hits.toLocaleString('fr-FR') + '
'
+ + '
'
+ + vis + ' vis.
'
+ + '
▾ '
+ + '
'
+ + '
'
+ + '
' + netRows + '
'
+ '
'
- + (hasIps ? '
'
- + '
' + ipRows + '
'
- + '
' : '')
+ '
';
- }).join('');
+ });
- html += '
'
- + '
'
- + '
' + cname + '
'
- + '
'
- + '
'
- + vis + ' vis.
'
- + '
▾ '
- + '
'
- + '
'
- + '
' + netRows + '
'
- + '
'
- + '
';
- });
+ html += '
';
- html += '
';
- el.innerHTML = html;
+ // Section AS exclus
+ if (excludedLists.length) {
+ html += '
'
+ + '
AS exclus des stats (' + excludedLists.length + ')
'
+ + '
';
+ excludedLists.forEach(function (n) {
+ html += ''
+ + esc(n.name || '?')
+ + (n.asn ? ' AS' + esc(n.asn) + ' ' : '')
+ + '↺ '
+ + ' ';
+ });
+ html += '
';
+ }
+
+ el.innerHTML = html;
+
+ // Délégation : boutons exclure / inclure
+ el.addEventListener('click', function (e) {
+ var btn = e.target.closest('.exclude-as-btn');
+ if (btn) { excludeAs(btn.getAttribute('data-asn'), btn.getAttribute('data-name')); return; }
+ btn = e.target.closest('.include-as-btn');
+ if (btn) { includeAs(btn.getAttribute('data-asn')); }
+ }, { once: true });
+ }
+
+ renderCountry();
+ document.addEventListener('folio:excluded-as-changed', renderCountry);
}());
// ── Liste consolidée de tous les agents ──────────────────────────────────────
@@ -298,7 +438,7 @@ function botBadge(ua) {
el.addEventListener('click', function (e) {
var btn = e.target.closest('.add-bot-btn');
if (!btn) { return; }
- var row = btn.closest('tr');
+ var row = btn.closest('tr');
var code = row ? row.querySelector('code') : null;
if (code) { addBot(code.textContent, btn); }
});
@@ -394,7 +534,7 @@ function botBadge(ua) {
var xLabels = '';
pts.forEach(function (p, i) {
- if (i % 2 === 0 || i === n - 1) {
+ if (i % 3 === 0 || i === n - 1) {
xLabels += '
' + p.l + ' ';
}
@@ -410,7 +550,7 @@ function botBadge(ua) {
}).join('');
trendEl.innerHTML =
- '
Trafic total — 14 derniers jours
'
+ '
Trafic total — 30 derniers jours
'
+ '
'
+ ''
@@ -433,7 +573,6 @@ function botBadge(ua) {
var COLORS = ['#0d6efd','#198754','#dc3545','#fd7e14','#6f42c1',
'#20c997','#0dcaf0','#e63946','#f4a261','#457b9d'];
- var n = 14;
var VW = 900, VH = 480;
var ml = 44, mr = 12, mt = 12, mb = 28;
var W = VW - ml - mr;
@@ -449,6 +588,7 @@ function botBadge(ua) {
});
if (!series.length) { return; }
+ var n = series[0].data.length;
var allVals = series.reduce(function (acc, s) { return acc.concat(s.data); }, []);
var rawMax = Math.max.apply(null, allVals) || 1;
var mag = Math.pow(10, Math.floor(Math.log(rawMax) / Math.LN10));
@@ -490,7 +630,7 @@ function botBadge(ua) {
var xLabels = '';
labels.forEach(function (lbl, i) {
- if (i % 2 === 0 || i === n - 1) {
+ if (i % 3 === 0 || i === n - 1) {
var x = (ml + i * W / (n - 1)).toFixed(1);
xLabels += '' + lbl + ' ';
@@ -522,7 +662,7 @@ function botBadge(ua) {
}).join('');
el.innerHTML =
- 'Par article — 14 derniers jours
'
+ 'Par article — 30 derniers jours
'
+ ''
+ grid + lines + yLabels + xLabels + ' '
@@ -549,7 +689,7 @@ function botBadge(ua) {
var daily = pm ? (pagesByDay[pm[0]] || null) : null;
return { title: title, link: link, slug: slug, vis: vis, daily: daily };
});
- var nDays = 14;
+ var nDays = Object.values(pagesByDay)[0] ? Object.values(pagesByDay)[0].length : 30;
var totals = new Array(nDays).fill(0);
Object.values(pagesByDay).forEach(function (arr) {
arr.forEach(function (v, i) { if (i < nDays) { totals[i] += v; } });
diff --git a/public/index.php b/public/index.php
index 6e8d878..deacb50 100644
--- a/public/index.php
+++ b/public/index.php
@@ -2765,7 +2765,7 @@ switch ($action) {
if ($statsRaw === null) {
$cutoff14 = strtotime('-14 days midnight') ?: (time() - 14 * 86400);
$tParser = new TrendingParser('/var/log/apache2', apacheAccessLog());
- $accessParser = new AccessLogParser('/var/log/apache2', apacheAccessLog(), '', 600, 14, $botPatterns);
+ $accessParser = new AccessLogParser('/var/log/apache2', apacheAccessLog(), '', 600, 30, $botPatterns);
$accessStats = $accessParser->stats();
$topIps = array_slice($accessStats['ips'], 0, 200, true);
$asnMap = (new AsnLookup())->batchLookup(array_keys($topIps));
@@ -2785,23 +2785,31 @@ switch ($action) {
}
$statsRaw = [
- 'readable' => $accessParser->isReadable(),
- 'books' => $tParser->top($cutoff14, 20, ['/book/']),
- 'as' => AsnLookup::aggregateByAs($topIps, $asnMap),
- 'pages_by_day' => $accessStats['pages_by_day'] ?? [],
- 'ip_data' => $ipData,
- 'all_uas' => $accessStats['all_uas'] ?? [],
+ 'readable' => $accessParser->isReadable(),
+ 'books' => $tParser->top($cutoff14, 20, ['/book/']),
+ 'as' => AsnLookup::aggregateByAs($topIps, $asnMap),
+ 'pages_by_day' => $accessStats['pages_by_day'] ?? [],
+ 'ip_data' => $ipData,
+ 'all_uas' => $accessStats['all_uas'] ?? [],
+ 'unique_visitors' => $accessStats['unique_visitors'] ?? [7 => 0, 14 => 0, 30 => 0],
];
@file_put_contents($statsCacheFile, json_encode($statsRaw));
}
- $adminData['stats_readable'] = $statsRaw['readable'];
- $adminData['stats_books'] = $statsRaw['books'];
- $adminData['stats_as'] = $statsRaw['as'];
- $adminData['stats_as_groups'] = AsnLookup::applyGroups($statsRaw['as'], asGroups());
- $adminData['as_groups'] = asGroups();
- $adminData['stats_pages_by_day'] = $statsRaw['pages_by_day'] ?? [];
- $adminData['stats_ip_data'] = $statsRaw['ip_data'] ?? [];
- $adminData['stats_all_uas'] = $statsRaw['all_uas'] ?? [];
+ $adminData['stats_readable'] = $statsRaw['readable'];
+ $adminData['stats_books'] = $statsRaw['books'];
+ $adminData['stats_as'] = $statsRaw['as'];
+ $adminData['stats_as_groups'] = AsnLookup::applyGroups($statsRaw['as'], asGroups());
+ $adminData['as_groups'] = asGroups();
+ $adminData['stats_pages_by_day'] = $statsRaw['pages_by_day'] ?? [];
+ $adminData['stats_ip_data'] = $statsRaw['ip_data'] ?? [];
+ $adminData['stats_all_uas'] = $statsRaw['all_uas'] ?? [];
+ $adminData['stats_unique_visitors'] = $statsRaw['unique_visitors'] ?? [7 => 0, 14 => 0, 30 => 0];
+
+ // AS exclus (chargé en direct, pas mis en cache)
+ $excludedAsFile = DATA_PATH . '/excluded_as.json';
+ $adminData['excluded_as'] = is_file($excludedAsFile)
+ ? (json_decode((string) file_get_contents($excludedAsFile), true) ?: [])
+ : [];
}
if ($tab === 'categories') {
@@ -3290,6 +3298,58 @@ switch ($action) {
echo json_encode(['ok' => true, 'pattern' => $addPattern]);
exit;
+ case 'admin_add_excluded_as':
+ requireAuth();
+ if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') {
+ http_response_code(403);
+ exit;
+ }
+ $csrf = $_POST['_csrf'] ?? '';
+ if ($csrf !== ($_session['csrf'] ?? '')) {
+ http_response_code(403);
+ header('Content-Type: application/json');
+ echo json_encode(['ok' => false, 'error' => 'csrf']);
+ exit;
+ }
+ $asn = trim((string) ($_POST['asn'] ?? ''));
+ if ($asn === '') {
+ http_response_code(400);
+ header('Content-Type: application/json');
+ echo json_encode(['ok' => false, 'error' => 'empty']);
+ exit;
+ }
+ $excludedAsFile = DATA_PATH . '/excluded_as.json';
+ $excludedAs = is_file($excludedAsFile) ? (json_decode((string) file_get_contents($excludedAsFile), true) ?: []) : [];
+ if (!in_array($asn, $excludedAs, true)) {
+ $excludedAs[] = $asn;
+ @file_put_contents($excludedAsFile, json_encode($excludedAs, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
+ }
+ header('Content-Type: application/json');
+ echo json_encode(['ok' => true, 'asn' => $asn]);
+ exit;
+
+ case 'admin_remove_excluded_as':
+ requireAuth();
+ if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') {
+ http_response_code(403);
+ exit;
+ }
+ $csrf = $_POST['_csrf'] ?? '';
+ if ($csrf !== ($_session['csrf'] ?? '')) {
+ http_response_code(403);
+ header('Content-Type: application/json');
+ echo json_encode(['ok' => false, 'error' => 'csrf']);
+ exit;
+ }
+ $asn = trim((string) ($_POST['asn'] ?? ''));
+ $excludedAsFile = DATA_PATH . '/excluded_as.json';
+ $excludedAs = is_file($excludedAsFile) ? (json_decode((string) file_get_contents($excludedAsFile), true) ?: []) : [];
+ $excludedAs = array_values(array_filter($excludedAs, static fn ($a) => $a !== $asn));
+ @file_put_contents($excludedAsFile, json_encode($excludedAs, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
+ header('Content-Type: application/json');
+ echo json_encode(['ok' => true, 'asn' => $asn]);
+ exit;
+
case 'admin_create_role':
requireAuth();
if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') {
diff --git a/public/version.txt b/public/version.txt
index e038092..7337a93 100644
--- a/public/version.txt
+++ b/public/version.txt
@@ -1 +1 @@
-1.6.32
+1.6.33
diff --git a/src/AccessLogParser.php b/src/AccessLogParser.php
index 4a5fc88..d822641 100644
--- a/src/AccessLogParser.php
+++ b/src/AccessLogParser.php
@@ -25,7 +25,7 @@ class AccessLogParser
string $pattern = '*-access.log',
string $cacheFile = '',
int $cacheTtl = 600,
- int $days = 14,
+ int $days = 30,
array $botPatterns = []
) {
$this->logDir = rtrim($logDir, '/');
@@ -45,7 +45,8 @@ class AccessLogParser
* ips_by_day:array>,
* ip_top_paths:array>,
* ip_agents:array>,
- * all_uas:array
+ * all_uas:array,
+ * unique_visitors:array
* }
*/
public function stats(): array
@@ -121,15 +122,45 @@ class AccessLogParser
$ipTopAgents[$ip] = array_keys(array_slice($agents, 0, 5, true));
}
+ // Visiteurs uniques par période — calculé sur TOUS les IPs non-bot (pas seulement le top 200)
+ $uniqueVisitors = [7 => 0, 14 => 0, 30 => 0];
+ $start7 = $this->days - 7;
+ $start14 = $this->days - 14;
+ foreach ($ipAllDays as $ipDay) {
+ $active7 = $active14 = $active30 = false;
+ foreach ($ipDay as $offset => $cnt) {
+ if ($cnt <= 0) {
+ continue;
+ }
+ $active30 = true;
+ if ($offset >= $start14) {
+ $active14 = true;
+ }
+ if ($offset >= $start7) {
+ $active7 = true;
+ }
+ }
+ if ($active7) {
+ ++$uniqueVisitors[7];
+ }
+ if ($active14) {
+ ++$uniqueVisitors[14];
+ }
+ if ($active30) {
+ ++$uniqueVisitors[30];
+ }
+ }
+
$result = [
- 'pages' => $pages,
- 'books' => $books,
- 'ips' => $ips,
- 'pages_by_day' => $pagesByDay,
- 'ips_by_day' => $ipsByDay,
- 'ip_top_paths' => $ipTopPaths,
- 'ip_agents' => $ipTopAgents,
- 'all_uas' => array_slice($allUas, 0, 300, true),
+ 'pages' => $pages,
+ 'books' => $books,
+ 'ips' => $ips,
+ 'pages_by_day' => $pagesByDay,
+ 'ips_by_day' => $ipsByDay,
+ 'ip_top_paths' => $ipTopPaths,
+ 'ip_agents' => $ipTopAgents,
+ 'all_uas' => array_slice($allUas, 0, 300, true),
+ 'unique_visitors' => $uniqueVisitors,
];
@mkdir(dirname($this->cacheFile), 0755, true);
@file_put_contents($this->cacheFile, json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
diff --git a/templates/admin_stats.php b/templates/admin_stats.php
index 81340f8..398097f 100644
--- a/templates/admin_stats.php
+++ b/templates/admin_stats.php
@@ -6,8 +6,10 @@ $_books = $adminData['stats_books'] ?? [];
$_asList = $adminData['stats_as'] ?? [];
$_pagesByDay = $adminData['stats_pages_by_day'] ?? [];
$_ipData = $adminData['stats_ip_data'] ?? [];
-$_botPatterns = $adminData['bot_patterns'] ?? [];
-$_allUas = $adminData['stats_all_uas'] ?? [];
+$_botPatterns = $adminData['bot_patterns'] ?? [];
+$_allUas = $adminData['stats_all_uas'] ?? [];
+$_uniqueVisitors = $adminData['stats_unique_visitors'] ?? [7 => 0, 14 => 0, 30 => 0];
+$_excludedAs = $adminData['excluded_as'] ?? [];
?>
@@ -23,17 +25,21 @@ $_allUas = $adminData['stats_all_uas'] ?? [];
-
14 derniers jours · tous les chemins · flux RSS XML
+
30 derniers jours · tous les chemins · flux RSS XML
+
+