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