001/* 002 * Copyright 2023 Anyware Services 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016package org.ametys.web.statistics; 017 018import java.util.ArrayList; 019import java.util.Comparator; 020import java.util.HashMap; 021import java.util.List; 022import java.util.Map; 023import java.util.Map.Entry; 024import java.util.function.Function; 025import java.util.stream.Collectors; 026 027import org.apache.avalon.framework.service.ServiceException; 028import org.apache.avalon.framework.service.ServiceManager; 029import org.apache.avalon.framework.service.Serviceable; 030import org.apache.commons.lang3.StringUtils; 031 032import org.ametys.cms.repository.Content; 033import org.ametys.core.util.I18nUtils; 034import org.ametys.plugins.explorer.resources.Resource; 035import org.ametys.plugins.explorer.resources.ResourceCollection; 036import org.ametys.plugins.repository.AmetysObject; 037import org.ametys.plugins.repository.AmetysObjectIterable; 038import org.ametys.plugins.repository.AmetysObjectResolver; 039import org.ametys.plugins.repository.TraversableAmetysObject; 040import org.ametys.runtime.config.Config; 041import org.ametys.runtime.i18n.I18nizableText; 042import org.ametys.runtime.plugin.component.AbstractLogEnabled; 043import org.ametys.runtime.plugin.component.PluginAware; 044import org.ametys.runtime.plugins.admin.statistics.Statistics; 045import org.ametys.runtime.plugins.admin.statistics.StatisticsNode; 046import org.ametys.runtime.plugins.admin.statistics.StatisticsProvider; 047import org.ametys.runtime.plugins.admin.statistics.StatisticsValue; 048import org.ametys.web.cache.PageHelper; 049import org.ametys.web.gdpr.GDPRComponentEnumerator; 050import org.ametys.web.repository.content.SharedContent; 051import org.ametys.web.repository.content.WebContent; 052import org.ametys.web.repository.page.Page; 053import org.ametys.web.repository.page.SitemapElement; 054import org.ametys.web.repository.page.Zone; 055import org.ametys.web.repository.page.ZoneItem; 056import org.ametys.web.repository.site.Site; 057import org.ametys.web.repository.site.SiteManager; 058import org.ametys.web.repository.sitemap.Sitemap; 059import org.ametys.web.service.Service; 060import org.ametys.web.service.ServiceExtensionPoint; 061import org.ametys.web.site.SiteConfigurationManager; 062import org.ametys.web.skin.SkinsManager; 063 064/** 065 * Web statistics 066 */ 067public class WebStatisticsProvider extends AbstractLogEnabled implements StatisticsProvider, Serviceable, PluginAware 068{ 069 private String _id; 070 private SiteConfigurationManager _siteConfigurationManager; 071 private SiteManager _siteManager; 072 private PageHelper _pageHelper; 073 private ServiceExtensionPoint _serviceEP; 074 private I18nUtils _i18nUtils; 075 private AmetysObjectResolver _ametysResolver; 076 private SkinsManager _skinsManager; 077 private GDPRComponentEnumerator _gDPRComponentEnumerator; 078 079 public void service(ServiceManager manager) throws ServiceException 080 { 081 _siteConfigurationManager = (SiteConfigurationManager) manager.lookup(SiteConfigurationManager.ROLE); 082 _siteManager = (SiteManager) manager.lookup(SiteManager.ROLE); 083 _pageHelper = (PageHelper) manager.lookup(PageHelper.ROLE); 084 _serviceEP = (ServiceExtensionPoint) manager.lookup(ServiceExtensionPoint.ROLE); 085 _i18nUtils = (I18nUtils) manager.lookup(I18nUtils.ROLE); 086 _ametysResolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE); 087 _skinsManager = (SkinsManager) manager.lookup(SkinsManager.ROLE); 088 _gDPRComponentEnumerator = (GDPRComponentEnumerator) manager.lookup(GDPRComponentEnumerator.ROLE); 089 } 090 091 public void setPluginInfo(String pluginName, String featureName, String id) 092 { 093 _id = id; 094 } 095 096 public Statistics getStatistics() 097 { 098 SitesInfos sitesInfos = _getSitesInfos(); 099 List<List<Map<String, Long>>> countPages = sitesInfos.sitesPages(); 100 int siteCount = countPages.size(); 101 int sitemapCount = countPages.stream().map(List<Map<String, Long>>::size).reduce(0, Integer::sum); 102 List<Long> nbPagesPerSitemap = countPages.stream().flatMap(List<Map<String, Long>>::stream).map(m -> m.get("NBPAGES")).sorted().toList(); 103 long pagesCount = nbPagesPerSitemap.stream().reduce(0L, Long::sum); 104 List<Long> nbContentsRefPerSitemap = countPages.stream().flatMap(List<Map<String, Long>>::stream).map(m -> m.get("CONTENTS")).sorted().toList(); 105 long contentsRefsCount = nbContentsRefPerSitemap.stream().reduce(0L, Long::sum); 106 List<Long> nbServicesPerSitemap = countPages.stream().flatMap(List<Map<String, Long>>::stream).map(m -> m.get("SERVICES")).sorted().toList(); 107 long servicesCount = nbServicesPerSitemap.stream().reduce(0L, Long::sum); 108 List<Long> nbCacheablePagesPerSitemap = countPages.stream().flatMap(List<Map<String, Long>>::stream).map(m -> m.get("NBCACHEABLE")).sorted().toList(); 109 long cacheablePagesCount = nbCacheablePagesPerSitemap.stream().reduce(0L, Long::sum); 110 111 Map<String, Long> contentsForSites = _processContentsForSites(); 112 113 List<Statistics> skins = _skinsManager.getResourceSkins() 114 .stream() 115 .sorted(Comparator.naturalOrder()) 116 .map(skinName -> new StatisticsValue(skinName, 117 new I18nizableText(skinName), 118 "ametysicon-puzzle-piece1", 119 _skinsManager.getVersion(skinName))) 120 .map(Statistics.class::cast) 121 .toList(); 122 123 124 return new StatisticsNode( 125 _id, 126 new I18nizableText("plugin.web", "PLUGINS_WEB_STATISTICS_LABEL"), 127 "ametysicon-code-html-link", 128 null, 129 List.of( 130 new StatisticsNode( 131 "sites", 132 new I18nizableText("plugin.web", "PLUGINS_WEB_STATISTICS_SITES_LABEL"), 133 "ametysicon-world-earth-black", 134 siteCount, 135 List.of( 136 new StatisticsNode( 137 "sitemaps", 138 new I18nizableText("plugin.web", "PLUGINS_WEB_STATISTICS_SITES_SITEMAP_LABEL"), 139 "ametysicon-world-flag", 140 sitemapCount, 141 List.of( 142 new StatisticsNode( 143 "pages", 144 new I18nizableText("plugin.web", "PLUGINS_WEB_STATISTICS_SITES_SITEMAP_PAGES_LABEL"), 145 "ametysicon-website38", 146 pagesCount, 147 List.of( 148 new StatisticsValue( 149 "max", 150 new I18nizableText("plugin.web", "PLUGINS_WEB_STATISTICS_SITES_SITEMAP_PAGES_MAX_LABEL"), 151 "ametysicon-sort51", 152 nbPagesPerSitemap.size() > 0 ? nbPagesPerSitemap.get(nbPagesPerSitemap.size() - 1) : 0 153 ), 154 new StatisticsValue( 155 "median", 156 new I18nizableText("plugin.web", "PLUGINS_WEB_STATISTICS_SITES_SITEMAP_PAGES_MEDIAN_LABEL"), 157 "ametysicon-maths-window-symbol-x", 158 nbPagesPerSitemap.size() > 0 ? nbPagesPerSitemap.get(nbPagesPerSitemap.size() / 2) : 0 159 ), 160 new StatisticsValue( 161 "cacheable", 162 new I18nizableText("plugin.web", "PLUGINS_WEB_STATISTICS_SITES_SITEMAP_PAGES_CACHEABLE_LABEL"), 163 "ametysicon-desktop-archive", 164 cacheablePagesCount 165 ) 166 ), 167 false 168 ), 169 new StatisticsNode( 170 "content-references", 171 new I18nizableText("plugin.web", "PLUGINS_WEB_STATISTICS_SITES_SITEMAP_CONTENTREFERENCES_LABEL"), 172 "ametysicon-text70", 173 contentsRefsCount, 174 List.of( 175 new StatisticsValue( 176 "max", 177 new I18nizableText("plugin.web", "PLUGINS_WEB_STATISTICS_SITES_SITEMAP_CONTENTREFERENCES_MAX_LABEL"), 178 "ametysicon-sort51", 179 nbContentsRefPerSitemap.size() > 0 ? nbContentsRefPerSitemap.get(nbContentsRefPerSitemap.size() - 1) : 0 180 ), 181 new StatisticsValue( 182 "median", 183 new I18nizableText("plugin.web", "PLUGINS_WEB_STATISTICS_SITES_SITEMAP_CONTENTREFERENCES_MEDIAN_LABEL"), 184 "ametysicon-maths-window-symbol-x", 185 nbContentsRefPerSitemap.size() > 0 ? nbContentsRefPerSitemap.get(nbContentsRefPerSitemap.size() / 2) : 0 186 ) 187 ), 188 false 189 ), 190 new StatisticsNode( 191 "service-references", 192 new I18nizableText("plugin.web", "PLUGINS_WEB_STATISTICS_SITES_SITEMAP_SERVICES_LABEL"), 193 "ametysicon-system-monitoring", 194 servicesCount, 195 List.of( 196 new StatisticsValue( 197 "max", 198 new I18nizableText("plugin.web", "PLUGINS_WEB_STATISTICS_SITES_SITEMAP_SERVICES_MAX_LABEL"), 199 "ametysicon-sort51", 200 nbServicesPerSitemap.size() > 0 ? nbServicesPerSitemap.get(nbServicesPerSitemap.size() - 1) : 0 201 ), 202 new StatisticsValue( 203 "median", 204 new I18nizableText("plugin.web", "PLUGINS_WEB_STATISTICS_SITES_SITEMAP_SERVICES_MEDIAN_LABEL"), 205 "ametysicon-maths-window-symbol-x", 206 nbServicesPerSitemap.size() > 0 ? nbServicesPerSitemap.get(nbServicesPerSitemap.size() / 2) : 0 207 ) 208 ), 209 false 210 ) 211 ), 212 false 213 ), 214 new StatisticsNode( 215 "contents", 216 new I18nizableText("plugin.web", "PLUGINS_WEB_STATISTICS_SITES_CONTENTS_LABEL"), 217 "ametysicon-text70", 218 contentsForSites.get("COUNT"), 219 List.of( 220 new StatisticsValue( 221 "orphans", 222 new I18nizableText("plugin.web", "PLUGINS_WEB_STATISTICS_SITES_CONTENTS_ORPHANS_LABEL"), 223 "ametysicon-sort51", 224 contentsForSites.get("ORPHANS") 225 ), 226 new StatisticsValue( 227 "shared", 228 new I18nizableText("plugin.web", "PLUGINS_WEB_STATISTICS_SITES_CONTENTS_EXTERNAL_LABEL"), 229 "ametysicon-maths-window-symbol-x", 230 contentsForSites.get("EXTERNAL") 231 ) 232 ), 233 false 234 ), 235 new StatisticsNode( 236 "services", 237 new I18nizableText("plugin.web", "PLUGINS_WEB_STATISTICS_SITES_SERVICES_LABEL"), 238 "ametysicon-system-monitoring", 239 null, 240 _getServices(), 241 false 242 ), 243 _processResources(), 244 new StatisticsNode( 245 "skins", 246 new I18nizableText("plugin.web", "PLUGINS_WEB_STATISTICS_SITES_SKINS_LABEL"), 247 "ametysicon-puzzle-piece1", 248 null, 249 skins, 250 false 251 ), 252 new StatisticsNode( 253 "gdpr", 254 new I18nizableText("plugin.web", "PLUGINS_WEB_STATISTICS_SITES_GDPR_LABEL"), 255 "ametysicon-desktop-clipboard-list", 256 _getGDPRGlobal(), 257 _getGDPRBySite(), 258 false 259 ) 260 ), 261 true 262 ), 263 new StatisticsValue( 264 "unavailable-sites", 265 new I18nizableText("plugin.web", "PLUGINS_WEB_STATISTICS_UNAVAILABLE_SITES_LABEL"), 266 "ametysicon-sign-caution", 267 sitesInfos.unavailableSites() 268 ) 269 ), 270 true 271 ); 272 } 273 274 private SitesInfos _getSitesInfos() 275 { 276 try (AmetysObjectIterable<Site> sites = _siteManager.getSites()) 277 { 278 List<List<Map<String, Long>>> sitesValues = new ArrayList<>(); 279 int unavailableSites = 0; 280 for (Site site : sites) 281 { 282 try 283 { 284 // most operation on site and sitemap element expect a valid site 285 // ignore invalid site to prevent errors 286 if (_siteConfigurationManager.isSiteConfigurationValid(site)) 287 { 288 sitesValues.add(_countPages(site)); 289 } 290 else 291 { 292 unavailableSites++; 293 } 294 } 295 catch (Exception e) 296 { 297 getLogger().warn("Failed to compute statistics for site '{}', the site will be considered unavailable", e); 298 unavailableSites++; 299 } 300 } 301 302 return new SitesInfos(sitesValues, unavailableSites); 303 } 304 } 305 306 private record SitesInfos(List<List<Map<String, Long>>> sitesPages, int unavailableSites) { } 307 308 private List<Map<String, Long>> _countPages(Site site) 309 { 310 List<Map<String, Long>> siteValues = new ArrayList<>(); 311 312 for (Sitemap sitemap : site.getSitemaps()) 313 { 314 siteValues.add(_countPages(sitemap)); 315 } 316 317 return siteValues; 318 } 319 320 private Map<String, Long> _countPages(SitemapElement pages) 321 { 322 Map<String, Long> sitemapElementValues = _newCountMap(); 323 324 AmetysObjectIterable<? extends Page> childPages = pages.getChildrenPages(); 325 326 sitemapElementValues.put("NBPAGES", sitemapElementValues.get("NBPAGES") + childPages.getSize()); 327 328 int cacheableCount = 0; 329 330 for (Page page : childPages) 331 { 332 boolean isCacheable = _pageHelper.isCacheable(page); 333 for (Zone zone : page.getZones()) 334 { 335 AmetysObjectIterable<? extends ZoneItem> zoneItems = zone.getZoneItems(); 336 for (ZoneItem item : zoneItems) 337 { 338 switch (item.getType()) 339 { 340 case CONTENT: 341 sitemapElementValues.put("CONTENTS", sitemapElementValues.get("CONTENTS") + 1); 342 break; 343 case SERVICE: 344 sitemapElementValues.put("SERVICES", sitemapElementValues.get("SERVICES") + 1); 345 break; 346 default: 347 break; 348 } 349 } 350 } 351 352 if (isCacheable) 353 { 354 cacheableCount++; 355 } 356 357 _sumMaps(sitemapElementValues, _countPages(page)); 358 } 359 360 sitemapElementValues.put("NBCACHEABLE", sitemapElementValues.get("NBCACHEABLE") + cacheableCount); 361 362 return sitemapElementValues; 363 } 364 365 366 private Map<String, Long> _newCountMap() 367 { 368 Map<String, Long> countMap = new HashMap<>(); 369 countMap.put("NBPAGES", 0L); // Number of pages 370 countMap.put("NBCACHEABLE", 0L); // Number of cacheable pages 371 countMap.put("SERVICES", 0L); // Number of services 372 countMap.put("CONTENTS", 0L); // Number of contents 373 return countMap; 374 } 375 376 private void _sumMaps(Map<String, Long> mainMap, Map<String, Long> mapToAdd) 377 { 378 for (String k : mainMap.keySet()) 379 { 380 mainMap.put(k, mainMap.get(k) + mapToAdd.get(k)); 381 } 382 } 383 384 private List<Statistics> _getServices() 385 { 386 List<Statistics> stats = new ArrayList<>(); 387 388 for (String serviceId : _serviceEP.getExtensionsIds()) 389 { 390 Service service = _serviceEP.getExtension(serviceId); 391 392 stats.add(new StatisticsValue( 393 serviceId, 394 new I18nizableText(_i18nUtils.translate(service.getLabel())), // Translate now for sorting 395 StringUtils.defaultIfBlank(service.getIconGlyph(), "ametysicon-system-monitoring"), 396 _countAllServices(serviceId) 397 )); 398 } 399 400 stats.sort(new Comparator<>() { 401 public int compare(Statistics o1, Statistics o2) 402 { 403 return o1.getLabel().getLabel().compareTo(o2.getLabel().getLabel()); 404 } 405 }); 406 407 String query = "//element(ametys:zoneItem, ametys:zoneItem)[@ametys-internal:type='SERVICE'" 408 + _serviceEP.getExtensionsIds().stream().map(id -> " and not(@ametys-internal:service='" + id + "')").collect(Collectors.joining()) 409 + "]"; 410 try (AmetysObjectIterable<ZoneItem> serviceZoneItems = _ametysResolver.query(query)) 411 { 412 stats.add(0, new StatisticsValue( 413 "unknown", 414 new I18nizableText("plugin.web", "PLUGINS_WEB_STATISTICS_SITES_SERVICES_UNKNOWN_LABEL"), 415 "ametysicon-file-extension-generic-unknown", 416 serviceZoneItems.getSize() 417 )); 418 } 419 420 return stats; 421 } 422 423 private long _countAllServices(String serviceId) 424 { 425 String query = "//element(ametys:zoneItem, ametys:zoneItem)[@ametys-internal:type='SERVICE'" 426 + (serviceId != null ? " and @ametys-internal:service='" + serviceId + "'" : "") 427 + "]"; 428 try (AmetysObjectIterable<ZoneItem> serviceZoneItems = _ametysResolver.query(query)) 429 { 430 return serviceZoneItems.getSize(); 431 } 432 } 433 434 private Map<String, Long> _processContentsForSites() 435 { 436 Map<String, Long> values = new HashMap<>(); 437 values.put("COUNT", 0L); 438 values.put("ORPHANS", 0L); 439 values.put("EXTERNAL", 0L); 440 441 try (AmetysObjectIterable<Site> sites = _siteManager.getSites()) 442 { 443 for (Site site : sites) 444 { 445 _processContents (site, values); 446 } 447 } 448 catch (Exception e) 449 { 450 throw new IllegalStateException(e); 451 } 452 453 return values; 454 } 455 456 private void _processContents (Site site, Map<String, Long> values) 457 { 458 // Number of contents 459 AmetysObjectIterable<Content> contents = site.getContents(); 460 values.put("COUNT", values.get("COUNT") + contents.getSize()); 461 462 int orphans = 0; 463 int external = 0; 464 for (Content content : contents) 465 { 466 if (content instanceof WebContent webContent 467 && webContent.getReferencingPages().size() == 0) 468 { 469 orphans++; 470 } 471 if (content instanceof SharedContent) 472 { 473 external++; 474 } 475 } 476 477 // Number of orphan contents 478 values.put("ORPHANS", values.get("ORPHANS") + orphans); 479 480 // Number of shared contents 481 values.put("EXTERNAL", values.get("EXTERNAL") + external); 482 } 483 484 private Statistics _processResources() 485 { 486 HashMap<String, Long> values = new HashMap<>(); 487 values.put("FOLDERCOUNT", 0L); 488 values.put("RESOURCECOUNT", 0L); 489 values.put("TOTALSIZE", 0L); 490 491 try (AmetysObjectIterable<Site> sites = _siteManager.getSites()) 492 { 493 for (Site site : sites) 494 { 495 _processResources (site.getRootResources(), values); 496 } 497 } 498 499 return new StatisticsNode( 500 "explorer", 501 new I18nizableText("plugin.web", "PLUGINS_WEB_STATISTICS_SITES_EXPLORER_LABEL"), 502 "ametysicon-folder249", 503 values.get("TOTALSIZE"), 504 List.of( 505 new StatisticsValue( 506 "folders", 507 new I18nizableText("plugin.web", "PLUGINS_WEB_STATISTICS_SITES_EXPLORER_FOLDER_LABEL"), 508 "ametysicon-folder250", 509 values.get("FOLDERCOUNT") 510 ), 511 new StatisticsValue( 512 "files", 513 new I18nizableText("plugin.web", "PLUGINS_WEB_STATISTICS_SITES_EXPLORER_FILES_LABEL"), 514 "ametysicon-document77", 515 values.get("RESOURCECOUNT") 516 ) 517 ), 518 false 519 ); 520 } 521 522 private void _processResources(TraversableAmetysObject resourceContainer, HashMap<String, Long> values) 523 { 524 AmetysObjectIterable<? extends AmetysObject> objects = resourceContainer.getChildren(); 525 526 for (AmetysObject object : objects) 527 { 528 // Traverse the child nodes if depth < 0 (generate all) or depth > 0 (we're not in the last level). 529 if (object instanceof ResourceCollection) 530 { 531 values.put("FOLDERCOUNT", values.get("FOLDERCOUNT") + 1); 532 533 _processResources((ResourceCollection) object, values); 534 } 535 else if (object instanceof Resource) 536 { 537 Resource resource = (Resource) object; 538 539 values.put("RESOURCECOUNT", values.get("RESOURCECOUNT") + 1); 540 values.put("TOTALSIZE", values.get("TOTALSIZE") + resource.getLength()); 541 } 542 } 543 } 544 545 private I18nizableText _getGDPRGlobal() 546 { 547 try (AmetysObjectIterable<Site> sites = _siteManager.getSites()) 548 { 549 return _gDPRComponentEnumerator.getEntry(Config.getInstance().getValue("plugins.web.gdpr.choice")); 550 } 551 catch (Exception e) 552 { 553 throw new IllegalStateException(e); 554 } 555 } 556 557 private List<Statistics> _getGDPRBySite() 558 { 559 try (AmetysObjectIterable<Site> sites = _siteManager.getSites()) 560 { 561 List<Statistics> gdpr = new ArrayList<>(); 562 563 Map<String, Long> gdprChoiceNumber = sites.stream() 564 .map(site -> (String) (site.hasValue("gdpr-component-choice") ? site.getValue("gdpr-component-choice") : "_ametys_general_configuration")) 565 .collect(Collectors.groupingBy(Function.identity(), Collectors.counting())); 566 567 gdpr.add(new StatisticsValue("_ametys_general_configuration", 568 new I18nizableText("plugin.web", "PLUGINS_WEB_SITE_COOKIES_GENERAL_CONFIGURATION"), 569 "ametysicon-system-monitoring", 570 gdprChoiceNumber.getOrDefault("_ametys_general_configuration", 0L))); 571 for (Entry<String, I18nizableText> entry : _gDPRComponentEnumerator.getEntries().entrySet()) 572 { 573 gdpr.add(new StatisticsValue(entry.getKey(), 574 entry.getValue(), 575 "ametysicon-system-monitoring", 576 gdprChoiceNumber.getOrDefault(entry.getKey(), 0L))); 577 } 578 return gdpr; 579 } 580 catch (Exception e) 581 { 582 throw new IllegalStateException(e); 583 } 584 585 } 586}