feat : onglet Statistiques — pages, livres, répartition AS avec groupes configurables

- AccessLogParser : parse COMBINED, agrège hits /post/ et /book/, cache 10 min
- AsnLookup : batch lookup ip-api.com, cache 30j, agrégation et groupes AS
- Onglet Statistiques dans l'admin : top pages, top livres, répartition réseau
- Filtrage par groupe AS (badges) + formulaire de configuration des groupes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-15 00:48:34 +02:00
parent dbd76556fb
commit 8cab6362a3
5 changed files with 666 additions and 1 deletions
+46 -1
View File
@@ -43,7 +43,7 @@ $action = $_GET['action'] ?? 'list';
$uuid = $_GET['uuid'] ?? '';
$slug = $_GET['slug'] ?? '';
$_noindexActions = ['create', 'edit', 'admin', 'categories', 'diff', 'add_files', 'import_image', 'import_image_step2', 'sources', 'profile', 'delete_file', 'delete_external_link', 'rename_category', 'delete_category', 'toggle_private_category', 'admin_save_site', 'not_found', 'add_feed', 'delete_feed', 'add_link', 'delete_link', 'reorder_links', 'react', 'comment', 'verify_comment', 'comment_moderate', 'comment_delete', 'comment_resend', 'create_tag_type', 'delete_tag_type', 'edit_tags', 'book_save', 'book_delete'];
$_noindexActions = ['create', 'edit', 'admin', 'categories', 'diff', 'add_files', 'import_image', 'import_image_step2', 'sources', 'profile', 'delete_file', 'delete_external_link', 'rename_category', 'delete_category', 'toggle_private_category', 'admin_save_site', 'not_found', 'add_feed', 'delete_feed', 'add_link', 'delete_link', 'reorder_links', 'react', 'comment', 'verify_comment', 'comment_moderate', 'comment_delete', 'comment_resend', 'create_tag_type', 'delete_tag_type', 'edit_tags', 'book_save', 'book_delete', 'admin_save_as_groups'];
$metaRobots = in_array($action, $_noindexActions, true) ? 'noindex, nofollow' : null;
unset($_noindexActions);
@@ -2542,6 +2542,27 @@ switch ($action) {
$adminData['search_log_readable'] = $parser->isReadable();
}
if ($tab === 'stats') {
if (!isAdmin()) {
http_response_code(403);
exit;
}
require_once BASE_PATH . '/src/AccessLogParser.php';
require_once BASE_PATH . '/src/AsnLookup.php';
$accessParser = new AccessLogParser('/var/log/apache2', apacheAccessLog());
$accessStats = $accessParser->stats();
$adminData['stats_readable'] = $accessParser->isReadable();
$adminData['stats_pages'] = array_slice($accessStats['pages'], 0, 30, true);
$adminData['stats_books'] = array_slice($accessStats['books'], 0, 20, true);
// Lookup AS pour les top 200 IPs
$topIps = array_slice($accessStats['ips'], 0, 200, true);
$asnMap = (new AsnLookup())->batchLookup(array_keys($topIps));
$asList = AsnLookup::aggregateByAs($topIps, $asnMap);
$adminData['stats_as'] = $asList;
$adminData['stats_as_groups'] = AsnLookup::applyGroups($asList, asGroups());
$adminData['as_groups'] = asGroups();
}
if ($tab === 'categories') {
$adminData['cats'] = $articles->getCategories();
$adminData['privateCats'] = $articles->getPrivateCategories();
@@ -2820,6 +2841,30 @@ switch ($action) {
header('Location: /admin/searches?' . ($ok ? 'saved=1' : 'error=write'));
exit;
case 'admin_save_as_groups':
requireAuth();
if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(403);
exit;
}
$rawLabels = $_POST['as_group_label'] ?? [];
$rawPatterns = $_POST['as_group_patterns'] ?? [];
$groups = [];
foreach ((array) $rawLabels as $i => $label) {
$label = trim((string) $label);
if ($label === '') {
continue;
}
$patterns = array_values(array_filter(array_map(
'trim',
explode("\n", (string) ($rawPatterns[$i] ?? ''))
)));
$groups[] = ['label' => $label, 'patterns' => $patterns];
}
$ok = saveSiteSettings(['as_groups' => $groups]);
header('Location: /admin/stats?' . ($ok ? 'saved=1' : 'error=write'));
exit;
case 'admin_create_role':
requireAuth();
if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') {