From 40656631ba8e2f42556ca0b712744a3f451d9a21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9drix?= Date: Tue, 19 May 2026 19:59:44 +0200 Subject: [PATCH] =?UTF-8?q?v1.6.28=20:=20drill-down=20IP=20par=20AS=20dans?= =?UTF-8?q?=20stats=20pays,=20suppression=20R=C3=A9partition=20par=20r?= =?UTF-8?q?=C3=A9seau?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Admin stats : clic sur un réseau AS affiche les IPs avec mini sparkline 14 jours + articles/livres consultés - AccessLogParser : calcul ip_data (daily + top paths) inclus dans le cache stats - Suppression du tableau statique "Répartition par réseau" (fusionné dans accordéon pays) - PHP-CS-Fixer appliqué sur l'ensemble des fichiers modifiés Co-Authored-By: Claude Sonnet 4.6 --- .php-cs-fixer.cache | 2 +- CHANGELOG.md | 11 +++ public/assets/js/admin-stats.js | 148 +++++++++++++++++++++++--------- public/index.php | 15 ++++ public/login/magic.php | 1 + public/tendances.php | 6 +- public/trending.php | 2 +- public/version.txt | 2 +- scripts/migrate_content.php | 2 +- src/AccessLogParser.php | 57 +++++++++--- src/BookManager.php | 2 +- src/DataGit.php | 4 +- src/Service/AiService.php | 8 +- src/SiteSettings.php | 8 +- src/TrendingParser.php | 3 +- src/helpers.php | 8 +- templates/admin.php | 28 +++--- templates/admin_stats.php | 74 +--------------- templates/books_list.php | 6 +- templates/layout.php | 15 ++-- templates/post_list.php | 6 +- templates/post_view.php | 10 +-- templates/wizard/step1.php | 4 +- 23 files changed, 248 insertions(+), 174 deletions(-) diff --git a/.php-cs-fixer.cache b/.php-cs-fixer.cache index 88f0298..c26a3ce 100644 --- a/.php-cs-fixer.cache +++ b/.php-cs-fixer.cache @@ -1 +1 @@ -{"php":"8.3.6","version":"3.89.1:v3.89.1#f34967da2866ace090a2b447de1f357356474573","indent":" ","lineEnding":"\n","rules":{"binary_operator_spaces":{"default":"at_least_single_space"},"blank_line_after_opening_tag":true,"blank_line_between_import_groups":true,"blank_lines_before_namespace":true,"braces_position":{"allow_single_line_empty_anonymous_classes":true},"class_definition":{"inline_constructor_arguments":false,"space_before_parenthesis":true},"compact_nullable_type_declaration":true,"declare_equal_normalize":true,"lowercase_cast":true,"lowercase_static_reference":true,"modifier_keywords":true,"new_with_parentheses":{"anonymous_class":true},"no_blank_lines_after_class_opening":true,"no_extra_blank_lines":{"tokens":["use"]},"no_leading_import_slash":true,"no_whitespace_in_blank_line":true,"ordered_class_elements":{"order":["use_trait"]},"ordered_imports":{"imports_order":["class","function","const"],"sort_algorithm":"none"},"return_type_declaration":true,"short_scalar_cast":true,"single_import_per_statement":{"group_to_single_imports":false},"single_space_around_construct":{"constructs_followed_by_a_single_space":["abstract","as","case","catch","class","const_import","do","else","elseif","final","finally","for","foreach","function","function_import","if","insteadof","interface","namespace","new","private","protected","public","static","switch","trait","try","use","use_lambda","while"],"constructs_preceded_by_a_single_space":["as","else","elseif","use_lambda"]},"single_trait_insert_per_statement":true,"ternary_operator_spaces":true,"unary_operator_spaces":{"only_dec_inc":true},"blank_line_after_namespace":true,"constant_case":true,"control_structure_braces":true,"control_structure_continuation_position":true,"elseif":true,"function_declaration":{"closure_fn_spacing":"one"},"indentation_type":true,"line_ending":true,"lowercase_keywords":true,"method_argument_space":{"after_heredoc":false,"attribute_placement":"ignore","on_multiline":"ensure_fully_multiline"},"no_break_comment":true,"no_closing_tag":true,"no_multiple_statements_per_line":true,"no_space_around_double_colon":true,"no_spaces_after_function_name":true,"no_trailing_whitespace":true,"no_trailing_whitespace_in_comment":true,"single_blank_line_at_eof":true,"single_class_element_per_statement":{"elements":["property"]},"single_line_after_imports":true,"spaces_inside_parentheses":true,"statement_indentation":true,"switch_case_semicolon_to_colon":true,"switch_case_space":true,"encoding":true,"full_opening_tag":true,"strict_param":true,"declare_strict_types":true,"no_unused_imports":true,"single_quote":true},"hashes":{"templates\/header.php":"f4b64c4ecb4dadec166cb7935309096a","templates\/footer.php":"3111b4701ea698ba11c3423260657e28","public\/login\/oidc.php":"8ec86d6f3af33f64d586109ec17f817d","public\/login\/config.php":"5b7b3e2937b349c76a2fd239c3ae06f8","src\/Infrastructure\/DbAdapter.php":"3899a835130c146e2d30dbcca88d8f33","src\/Infrastructure\/Database.php":"6f2848ed70b29d9c2e2d259be611b9b0","src\/Infrastructure\/Session.php":"3538a1147cc81678c470d45ea8574a95","src\/Domain\/User.php":"02213454f7edf43f4afae3f2f81aaf01","src\/Http\/Csrf.php":"55631812cab4b1192f8e30c5d35fd5eb","src\/FileManager.php":"a51dda44f293f238aea295fd56b2fa99","src\/PostManager.php":"25f0179c4d96e9aa04218d54bf45a029","src\/db.php":"8888b7fbc9740eb3c60dd2374d0cb5d6","src\/ConfigRepo.php":"c2dcee160a272d27725d480a90e76dcf","src\/Parsedown.php":"85da2b47eca1a703fdfe44753bf912df","src\/Service\/Validator.php":"7c267b8b9f3f1bac0f2520dd10364831","src\/Service\/UiFormRenderer.php":"065617191c6d680ce97588f4fa159688","src\/Repository\/DictionnaryRepository.php":"f937e98cf0f27b59ae00e430b52a586d","src\/Repository\/ProfileRepository.php":"b1cd483652500ee4e2aaaa9e0330ff1d","phpstan-bootstrap.php":"d74864c2f107b740523f070d077d715e","src\/Service\/MailQueue.php":"20db418b83dcf426b7c6ad6787644cde","src\/Service\/AuthService.php":"f95a9ab097dcfc4ac6cbcf908cf4cd90","src\/Repository\/UserRepository.php":"d0ccc80374b54a5c4f20cb04c00fb083","src\/Service\/MailService.php":"7bff5df8cac3274a1a4ab8fe137514f2","public\/file.php":"4e9de9cbe565e895e7bd809028754cb9","public\/logout.php":"68bc31b06a9e23aa7a43ca7642365bcd","public\/login\/magic.php":"3d9447ed551e6401c3d43429b1600247","public\/route.php":"8a478892d21a95352cae93b85169f424","database\/migrate.php":"259bacce606e05eadfa5d6ca8f5fe0d5","database\/migrate-init.php":"55c7b9bf5fb04a2ba434b2b6a59a87c6","templates\/author_articles.php":"dc9d7ebade3f7b8c551c5f99a5439509","templates\/sources.php":"42aa657413768450b5d39d0c540fb80a","templates\/comments_section.php":"652632dbfd033d7c6910bccfeaeed62b","templates\/edit_tags.php":"a06766336e2904292821af40679f4e36","templates\/import_image.php":"273bf10d41a750e750c21f5d68d109e7","templates\/flux.php":"f5895686e72c8bce875fbb3099cb75b2","templates\/author_profile.php":"c85167ec05d9e75bcd14459c9b98fbca","templates\/admin_role_edit.php":"eb5cfd7fc3f89a696871feb6fddb9e73","templates\/profile.php":"02bc0f8e3b6ff4cfb0f65b4bf5c11dc1","templates\/admin.php":"4c4ce239c7286879b48d33e7e5f0f9dd","templates\/404.php":"af571fc91a824de8c14d505e989e1423","templates\/post_confirm.php":"8b2ed7733d6384a2c9164a69545886a0","templates\/categories.php":"9e5bc9719de64c9e3cb37d2842920d7e","templates\/search.php":"9370b3a475966b36afa1fc6fb38b250d","templates\/liens.php":"2f597438502f97c43880c8574d7ef872","templates\/add_files.php":"85169163967fe62fdd63260bdbeea8c4","templates\/import_image_step2.php":"7a8b7087c39d947b844fe6708b888a89","templates\/diff.php":"0d0b379522675cb33719a27e092555a0","templates\/copyright_ack.php":"94524eaf5f07e4e0b99b39a39a36dedb","public\/sitemap.php":"4d81e9f04290e5160c5e9216fc4997ad","public\/feed.php":"fdbb086c3f9002373b22fdf25b29a861","src\/CommentManager.php":"6f2660a76b738226d8c44e0c411a9fad","src\/RatingManager.php":"4f1dd5924facfab0e0d60c30ae9b31af","src\/FeedFetcher.php":"e03c0e6a24715dc204c61d74ab5bdbec","src\/ReactionManager.php":"1e383e3fb6dc68d91f0c4ebffea8df8b","src\/SmtpSettings.php":"02285cd4d4bd2ea1ad3223a9474cc1a3","src\/SiteSettings.php":"c2db0927622a3e83d6b69f235aec3a53","src\/SearchLogParser.php":"def0c80a3f8ba9b32c151c4632aa34f9","src\/SearchEngine.php":"1c1934b0ac81223566c5440d4c23fd3c","src\/TagSuggester.php":"94a15f13cef084ac1ae9e195c7ec47e7","templates\/post_view.php":"8b5cf7b5a16dfe6c186b71eba001019b","templates\/wizard\/step6.php":"4bd662c4484fedabc027234b77b7fc83","templates\/wizard\/step4.php":"dc67f6460a068bdf11908727f3157378","templates\/wizard\/nav.php":"c752cfc72cc683b16020b834eac21aea","templates\/wizard\/step1.php":"e1d11677587a3c0902ec1e49a134dc97","templates\/wizard\/step2.php":"4e34d77097ee53c1e8e7e20ed3a2f629","templates\/wizard\/step3.php":"7d1c7ec84e6d60b50be96e7a09d4e2af","templates\/wizard\/step5.php":"8edce714601d0267db0c2791fe10a418","templates\/licenses.php":"38606fef87b66034927714ca0634b0f6","templates\/post_list.php":"74671463ca3b98ebaf426ebcf9764e74","templates\/layout.php":"c9c5de1620a949ea0f7d9532984e38af","templates\/contact.php":"fe723ed1f97d5b1e94c7b2c19b6c8433","templates\/post_form.php":"895ee0e15b2d8f22ee7067d4c4ccd716","templates\/legal.php":"024ab0c9ede825c85cc8251071dab508","templates\/about.php":"a4e7bbd5b6268455befdcc37642e6eb7","bootstrap.php":"5b4f17ddc425d4a7add3a9fa857cc878","public\/login\/index.php":"cadec933ef617c451d1723bbe6da6173","public\/oidc\/callback.php":"20e138166d892491d3671a73994cf49a","public\/oidc\/start.php":"6505a18cf7028c6614140f9d0a5a2d24","public\/oidc\/me.php":"6ecfed0e783a214550279b035d96ae13","src\/helpers.php":"3f8ff8b9b179d2739afb58ea708e4291","src\/auth.php":"766190d929a5036f67ba91bb3abb6b1f","src\/ArticleManager.php":"26143d96b991f6b2df3e2f63e496f180","src\/mailer.php":"065fc7a1e39c929d0a14fc544846ead8","versions.php":"08447f315d2ac3ad9ce0bd76b10afa24","config\/config.php":"db35fc35b1ec6c20e2ddd4f6cd80bd61","public\/index.php":"bac67ffb25a948ea3d31d54151ba0ff5"}} \ No newline at end of file +{"php":"8.3.6","version":"3.89.1:v3.89.1#f34967da2866ace090a2b447de1f357356474573","indent":" ","lineEnding":"\n","rules":{"binary_operator_spaces":{"default":"at_least_single_space"},"blank_line_after_opening_tag":true,"blank_line_between_import_groups":true,"blank_lines_before_namespace":true,"braces_position":{"allow_single_line_empty_anonymous_classes":true},"class_definition":{"inline_constructor_arguments":false,"space_before_parenthesis":true},"compact_nullable_type_declaration":true,"declare_equal_normalize":true,"lowercase_cast":true,"lowercase_static_reference":true,"modifier_keywords":true,"new_with_parentheses":{"anonymous_class":true},"no_blank_lines_after_class_opening":true,"no_extra_blank_lines":{"tokens":["use"]},"no_leading_import_slash":true,"no_whitespace_in_blank_line":true,"ordered_class_elements":{"order":["use_trait"]},"ordered_imports":{"imports_order":["class","function","const"],"sort_algorithm":"none"},"return_type_declaration":true,"short_scalar_cast":true,"single_import_per_statement":{"group_to_single_imports":false},"single_space_around_construct":{"constructs_followed_by_a_single_space":["abstract","as","case","catch","class","const_import","do","else","elseif","final","finally","for","foreach","function","function_import","if","insteadof","interface","namespace","new","private","protected","public","static","switch","trait","try","use","use_lambda","while"],"constructs_preceded_by_a_single_space":["as","else","elseif","use_lambda"]},"single_trait_insert_per_statement":true,"ternary_operator_spaces":true,"unary_operator_spaces":{"only_dec_inc":true},"blank_line_after_namespace":true,"constant_case":true,"control_structure_braces":true,"control_structure_continuation_position":true,"elseif":true,"function_declaration":{"closure_fn_spacing":"one"},"indentation_type":true,"line_ending":true,"lowercase_keywords":true,"method_argument_space":{"after_heredoc":false,"attribute_placement":"ignore","on_multiline":"ensure_fully_multiline"},"no_break_comment":true,"no_closing_tag":true,"no_multiple_statements_per_line":true,"no_space_around_double_colon":true,"no_spaces_after_function_name":true,"no_trailing_whitespace":true,"no_trailing_whitespace_in_comment":true,"single_blank_line_at_eof":true,"single_class_element_per_statement":{"elements":["property"]},"single_line_after_imports":true,"spaces_inside_parentheses":true,"statement_indentation":true,"switch_case_semicolon_to_colon":true,"switch_case_space":true,"encoding":true,"full_opening_tag":true,"strict_param":true,"declare_strict_types":true,"no_unused_imports":true,"single_quote":true},"hashes":{"templates\/header.php":"f4b64c4ecb4dadec166cb7935309096a","templates\/footer.php":"3111b4701ea698ba11c3423260657e28","public\/login\/oidc.php":"8ec86d6f3af33f64d586109ec17f817d","public\/login\/config.php":"5b7b3e2937b349c76a2fd239c3ae06f8","src\/Infrastructure\/DbAdapter.php":"3899a835130c146e2d30dbcca88d8f33","src\/Infrastructure\/Database.php":"6f2848ed70b29d9c2e2d259be611b9b0","src\/Infrastructure\/Session.php":"3538a1147cc81678c470d45ea8574a95","src\/Domain\/User.php":"02213454f7edf43f4afae3f2f81aaf01","src\/Http\/Csrf.php":"55631812cab4b1192f8e30c5d35fd5eb","src\/FileManager.php":"a51dda44f293f238aea295fd56b2fa99","src\/PostManager.php":"25f0179c4d96e9aa04218d54bf45a029","src\/db.php":"8888b7fbc9740eb3c60dd2374d0cb5d6","src\/ConfigRepo.php":"c2dcee160a272d27725d480a90e76dcf","src\/Parsedown.php":"85da2b47eca1a703fdfe44753bf912df","src\/Service\/Validator.php":"7c267b8b9f3f1bac0f2520dd10364831","src\/Service\/UiFormRenderer.php":"065617191c6d680ce97588f4fa159688","src\/Repository\/DictionnaryRepository.php":"f937e98cf0f27b59ae00e430b52a586d","src\/Repository\/ProfileRepository.php":"b1cd483652500ee4e2aaaa9e0330ff1d","src\/Service\/MailQueue.php":"20db418b83dcf426b7c6ad6787644cde","src\/Service\/AuthService.php":"f95a9ab097dcfc4ac6cbcf908cf4cd90","src\/Repository\/UserRepository.php":"d0ccc80374b54a5c4f20cb04c00fb083","src\/Service\/MailService.php":"7bff5df8cac3274a1a4ab8fe137514f2","public\/route.php":"8a478892d21a95352cae93b85169f424","database\/migrate.php":"259bacce606e05eadfa5d6ca8f5fe0d5","database\/migrate-init.php":"55c7b9bf5fb04a2ba434b2b6a59a87c6","templates\/import_image.php":"273bf10d41a750e750c21f5d68d109e7","templates\/admin_role_edit.php":"eb5cfd7fc3f89a696871feb6fddb9e73","templates\/profile.php":"02bc0f8e3b6ff4cfb0f65b4bf5c11dc1","templates\/404.php":"af571fc91a824de8c14d505e989e1423","templates\/categories.php":"9e5bc9719de64c9e3cb37d2842920d7e","templates\/search.php":"9370b3a475966b36afa1fc6fb38b250d","templates\/liens.php":"2f597438502f97c43880c8574d7ef872","templates\/add_files.php":"85169163967fe62fdd63260bdbeea8c4","templates\/diff.php":"0d0b379522675cb33719a27e092555a0","templates\/copyright_ack.php":"94524eaf5f07e4e0b99b39a39a36dedb","src\/CommentManager.php":"6f2660a76b738226d8c44e0c411a9fad","src\/RatingManager.php":"4f1dd5924facfab0e0d60c30ae9b31af","src\/FeedFetcher.php":"e03c0e6a24715dc204c61d74ab5bdbec","src\/ReactionManager.php":"1e383e3fb6dc68d91f0c4ebffea8df8b","src\/SearchEngine.php":"1c1934b0ac81223566c5440d4c23fd3c","src\/TagSuggester.php":"94a15f13cef084ac1ae9e195c7ec47e7","templates\/wizard\/step4.php":"dc67f6460a068bdf11908727f3157378","templates\/wizard\/nav.php":"c752cfc72cc683b16020b834eac21aea","templates\/wizard\/step2.php":"4e34d77097ee53c1e8e7e20ed3a2f629","templates\/wizard\/step3.php":"7d1c7ec84e6d60b50be96e7a09d4e2af","templates\/licenses.php":"38606fef87b66034927714ca0634b0f6","templates\/contact.php":"fe723ed1f97d5b1e94c7b2c19b6c8433","templates\/legal.php":"024ab0c9ede825c85cc8251071dab508","templates\/about.php":"a4e7bbd5b6268455befdcc37642e6eb7","public\/oidc\/me.php":"6ecfed0e783a214550279b035d96ae13","src\/auth.php":"766190d929a5036f67ba91bb3abb6b1f","src\/mailer.php":"065fc7a1e39c929d0a14fc544846ead8","versions.php":"08447f315d2ac3ad9ce0bd76b10afa24","templates\/books_list.php":"adbb64e2368c17144341fb06c8f26e34","templates\/book.php":"114909571c37d16d1759351c6d70d5d2","templates\/maintenance.php":"3c6d4eab6e4578ac95ebb702d7f5fd22","public\/tendances.php":"db13e1e9823bfb9f3cbb12b2050aea4c","public\/trending.php":"8c358712214c7b977486debbf3a97185","src\/UpdateChecker.php":"297ea2f4490b801d907e9ec5a3a90787","src\/BookManager.php":"cacf5daef98820bb587171500df6437d","src\/AsnLookup.php":"248695dd802b4043af1f558b3a544af1","src\/DataGit.php":"fee62d90d636a2aa2ab8465ff3172c25","src\/TrendingParser.php":"3271a00ef5ded0fbf860ca9444a6528d","src\/Service\/AiService.php":"61e8bad5e91678c4d16e81850939352b","scripts\/content\/migration_001_add_h1_headings.php":"9e8fc777e3efab9156f6965c23221d5b","scripts\/migrate_content.php":"d407e1f89779d85b2b63144b1474d2d6","templates\/post_view.php":"00d75c3754e28ced9c62190b951cf4a7","templates\/author_articles.php":"7b381a499a893374a381ff074c1788bf","templates\/wizard\/step6.php":"db502b5ddc9fdd65eef39618427766c4","templates\/wizard\/step1.php":"6b68f30d012f179a19714a319ef329db","templates\/wizard\/step5.php":"0ba64cc326195ebf5a83130b8202b244","templates\/sources.php":"4804a58883c9ae03bf08e765ae2347be","templates\/comments_section.php":"2f1ad31be49310d24ed9acc007ac6b12","templates\/edit_tags.php":"56c43909f65b0679b1183c73339e76f0","templates\/flux.php":"fa8a85df7a31ea81908114c8e2e205ea","templates\/author_profile.php":"58fc68ba7a15f1f3e2c007594380c96d","templates\/admin.php":"d4df9a5f9e8032c6ac505b44962e73ce","templates\/post_list.php":"067facfece781547d4dba9505f817101","templates\/post_confirm.php":"fadf46210614c0afa544641fc8b32707","templates\/layout.php":"ed106b0fa8618892375cc580e586c559","templates\/import_image_step2.php":"0825e6111fb7ccf0dc0424ffea92f45e","templates\/post_form.php":"9b64c181920dc77124884091b61005b4","bootstrap.php":"dbf9bac00fb48332e91f3c34eb641588","public\/login\/magic.php":"dc6d92754c091ebf7c2a9d3260964e14","public\/login\/index.php":"c6b171b21b8fec11882fa72b62bea78a","public\/sitemap.php":"fdce643ea6a0f02e0cd97876c6f632cd","public\/feed.php":"bb882110ce0f90b4fd50ec7ef51d57c8","public\/file.php":"d42b8625165619abc5a85c2855a52735","public\/logout.php":"06679fd2f0d3776b63c199fdbe788658","public\/oidc\/callback.php":"7b71d9f827acd341ceb379c86b5f938e","public\/oidc\/start.php":"e99af3eadaac8913bf2a3e285cc0a94d","public\/index.php":"843af843770aafb510b8d26a31f0bce4","src\/SmtpSettings.php":"9c5a2d9e6ba89e44c7c9dd0e09d6561f","src\/helpers.php":"0dedc680d8a9ea286464dc67bc75df02","src\/SiteSettings.php":"2967b3c22bdeb719687f92ee770e8563","src\/SearchLogParser.php":"db525efc963fb1472526cdb914fc5849","phpstan-bootstrap.php":"157c13fc6c52b1f595cb351ab54340d3","config\/config.php":"3931c9764f339a06e8f2e3d03829d7ad","templates\/admin_stats.php":"1aea3af38a0508e26200df38ee7cb7da","src\/AccessLogParser.php":"b5d6cab857670479e2f1cc17f334e126","src\/ArticleManager.php":"c8f4b26c38d2526e54157b96bdeff3c3"}} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c0edf9..a40e590 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ Format : [Keep a Changelog](https://keepachangelog.com/fr/1.0.0/) — versionnag --- +## [1.6.28] - 2026-05-19 + +### Ajouté +- Admin stats : drill-down AS → IPs dans l'accordéon « Visiteurs par pays » — mini sparkline 14 jours + articles/livres consultés par IP +- Admin stats : `ip_data` dans le cache stats (daily + top paths par IP publique) + +### Supprimé +- Admin stats : section « Répartition par réseau » (fusionnée dans l'accordéon pays) + +--- + ## [1.6.27] - 2026-05-19 ### Ajouté diff --git a/public/assets/js/admin-stats.js b/public/assets/js/admin-stats.js index 50865a4..7aa88c0 100644 --- a/public/assets/js/admin-stats.js +++ b/public/assets/js/admin-stats.js @@ -1,35 +1,63 @@ -/* Admin stats : groupes AS + chargement pages via flux RSS XML /trending?period=14d */ +/* Admin stats : graphiques, sparklines, accordéon pays/AS/IP */ +function esc(s) { + return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} // ── Visiteurs par pays ──────────────────────────────────────────────────────── (function () { var el = document.getElementById('stats-country-container'); var asList = (typeof FOLIO_AS_LIST !== 'undefined') ? FOLIO_AS_LIST : []; + var ipData = (typeof FOLIO_IP_DATA !== 'undefined') ? FOLIO_IP_DATA : {}; if (!el || !asList.length) { return; } - // Noms de pays en français via l'API Intl var dispNames = null; try { dispNames = new Intl.DisplayNames(['fr'], { type: 'region' }); } catch (e) {} function countryName(code) { if (!code || code === '??') { return 'Inconnu'; } try { return dispNames ? dispNames.of(code) : code; } catch (e) { return code; } } - - // Drapeau emoji depuis le code ISO-2 function flag(code) { if (!code || code.length !== 2) { return ''; } - var cp = Array.from(code.toUpperCase()).map(function (c) { - return 0x1F1E6 + c.charCodeAt(0) - 65; - }); - return String.fromCodePoint(cp[0], cp[1]) + ' '; + var cp = Array.from(code.toUpperCase()).map(function (c) { return 0x1F1E6 + c.charCodeAt(0) - 65; }); + return String.fromCodePoint(cp[0], cp[1]) + ' '; + } + + // Index IPs par ASN pour le drill-down + var ipsByAsn = {}; + Object.keys(ipData).forEach(function (ip) { + var d = ipData[ip]; + var key = d.asn || '__unknown__'; + if (!ipsByAsn[key]) { ipsByAsn[key] = []; } + ipsByAsn[key].push({ ip: ip, hits: d.hits, daily: d.daily, paths: d.paths }); + }); + Object.keys(ipsByAsn).forEach(function (k) { + ipsByAsn[k].sort(function (a, b) { return b.hits - a.hits; }); + }); + + // Mini sparkline (80×20px polyline) pour chaque IP + function ipSparkline(daily) { + if (!daily || !daily.length) { return ''; } + var W = 80, H = 20, padX = 1, padY = 2; + var max = Math.max.apply(null, daily) || 1; + var n = daily.length; + var pts = daily.map(function (v, i) { + var x = padX + i * (W - 2 * padX) / (n - 1); + var y = H - padY - (v / max) * (H - 2 * padY); + return x.toFixed(1) + ',' + y.toFixed(1); + }).join(' '); + return '' + + '' + + ''; } // Agréger par pays - var byCountry = {}; - var asByCountry = {}; // country → [{name, asn, hits}] + var byCountry = {}, asByCountry = {}; asList.forEach(function (as) { var c = as.country || '??'; - byCountry[c] = (byCountry[c] || 0) + as.hits; + byCountry[c] = (byCountry[c] || 0) + as.hits; if (!asByCountry[c]) { asByCountry[c] = []; } asByCountry[c].push(as); }); @@ -41,26 +69,83 @@ if (!countries.length) { el.innerHTML = '

Aucune donnée.

'; return; } var maxH = countries[0].hits || 1; - var html = '
'; - countries.forEach(function (c, i) { + + 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-' + i; + 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) { - var npct = Math.round(n.hits / maxN * 100); - return '
' - + '
' - + (n.name || '?') + (n.asn ? ' AS' + n.asn + '' : '') + '
' + 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] || []; + + // Lignes IP avec mini sparkline + chemins + var ipRows = ips.slice(0, 20).map(function (ipInfo) { + var articles = [], books = []; + Object.keys(ipInfo.paths || {}).forEach(function (path) { + var cnt = ipInfo.paths[path]; + if (path.indexOf('/post/') === 0) { articles.push({ path: path, cnt: cnt }); } + else if (path.indexOf('/book/') === 0) { books.push({ path: path, cnt: cnt }); } + }); + articles.sort(function (a, b) { return b.cnt - a.cnt; }); + books.sort(function (a, b) { return b.cnt - a.cnt; }); + + var pathsHtml = ''; + if (articles.length) { + pathsHtml += '
Articles : ' + + articles.slice(0, 3).map(function (p) { + var slug = decodeURIComponent(p.path.replace('/post/', '')); + return '' + + esc(slug.length > 28 ? slug.slice(0, 28) + '…' : slug) + + ' (' + p.cnt + ')'; + }).join(', ') + '
'; + } + if (books.length) { + pathsHtml += '
Livres : ' + + books.slice(0, 3).map(function (p) { + var slug = decodeURIComponent(p.path.replace('/book/', '')); + return '' + + esc(slug.length > 28 ? slug.slice(0, 28) + '…' : slug) + + ' (' + p.cnt + ')'; + }).join(', ') + '
'; + } + if (!pathsHtml) { pathsHtml = ''; } + + return '
' + + '' + + esc(ipInfo.ip) + '' + + ipSparkline(ipInfo.daily || []) + + '
' + pathsHtml + '
' + + '
' + + (ipInfo.hits || 0).toLocaleString('fr-FR') + '
' + + '
'; + }).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 + '
' + '
' + '
' + '
' + '
' + n.hits.toLocaleString('fr-FR') + '
' + + '
' + + (hasIps ? '
' + + '
' + ipRows + '
' + + '
' : '') + '
'; }).join(''); @@ -80,6 +165,7 @@ + '
' + '
'; }); + html += ''; el.innerHTML = html; }()); @@ -91,10 +177,6 @@ var pagesByDay = (typeof FOLIO_PAGES_BY_DAY !== 'undefined') ? FOLIO_PAGES_BY_DAY : {}; if (!container) { return; } - function esc(s) { - return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); - } - function sparkline(data) { var W = 120, H = 28, padX = 2, padY = 3; var max = Math.max.apply(null, data) || 1; @@ -104,7 +186,6 @@ var y = H - padY - (v / max) * (H - 2 * padY); return x.toFixed(1) + ',' + y.toFixed(1); }).join(' '); - // Zone remplie sous la courbe var first = padX.toFixed(1) + ',' + (H - padY).toFixed(1); var last = (W - padX).toFixed(1) + ',' + (H - padY).toFixed(1); return '' @@ -123,17 +204,15 @@ var n = totals.length; var VW = 900, VH = 480; - var ml = 44, mr = 12, mt = 12, mb = 28; // marges pour axes + var ml = 44, mr = 12, mt = 12, mb = 28; var W = VW - ml - mr; var H = VH - mt - mb; - // Échelle Y — plafond arrondi à un multiple "propre" var rawMax = Math.max.apply(null, totals) || 1; var mag = Math.pow(10, Math.floor(Math.log(rawMax) / Math.LN10)); var maxV = Math.ceil(rawMax / mag) * mag; var nTicks = 4; - // Dates var now = new Date(); var labels = totals.map(function (_, i) { var d = new Date(now); @@ -141,17 +220,10 @@ return d.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' }); }); - // Coordonnées des points var pts = totals.map(function (v, i) { - return { - x: ml + i * W / (n - 1), - y: mt + H - (v / maxV) * H, - v: v, - l: labels[i] - }; + return { x: ml + i * W / (n - 1), y: mt + H - (v / maxV) * H, v: v, l: labels[i] }; }); - // Courbe bezier lisse (tension 0.35) function smoothPath(points) { var d = 'M ' + points[0].x.toFixed(1) + ' ' + points[0].y.toFixed(1); for (var i = 0; i < points.length - 1; i++) { @@ -176,7 +248,6 @@ + ' L ' + pts[n - 1].x.toFixed(1) + ' ' + (mt + H) + ' L ' + pts[0].x.toFixed(1) + ' ' + (mt + H) + ' Z'; - // Grille horizontale + labels Y var grid = '', yLabels = ''; for (var t = 0; t <= nTicks; t++) { var val = Math.round(maxV * t / nTicks); @@ -187,7 +258,6 @@ + ' font-size="11" fill="#adb5bd">' + val + ''; } - // Labels X (toutes les 2 dates + dernière) var xLabels = ''; pts.forEach(function (p, i) { if (i % 2 === 0 || i === n - 1) { @@ -196,7 +266,6 @@ } }); - // Points interactifs (cercle invisible + tooltip natif) var dots = pts.map(function (p) { return '' @@ -238,7 +307,6 @@ var W = VW - ml - mr; var H = VH - mt - mb; - // Top articles par total (max 10), dans l'ordre du RSS var series = []; rssRows.forEach(function (row) { var pm = row.link.match(/\/post\/[^?#]*/); @@ -312,7 +380,6 @@ + '" stroke-width="1.8" stroke-linejoin="round" stroke-linecap="round"/>' + dots; }).join(''); - // Légende var legend = series.map(function (s, si) { var color = COLORS[si % COLORS.length]; var short = s.title.length > 32 ? s.title.slice(0, 32) + '…' : s.title; @@ -351,7 +418,6 @@ var daily = pm ? (pagesByDay[pm[0]] || null) : null; return { title: title, link: link, slug: slug, vis: vis, daily: daily }; }); - // Graphique global : somme de tous les articles par jour var nDays = 14; var totals = new Array(nDays).fill(0); Object.values(pagesByDay).forEach(function (arr) { diff --git a/public/index.php b/public/index.php index 1ebcdd3..7e275d3 100644 --- a/public/index.php +++ b/public/index.php @@ -2738,11 +2738,25 @@ switch ($action) { $topIps = array_slice($accessStats['ips'], 0, 200, true); $asnMap = (new AsnLookup())->batchLookup(array_keys($topIps)); + $ipData = []; + foreach ($accessStats['ips_by_day'] ?? [] as $ip => $daily) { + $info = $asnMap[$ip] ?? ['asn' => '', 'name' => '?', 'country' => '']; + $ipData[$ip] = [ + 'hits' => $topIps[$ip] ?? (int) array_sum($daily), + 'asn' => $info['asn'], + 'name' => $info['name'], + 'country' => $info['country'], + 'daily' => $daily, + 'paths' => $accessStats['ip_top_paths'][$ip] ?? [], + ]; + } + $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, ]; @file_put_contents($statsCacheFile, json_encode($statsRaw)); } @@ -2752,6 +2766,7 @@ switch ($action) { $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'] ?? []; } if ($tab === 'categories') { diff --git a/public/login/magic.php b/public/login/magic.php index ddac7f9..b86ed4e 100644 --- a/public/login/magic.php +++ b/public/login/magic.php @@ -1,4 +1,5 @@ '8 h', '14d' => '8 h', '30d' => '8 h', '1y' => '8 h', ]; - foreach (TENDANCES_PERIODS as $p => $info): - $url = $base . '/trending?period=' . rawurlencode($p); - ?> +foreach (TENDANCES_PERIODS as $p => $info): + $url = $base . '/trending?period=' . rawurlencode($p); + ?>
diff --git a/public/trending.php b/public/trending.php index a0d741e..43c0ce0 100644 --- a/public/trending.php +++ b/public/trending.php @@ -122,7 +122,7 @@ echo '' . "\n"; $title = htmlspecialchars(($a['title'] ?? ''), ENT_XML1); $plural = $v > 1 ? 's' : ''; $desc = htmlspecialchars($title . ' — ' . $v . ' visiteur' . $plural . ' unique' . $plural . ' (' . $label . ')', ENT_XML1); -?> + ?> ( visiteur) diff --git a/public/version.txt b/public/version.txt index 8bfaa5b..10010a4 100644 --- a/public/version.txt +++ b/public/version.txt @@ -1 +1 @@ -1.6.27 +1.6.28 diff --git a/scripts/migrate_content.php b/scripts/migrate_content.php index dc491b6..ff7b3d5 100644 --- a/scripts/migrate_content.php +++ b/scripts/migrate_content.php @@ -57,7 +57,7 @@ foreach ($pending as $file) { echo "✓\n"; $count++; } catch (Throwable $e) { - echo "✗ " . $e->getMessage() . "\n"; + echo '✗ ' . $e->getMessage() . "\n"; $errors++; break; } diff --git a/src/AccessLogParser.php b/src/AccessLogParser.php index 5fd84c3..54ae405 100644 --- a/src/AccessLogParser.php +++ b/src/AccessLogParser.php @@ -30,7 +30,7 @@ class AccessLogParser } /** - * @return array{pages:array,books:array,ips:array,pages_by_day:array>} + * @return array{pages:array,books:array,ips:array,pages_by_day:array>,ips_by_day:array>,ip_top_paths:array>} */ public function stats(): array { @@ -48,19 +48,19 @@ class AccessLogParser $pages = []; $books = []; $ips = []; - $dayPages = []; // [path => [dayOffset => count]], dayOffset 0=oldest + $dayPages = []; + $ipDays = []; // [ip => [dayOffset => count]] + $ipPaths = []; // [ip => [path => count]] foreach ($this->logFiles() as $file) { - $this->parseFile($file, $cutoff, $pages, $books, $ips, $dayPages); + $this->parseFile($file, $cutoff, $pages, $books, $ips, $dayPages, $ipDays, $ipPaths); } arsort($pages); arsort($books); arsort($ips); - // Normalise dayPages : pour chaque page, tableau de $this->days entiers (index 0 = le plus ancien) $pagesByDay = []; - $today = (int) strtotime('today midnight'); foreach ($dayPages as $path => $byOffset) { $arr = array_fill(0, $this->days, 0); foreach ($byOffset as $offset => $count) { @@ -71,7 +71,32 @@ class AccessLogParser $pagesByDay[$path] = $arr; } - $result = ['pages' => $pages, 'books' => $books, 'ips' => $ips, 'pages_by_day' => $pagesByDay]; + // Per-IP daily counts + top paths, limité aux 200 IPs les plus actives + $topIpKeys = array_keys(array_slice($ips, 0, 200, true)); + $ipsByDay = []; + $ipTopPaths = []; + foreach ($topIpKeys as $ip) { + $arr = array_fill(0, $this->days, 0); + foreach ($ipDays[$ip] ?? [] as $offset => $count) { + if ($offset >= 0 && $offset < $this->days) { + $arr[$offset] = $count; + } + } + $ipsByDay[$ip] = $arr; + + $paths = $ipPaths[$ip] ?? []; + arsort($paths); + $ipTopPaths[$ip] = array_slice($paths, 0, 10, true); + } + + $result = [ + 'pages' => $pages, + 'books' => $books, + 'ips' => $ips, + 'pages_by_day' => $pagesByDay, + 'ips_by_day' => $ipsByDay, + 'ip_top_paths' => $ipTopPaths, + ]; @mkdir(dirname($this->cacheFile), 0755, true); @file_put_contents($this->cacheFile, json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); return self::$memo = $result; @@ -127,7 +152,7 @@ class AccessLogParser return (int) strtotime("{$m[1]} {$m[2]} {$m[3]} {$m[4]} {$m[5]}"); } - private function parseLine(string $line, int $cutoff, array &$pages, array &$books, array &$ips, array &$dayPages): void + private function parseLine(string $line, int $cutoff, array &$pages, array &$books, array &$ips, array &$dayPages, array &$ipDays, array &$ipPaths): void { if (!preg_match(self::RE, $line, $m)) { return; @@ -142,24 +167,28 @@ class AccessLogParser return; } - $publicIp = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false; + $publicIp = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false; + $dayOffset = (int) floor(($tsVal - $cutoff) / 86400); if (str_starts_with($path, '/post/') && strlen($path) > 6) { $pages[$path] = ($pages[$path] ?? 0) + 1; if ($publicIp) { $ips[$ip] = ($ips[$ip] ?? 0) + 1; } - $dayOffset = (int) floor(($tsVal - $cutoff) / 86400); - $dayPages[$path][$dayOffset] = ($dayPages[$path][$dayOffset] ?? 0) + 1; + $dayPages[$path][$dayOffset] = ($dayPages[$path][$dayOffset] ?? 0) + 1; + $ipDays[$ip][$dayOffset] = ($ipDays[$ip][$dayOffset] ?? 0) + 1; + $ipPaths[$ip][$path] = ($ipPaths[$ip][$path] ?? 0) + 1; } elseif (str_starts_with($path, '/book/') && strlen($path) > 6) { $books[$path] = ($books[$path] ?? 0) + 1; if ($publicIp) { $ips[$ip] = ($ips[$ip] ?? 0) + 1; } + $ipDays[$ip][$dayOffset] = ($ipDays[$ip][$dayOffset] ?? 0) + 1; + $ipPaths[$ip][$path] = ($ipPaths[$ip][$path] ?? 0) + 1; } } - private function parseFile(array $file, int $cutoff, array &$pages, array &$books, array &$ips, array &$dayPages): void + private function parseFile(array $file, int $cutoff, array &$pages, array &$books, array &$ips, array &$dayPages, array &$ipDays, array &$ipPaths): void { if ($file['type'] === 'tgz') { try { @@ -170,7 +199,7 @@ class AccessLogParser continue; } foreach (explode("\n", $content) as $line) { - $this->parseLine($line, $cutoff, $pages, $books, $ips, $dayPages); + $this->parseLine($line, $cutoff, $pages, $books, $ips, $dayPages, $ipDays, $ipPaths); } } } catch (\Exception $e) { @@ -183,7 +212,7 @@ class AccessLogParser while (!gzeof($h)) { $line = gzgets($h, 8192); if ($line !== false) { - $this->parseLine($line, $cutoff, $pages, $books, $ips, $dayPages); + $this->parseLine($line, $cutoff, $pages, $books, $ips, $dayPages, $ipDays, $ipPaths); } } gzclose($h); @@ -193,7 +222,7 @@ class AccessLogParser return; } while (($line = fgets($h)) !== false) { - $this->parseLine($line, $cutoff, $pages, $books, $ips, $dayPages); + $this->parseLine($line, $cutoff, $pages, $books, $ips, $dayPages, $ipDays, $ipPaths); } fclose($h); } diff --git a/src/BookManager.php b/src/BookManager.php index c4bc060..9cb0e21 100644 --- a/src/BookManager.php +++ b/src/BookManager.php @@ -95,7 +95,7 @@ class BookManager $this->bookPath($slug), json_encode($book, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n" ); - $this->git?->commit("book: " . ($book['title'] ?? $slug)); + $this->git?->commit('book: ' . ($book['title'] ?? $slug)); } public function delete(string $slug): void diff --git a/src/DataGit.php b/src/DataGit.php index c3ab886..a296387 100644 --- a/src/DataGit.php +++ b/src/DataGit.php @@ -4,7 +4,9 @@ declare(strict_types=1); class DataGit { - public function __construct(private string $dataDir) {} + public function __construct(private string $dataDir) + { + } public function commit(string $message): void { diff --git a/src/Service/AiService.php b/src/Service/AiService.php index e41db0a..b8f80f1 100644 --- a/src/Service/AiService.php +++ b/src/Service/AiService.php @@ -61,7 +61,9 @@ PROMPT; $raw = $this->provider === 'claude_code' ? $this->queryClaudeCode(self::SYSTEM_ANALYZE, $userMsg) : $this->queryAnthropicRaw(self::SYSTEM_ANALYZE, $userMsg, 4096); - if (!$raw['ok']) return $raw; + if (!$raw['ok']) { + return $raw; + } return $this->parseAnalyzeResponse($raw['text'] ?? ''); } @@ -129,7 +131,9 @@ PROMPT; $err = curl_error($ch); curl_close($ch); - if ($err !== '') return ['ok' => false, 'error' => 'Erreur réseau : ' . $err]; + if ($err !== '') { + return ['ok' => false, 'error' => 'Erreur réseau : ' . $err]; + } $data = json_decode((string) $resp, true); if ($http !== 200) { diff --git a/src/SiteSettings.php b/src/SiteSettings.php index 94e7e54..c3dafc0 100644 --- a/src/SiteSettings.php +++ b/src/SiteSettings.php @@ -96,14 +96,18 @@ function asGroups(): array function aiProvider(): string { $v = siteSettings()['ai_provider'] ?? ''; - if ($v !== '') return $v; + if ($v !== '') { + return $v; + } return $_ENV['AI_PROVIDER'] ?? getenv('AI_PROVIDER') ?: 'anthropic'; } function aiModel(): string { $v = siteSettings()['ai_model'] ?? ''; - if ($v !== '') return $v; + if ($v !== '') { + return $v; + } return $_ENV['AI_MODEL'] ?? getenv('AI_MODEL') ?: 'claude-haiku-4-5-20251001'; } diff --git a/src/TrendingParser.php b/src/TrendingParser.php index e2334ca..74302db 100644 --- a/src/TrendingParser.php +++ b/src/TrendingParser.php @@ -15,7 +15,8 @@ class TrendingParser public function __construct( private string $logDir, private string $pattern, - ) {} + ) { + } /** * Retourne les $limit chemins les plus consultés depuis $cutoff, diff --git a/src/helpers.php b/src/helpers.php index e0867df..a456e85 100644 --- a/src/helpers.php +++ b/src/helpers.php @@ -66,8 +66,12 @@ function lineDiff(string $old, string $new): array if ($n * $m > 2_000_000) { $diff = [['!', "Diff trop grand ({$n}×{$m} lignes) — affichage simplifié."]]; - foreach ($a as $line) { $diff[] = ['-', $line]; } - foreach ($b as $line) { $diff[] = ['+', $line]; } + foreach ($a as $line) { + $diff[] = ['-', $line]; + } + foreach ($b as $line) { + $diff[] = ['+', $line]; + } return $diff; } diff --git a/templates/admin.php b/templates/admin.php index ba6454e..2566ef1 100644 --- a/templates/admin.php +++ b/templates/admin.php @@ -230,7 +230,9 @@ function adminStatusBadge(array $a, int $now): string return '/admin/articles?' . http_build_query($p); }; $_sortIcon = function (string $col) use ($_sortBy, $_sortDir): string { - if ($_sortBy !== $col) { return '
'; } + if ($_sortBy !== $col) { + return ''; + } return '' . ($_sortDir === 'asc' ? '↑' : '↓') . ''; }; ?> @@ -389,7 +391,7 @@ function adminStatusBadge(array $a, int $now): string 'sort' => $_sortBy, 'dir' => $_sortDir, ], fn ($v) => $v !== '')); - foreach ($adminData['articles'] as $_fa): +foreach ($adminData['articles'] as $_fa): ?>