├── src ├── web │ └── assets │ │ └── dist │ │ ├── assets │ │ ├── webperf-8z1FbwJh.js │ │ ├── webperf-8z1FbwJh.js.map │ │ ├── alerts-D3VsK7uo.js.gz │ │ ├── sidebar-DyQeHXoP.js.gz │ │ ├── webperf-zfG7r2xS.css.gz │ │ ├── alerts-D3VsK7uo.js.map.gz │ │ ├── dashboard-B8Tl2RKD.js.gz │ │ ├── sidebar-DyQeHXoP.js.map.gz │ │ ├── dashboard-B8Tl2RKD.js.map.gz │ │ ├── errors-detail-CbuM4Hh8.js.gz │ │ ├── errors-index-zYZol8PP.js.gz │ │ ├── PageResultCell-b43rcj4h.js.gz │ │ ├── RequestBarChart-Ee0Vh_Iz.js.gz │ │ ├── SamplePaneFooter-DUo1brgB.js.gz │ │ ├── SimpleBarChart-DDG34REw.js.gz │ │ ├── errors-index-zYZol8PP.js.map.gz │ │ ├── vue-apexcharts-C2g27_eS.js.gz │ │ ├── DataSampleDate-UiLkVbOT.js.map.gz │ │ ├── PageResultCell-b43rcj4h.js.map.gz │ │ ├── SampleRangePicker-B5MMA2lb.js.gz │ │ ├── SampleRangePicker-l4T-d3Wl.css.gz │ │ ├── SimpleBarChart-DDG34REw.js.map.gz │ │ ├── errors-detail-CbuM4Hh8.js.map.gz │ │ ├── performance-detail-erdgCHrY.js.gz │ │ ├── performance-index-Bj4b5YCn.js.gz │ │ ├── vue-apexcharts-C2g27_eS.js.map.gz │ │ ├── DataSampleDevice-D3lfOt8n.js.map.gz │ │ ├── ErrorsDetailAreaChart-CoSEsohF.js.gz │ │ ├── RecommendationsList-T8Et27n2.js.gz │ │ ├── RequestBarChart-Ee0Vh_Iz.js.map.gz │ │ ├── SamplePaneFooter-DUo1brgB.js.map.gz │ │ ├── SampleRangePicker-B5MMA2lb.js.map.gz │ │ ├── performance-index-Bj4b5YCn.js.map.gz │ │ ├── tri-color-blend-CUFlaG2k.js.map.gz │ │ ├── RecommendationsList-T8Et27n2.js.map.gz │ │ ├── performance-detail-erdgCHrY.js.map.gz │ │ ├── ErrorsDetailAreaChart-CoSEsohF.js.map.gz │ │ ├── PerformanceDetailAreaChart-BW1ni5m3.js.gz │ │ ├── PerformanceDetailAreaChart-BW1ni5m3.js.map.gz │ │ ├── DataSampleDate-UiLkVbOT.js │ │ ├── SampleSizeWarning-l8RSl_wj.js │ │ ├── DataSampleDevice-D3lfOt8n.js │ │ ├── tri-color-blend-CUFlaG2k.js │ │ ├── SampleSizeWarning-l8RSl_wj.js.map │ │ ├── PageResultCell-aK_k3t68.css │ │ ├── DataSampleDate-UiLkVbOT.js.map │ │ ├── DataSampleDevice-D3lfOt8n.js.map │ │ ├── RecommendationsList-T8Et27n2.js │ │ ├── ErrorsDetailAreaChart-CoSEsohF.js │ │ ├── SamplePaneFooter-DUo1brgB.js │ │ ├── PerformanceDetailAreaChart-BW1ni5m3.js │ │ ├── tri-color-blend-CUFlaG2k.js.map │ │ ├── RequestBarChart-Ee0Vh_Iz.js │ │ ├── SimpleBarChart-DDG34REw.js │ │ ├── SamplePaneFooter-DUo1brgB.js.map │ │ └── RecommendationsList-T8Et27n2.js.map │ │ ├── manifest.json.gz │ │ └── img │ │ └── Webperf-icon.svg ├── lib │ └── geoiploc.php ├── templates │ ├── _includes │ │ ├── range-picker.twig │ │ ├── macros.twig │ │ ├── speed-color-key.twig │ │ ├── recommendations.twig │ │ └── sites-menu.twig │ ├── _frontend │ │ └── scripts │ │ │ ├── load-boomerang-amp-iframe.twig │ │ │ ├── webperf-config.twig │ │ │ ├── boomerang-amp-iframe-html.twig │ │ │ ├── webperf-boomerang-custom.twig │ │ │ └── capture-errors.twig │ ├── _layouts │ │ ├── widget-cp.twig │ │ └── webperf-cp.twig │ ├── _components │ │ └── widgets │ │ │ ├── Metrics_body.twig │ │ │ └── Metrics_settings.twig │ ├── alerts │ │ └── index.twig │ ├── settings │ │ ├── index.twig │ │ └── _includes │ │ │ ├── errors.twig │ │ │ ├── appearance.twig │ │ │ ├── performance.twig │ │ │ └── general.twig │ ├── errors │ │ ├── index.twig │ │ └── page-detail.twig │ ├── performance │ │ └── index.twig │ └── dashboard │ │ └── index.twig ├── assetbundles │ ├── boomerang │ │ ├── src │ │ │ ├── js │ │ │ │ └── webperf-boomer-init.js │ │ │ └── json │ │ │ │ └── plugins.json │ │ ├── BoomerangAsset.php │ │ └── build-boomerang.sh │ └── webperf │ │ ├── WebperfAsset.php │ │ └── WebperfDashboardAsset.php ├── base │ ├── RecommendationInterface.php │ ├── BoomerangDataSample.php │ ├── CraftDataSample.php │ ├── DbDataSampleInterface.php │ ├── DbErrorSampleInterface.php │ ├── Recommendation.php │ ├── RecommendationTrait.php │ ├── CleanModel.php │ ├── FluentModel.php │ ├── DbErrorSample.php │ ├── DbDataSampleTrait.php │ ├── CraftDataSampleTrait.php │ ├── DbErrorSampleTrait.php │ └── BoomerangDataSampleTrait.php ├── events │ ├── ErrorSampleEvent.php │ ├── DataSampleEvent.php │ └── AlertEvent.php ├── models │ ├── CraftDbErrorSample.php │ ├── BoomerangDbErrorSample.php │ ├── RecommendationDataSample.php │ ├── CraftDbDataSample.php │ └── BoomerangDbDataSample.php ├── migrations │ └── m190625_151715_add_indexes.php ├── helpers │ ├── Permission.php │ ├── PluginTemplate.php │ └── Text.php ├── validators │ └── DbStringValidator.php ├── controllers │ ├── RenderController.php │ ├── RecommendationsController.php │ ├── DataSamplesController.php │ ├── ErrorSamplesController.php │ ├── SettingsController.php │ └── FileController.php ├── icon-mask.svg ├── recommendations │ ├── CraftTotalTime.php │ ├── CraftQueryCount.php │ ├── CraftQueryTime.php │ ├── CraftTwigTime.php │ ├── DomInteractive.php │ ├── FirstByte.php │ ├── FirstContentfulPaint.php │ └── MemoryLimit.php ├── icon.svg ├── console │ └── controllers │ │ └── SamplesController.php ├── records │ └── Alerts.php ├── variables │ └── WebperfVariable.php ├── log │ └── ErrorsTarget.php ├── widgets │ └── Metrics.php └── services │ ├── ServicesTrait.php │ └── Recommendations.php ├── phpstan.neon ├── ecs.php ├── CHANGELOG.md ├── composer.json ├── README.md └── LICENSE.md /src/web/assets/dist/assets/webperf-8z1FbwJh.js: -------------------------------------------------------------------------------- 1 | 2 | //# sourceMappingURL=webperf-8z1FbwJh.js.map 3 | -------------------------------------------------------------------------------- /src/lib/geoiploc.php: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nystudio107/craft-webperf/develop-v5/src/lib/geoiploc.php -------------------------------------------------------------------------------- /src/web/assets/dist/manifest.json.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nystudio107/craft-webperf/develop-v5/src/web/assets/dist/manifest.json.gz -------------------------------------------------------------------------------- /src/web/assets/dist/assets/webperf-8z1FbwJh.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"webperf-8z1FbwJh.js","sources":[],"sourcesContent":[],"names":[],"mappings":""} -------------------------------------------------------------------------------- /src/templates/_includes/range-picker.twig: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 |
5 | -------------------------------------------------------------------------------- /src/web/assets/dist/assets/alerts-D3VsK7uo.js.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nystudio107/craft-webperf/develop-v5/src/web/assets/dist/assets/alerts-D3VsK7uo.js.gz -------------------------------------------------------------------------------- /src/web/assets/dist/assets/sidebar-DyQeHXoP.js.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nystudio107/craft-webperf/develop-v5/src/web/assets/dist/assets/sidebar-DyQeHXoP.js.gz -------------------------------------------------------------------------------- /src/web/assets/dist/assets/webperf-zfG7r2xS.css.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nystudio107/craft-webperf/develop-v5/src/web/assets/dist/assets/webperf-zfG7r2xS.css.gz -------------------------------------------------------------------------------- /src/web/assets/dist/assets/alerts-D3VsK7uo.js.map.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nystudio107/craft-webperf/develop-v5/src/web/assets/dist/assets/alerts-D3VsK7uo.js.map.gz -------------------------------------------------------------------------------- /src/web/assets/dist/assets/dashboard-B8Tl2RKD.js.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nystudio107/craft-webperf/develop-v5/src/web/assets/dist/assets/dashboard-B8Tl2RKD.js.gz -------------------------------------------------------------------------------- /src/web/assets/dist/assets/sidebar-DyQeHXoP.js.map.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nystudio107/craft-webperf/develop-v5/src/web/assets/dist/assets/sidebar-DyQeHXoP.js.map.gz -------------------------------------------------------------------------------- /src/web/assets/dist/assets/dashboard-B8Tl2RKD.js.map.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nystudio107/craft-webperf/develop-v5/src/web/assets/dist/assets/dashboard-B8Tl2RKD.js.map.gz -------------------------------------------------------------------------------- /src/web/assets/dist/assets/errors-detail-CbuM4Hh8.js.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nystudio107/craft-webperf/develop-v5/src/web/assets/dist/assets/errors-detail-CbuM4Hh8.js.gz -------------------------------------------------------------------------------- /src/web/assets/dist/assets/errors-index-zYZol8PP.js.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nystudio107/craft-webperf/develop-v5/src/web/assets/dist/assets/errors-index-zYZol8PP.js.gz -------------------------------------------------------------------------------- /src/web/assets/dist/assets/PageResultCell-b43rcj4h.js.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nystudio107/craft-webperf/develop-v5/src/web/assets/dist/assets/PageResultCell-b43rcj4h.js.gz -------------------------------------------------------------------------------- /src/web/assets/dist/assets/RequestBarChart-Ee0Vh_Iz.js.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nystudio107/craft-webperf/develop-v5/src/web/assets/dist/assets/RequestBarChart-Ee0Vh_Iz.js.gz -------------------------------------------------------------------------------- /src/web/assets/dist/assets/SamplePaneFooter-DUo1brgB.js.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nystudio107/craft-webperf/develop-v5/src/web/assets/dist/assets/SamplePaneFooter-DUo1brgB.js.gz -------------------------------------------------------------------------------- /src/web/assets/dist/assets/SimpleBarChart-DDG34REw.js.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nystudio107/craft-webperf/develop-v5/src/web/assets/dist/assets/SimpleBarChart-DDG34REw.js.gz -------------------------------------------------------------------------------- /src/web/assets/dist/assets/errors-index-zYZol8PP.js.map.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nystudio107/craft-webperf/develop-v5/src/web/assets/dist/assets/errors-index-zYZol8PP.js.map.gz -------------------------------------------------------------------------------- /src/web/assets/dist/assets/vue-apexcharts-C2g27_eS.js.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nystudio107/craft-webperf/develop-v5/src/web/assets/dist/assets/vue-apexcharts-C2g27_eS.js.gz -------------------------------------------------------------------------------- /src/web/assets/dist/assets/DataSampleDate-UiLkVbOT.js.map.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nystudio107/craft-webperf/develop-v5/src/web/assets/dist/assets/DataSampleDate-UiLkVbOT.js.map.gz -------------------------------------------------------------------------------- /src/web/assets/dist/assets/PageResultCell-b43rcj4h.js.map.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nystudio107/craft-webperf/develop-v5/src/web/assets/dist/assets/PageResultCell-b43rcj4h.js.map.gz -------------------------------------------------------------------------------- /src/web/assets/dist/assets/SampleRangePicker-B5MMA2lb.js.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nystudio107/craft-webperf/develop-v5/src/web/assets/dist/assets/SampleRangePicker-B5MMA2lb.js.gz -------------------------------------------------------------------------------- /src/web/assets/dist/assets/SampleRangePicker-l4T-d3Wl.css.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nystudio107/craft-webperf/develop-v5/src/web/assets/dist/assets/SampleRangePicker-l4T-d3Wl.css.gz -------------------------------------------------------------------------------- /src/web/assets/dist/assets/SimpleBarChart-DDG34REw.js.map.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nystudio107/craft-webperf/develop-v5/src/web/assets/dist/assets/SimpleBarChart-DDG34REw.js.map.gz -------------------------------------------------------------------------------- /src/web/assets/dist/assets/errors-detail-CbuM4Hh8.js.map.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nystudio107/craft-webperf/develop-v5/src/web/assets/dist/assets/errors-detail-CbuM4Hh8.js.map.gz -------------------------------------------------------------------------------- /src/web/assets/dist/assets/performance-detail-erdgCHrY.js.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nystudio107/craft-webperf/develop-v5/src/web/assets/dist/assets/performance-detail-erdgCHrY.js.gz -------------------------------------------------------------------------------- /src/web/assets/dist/assets/performance-index-Bj4b5YCn.js.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nystudio107/craft-webperf/develop-v5/src/web/assets/dist/assets/performance-index-Bj4b5YCn.js.gz -------------------------------------------------------------------------------- /src/web/assets/dist/assets/vue-apexcharts-C2g27_eS.js.map.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nystudio107/craft-webperf/develop-v5/src/web/assets/dist/assets/vue-apexcharts-C2g27_eS.js.map.gz -------------------------------------------------------------------------------- /src/web/assets/dist/assets/DataSampleDevice-D3lfOt8n.js.map.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nystudio107/craft-webperf/develop-v5/src/web/assets/dist/assets/DataSampleDevice-D3lfOt8n.js.map.gz -------------------------------------------------------------------------------- /src/web/assets/dist/assets/ErrorsDetailAreaChart-CoSEsohF.js.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nystudio107/craft-webperf/develop-v5/src/web/assets/dist/assets/ErrorsDetailAreaChart-CoSEsohF.js.gz -------------------------------------------------------------------------------- /src/web/assets/dist/assets/RecommendationsList-T8Et27n2.js.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nystudio107/craft-webperf/develop-v5/src/web/assets/dist/assets/RecommendationsList-T8Et27n2.js.gz -------------------------------------------------------------------------------- /src/web/assets/dist/assets/RequestBarChart-Ee0Vh_Iz.js.map.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nystudio107/craft-webperf/develop-v5/src/web/assets/dist/assets/RequestBarChart-Ee0Vh_Iz.js.map.gz -------------------------------------------------------------------------------- /src/web/assets/dist/assets/SamplePaneFooter-DUo1brgB.js.map.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nystudio107/craft-webperf/develop-v5/src/web/assets/dist/assets/SamplePaneFooter-DUo1brgB.js.map.gz -------------------------------------------------------------------------------- /src/web/assets/dist/assets/SampleRangePicker-B5MMA2lb.js.map.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nystudio107/craft-webperf/develop-v5/src/web/assets/dist/assets/SampleRangePicker-B5MMA2lb.js.map.gz -------------------------------------------------------------------------------- /src/web/assets/dist/assets/performance-index-Bj4b5YCn.js.map.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nystudio107/craft-webperf/develop-v5/src/web/assets/dist/assets/performance-index-Bj4b5YCn.js.map.gz -------------------------------------------------------------------------------- /src/web/assets/dist/assets/tri-color-blend-CUFlaG2k.js.map.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nystudio107/craft-webperf/develop-v5/src/web/assets/dist/assets/tri-color-blend-CUFlaG2k.js.map.gz -------------------------------------------------------------------------------- /src/web/assets/dist/assets/RecommendationsList-T8Et27n2.js.map.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nystudio107/craft-webperf/develop-v5/src/web/assets/dist/assets/RecommendationsList-T8Et27n2.js.map.gz -------------------------------------------------------------------------------- /src/web/assets/dist/assets/performance-detail-erdgCHrY.js.map.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nystudio107/craft-webperf/develop-v5/src/web/assets/dist/assets/performance-detail-erdgCHrY.js.map.gz -------------------------------------------------------------------------------- /src/web/assets/dist/assets/ErrorsDetailAreaChart-CoSEsohF.js.map.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nystudio107/craft-webperf/develop-v5/src/web/assets/dist/assets/ErrorsDetailAreaChart-CoSEsohF.js.map.gz -------------------------------------------------------------------------------- /src/web/assets/dist/assets/PerformanceDetailAreaChart-BW1ni5m3.js.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nystudio107/craft-webperf/develop-v5/src/web/assets/dist/assets/PerformanceDetailAreaChart-BW1ni5m3.js.gz -------------------------------------------------------------------------------- /src/web/assets/dist/assets/PerformanceDetailAreaChart-BW1ni5m3.js.map.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nystudio107/craft-webperf/develop-v5/src/web/assets/dist/assets/PerformanceDetailAreaChart-BW1ni5m3.js.map.gz -------------------------------------------------------------------------------- /src/assetbundles/boomerang/src/js/webperf-boomer-init.js: -------------------------------------------------------------------------------- 1 | // This code is run after all plugins have initialized 2 | BOOMR.init({ 3 | beacon_url: '/webperf/metrics/beacon', 4 | log: null, 5 | }); 6 | BOOMR.t_end = new Date().getTime(); 7 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | includes: 2 | - %currentWorkingDirectory%/vendor/craftcms/phpstan/phpstan.neon 3 | 4 | parameters: 5 | level: 5 6 | paths: 7 | - src 8 | excludePaths: 9 | analyse: 10 | - src/lib/* 11 | -------------------------------------------------------------------------------- /src/templates/_frontend/scripts/load-boomerang-amp-iframe.twig: -------------------------------------------------------------------------------- 1 | 6 | 7 | -------------------------------------------------------------------------------- /src/assetbundles/boomerang/src/json/plugins.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "plugins/rt.js", 4 | "plugins/painttiming.js", 5 | "plugins/navtiming.js", 6 | "plugins/errors.js", 7 | "plugins/md5.js", 8 | "plugins/mq.js", 9 | "plugins/webperf-boomer-init.js" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /src/templates/_frontend/scripts/webperf-config.twig: -------------------------------------------------------------------------------- 1 | {% if headless is not defined or not headless %} 2 | var webperf_config = { 3 | url: '{{ boomerangScriptUrl | raw }}', 4 | title: '{{ boomerangTitle | raw }}' || document.title || '', 5 | requestId: '{{ boomerangRequestId | raw }}', 6 | }; 7 | {% endif %} 8 | -------------------------------------------------------------------------------- /src/templates/_layouts/widget-cp.twig: -------------------------------------------------------------------------------- 1 | {% block head %} 2 | {% set tagOptions = { 3 | 'depends': [ 4 | 'nystudio107\\webperf\\assetbundles\\webperf\\WebperfAsset' 5 | ], 6 | } %} 7 | {{ craft.webperf.register('src/js/webperf.js', false, tagOptions, tagOptions) }} 8 | {% endblock %} 9 | 10 | {% block content %} 11 | {% endblock %} 12 | -------------------------------------------------------------------------------- /src/templates/_layouts/webperf-cp.twig: -------------------------------------------------------------------------------- 1 | {% extends "_layouts/cp" %} 2 | 3 | {% block head %} 4 | {{ parent() }} 5 | {% set tagOptions = { 6 | 'depends': [ 7 | 'nystudio107\\webperf\\assetbundles\\webperf\\WebperfAsset' 8 | ], 9 | } %} 10 | {{ craft.webperf.register('src/js/webperf.js', false, tagOptions, tagOptions) }} 11 | {% endblock %} 12 | -------------------------------------------------------------------------------- /src/templates/_includes/macros.twig: -------------------------------------------------------------------------------- 1 | {% macro configWarning(setting, file) -%} 2 | {%- set configArray = craft.app.config.getConfigFromFile(file) -%} 3 | {%- if configArray[setting] is defined -%} 4 | {{- "This is being overridden by the `#{setting}` setting in the `config/#{file}.php` file." |raw }} 5 | {%- else -%} 6 | {{ false }} 7 | {%- endif -%} 8 | {%- endmacro %} 9 | -------------------------------------------------------------------------------- /ecs.php: -------------------------------------------------------------------------------- 1 | paths([ 8 | __DIR__ . '/src', 9 | __FILE__, 10 | ]); 11 | $ecsConfig->skip([ 12 | __DIR__ . '/src/lib', 13 | ]); 14 | $ecsConfig->parallel(); 15 | $ecsConfig->sets([SetList::CRAFT_CMS_4]); 16 | }; 17 | 18 | -------------------------------------------------------------------------------- /src/templates/_frontend/scripts/boomerang-amp-iframe-html.twig: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/web/assets/dist/assets/DataSampleDate-UiLkVbOT.js: -------------------------------------------------------------------------------- 1 | import{n as a}from"./vue-apexcharts-C2g27_eS.js";const n={name:"DataSampleDate",props:{date:{type:String,default:""},url:{type:String,default:""},query:{type:String,default:""}},computed:{title(){let e="";return e+="Url: "+this.url,this.query&&(e+=` 2 | 3 | Query: `+this.query),e}}};var l=function(){var t=this,r=t._self._c;return r("span",{staticClass:"cursor-default",attrs:{title:t.title}},[t._v(t._s(t.date))])},s=[],u=a(n,l,s,!1,null,null,null,null);const _=u.exports;export{_ as D}; 4 | //# sourceMappingURL=DataSampleDate-UiLkVbOT.js.map 5 | -------------------------------------------------------------------------------- /src/templates/_components/widgets/Metrics_body.twig: -------------------------------------------------------------------------------- 1 | {# @var craft \craft\web\twig\variables\CraftVariable #} 2 | {# 3 | /** 4 | * Webperf plugin for Craft CMS 5 | * 6 | * DataSamples Widget Body 7 | * 8 | * @author nystudio107 9 | * @copyright Copyright (c) 2019 nystudio107 10 | * @link https://nystudio107.com 11 | * @package Webperf 12 | * @since 1.0.0 13 | */ 14 | #} 15 | 16 | {% set iconUrl = view.getAssetManager().getPublishedUrl('@nystudio107/webperf/assetbundles/metricswidget/dist', true) ~ '/img/DataSamples-icon.svg' %} 17 | 18 | 19 | 20 |

{{ message }}

21 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Webperf Changelog 2 | 3 | ## 5.0.0 - 2024.04.16 4 | ### Added 5 | * Stable release for Craft CMS 5 6 | 7 | ### Fixed 8 | * Fixed an issue where the down and up arrows were reversed for sorting purposes 9 | 10 | ## 5.0.0-beta.3 - 2024.02.09 11 | ### Fixed 12 | * Fixed an issue with the Sites menu styling 13 | 14 | ## 5.0.0-beta.2 - 2024.02.09 15 | ### Fixed 16 | * Added the unused `static` to the Tailwind CSS `blocklist` to avoid a name collision with a Craft CSS class ([#1412](https://github.com/nystudio107/craft-seomatic/issues/1412)) 17 | 18 | ## 5.0.0-beta.1 - 2024.02.08 19 | ### Added 20 | * Initial beta release for Craft CMS 5 21 | -------------------------------------------------------------------------------- /src/templates/_includes/speed-color-key.twig: -------------------------------------------------------------------------------- 1 |
2 |
5 | Fast 6 | Average 7 | Slow 8 |
9 |
10 | -------------------------------------------------------------------------------- /src/web/assets/dist/assets/SampleSizeWarning-l8RSl_wj.js: -------------------------------------------------------------------------------- 1 | import{n as s}from"./vue-apexcharts-C2g27_eS.js";const n={name:"SampleSizeWarning",props:{sample:{type:Number,default:0}}};var l=function(){var e=this,a=e._self._c;return a("div",{staticClass:"field webperf-tooltip text-sm font-normal inline-block"},[e.sample<100?a("p",{staticClass:"warning display-block"},[e._v("   ")]):e._e(),a("span",{staticClass:"webperf-tooltiptext webperf-sample-tooltip"},[e._v(" Only "+e._s(e.sample)+" data sample"),e.sample!==1?a("span",[e._v("s")]):e._e(),e._v(". ")])])},t=[],p=s(n,l,t,!1,null,null,null,null);const i=p.exports;export{i as S}; 2 | //# sourceMappingURL=SampleSizeWarning-l8RSl_wj.js.map 3 | -------------------------------------------------------------------------------- /src/web/assets/dist/assets/DataSampleDevice-D3lfOt8n.js: -------------------------------------------------------------------------------- 1 | import{n as s}from"./vue-apexcharts-C2g27_eS.js";const l={name:"DataSampleDevice",props:{device:{type:String,default:""},mobile:{type:Boolean,default:!1}},computed:{className(){let e="";return this.device&&this.mobile!==void 0&&(e=this.mobile===!0?"webperf-mobile-icon":"webperf-desktop-icon"),e},title(){let e="";return this.device&&this.mobile!==void 0&&(e=this.mobile===!0?"Mobile device":"Desktop device"),e}}};var a=function(){var t=this,i=t._self._c;return i("span",{staticClass:"cursor-default",class:t.className,attrs:{title:t.title}},[t._v(" "+t._s(t.device))])},n=[],r=s(l,a,n,!1,null,null,null,null);const c=r.exports;export{c as D}; 2 | //# sourceMappingURL=DataSampleDevice-D3lfOt8n.js.map 3 | -------------------------------------------------------------------------------- /src/web/assets/dist/assets/tri-color-blend-CUFlaG2k.js: -------------------------------------------------------------------------------- 1 | class c{constructor(t="#00C800",r="#FFFF00",e="#C80000"){this.clr1=this.HexToRGB(t),this.clr2=this.HexToRGB(r),this.clr3=this.HexToRGB(e)}RGBToHex(t,r,e){let o=t<<16|r<<8|e;return function(n){return new Array(7-n.length).join("0")+n}(o.toString(16).toUpperCase())}HexToRGB(t){let r=/^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(t);return r?{r:parseInt(r[1],16),g:parseInt(r[2],16),b:parseInt(r[3],16)}:null}colorFromPercentage(t){let r=this.clr1,e=this.clr2;t>=50&&(r=this.clr2,e=this.clr3,t=t-50);const o=t/50,n=Math.round(r.r+o*(e.r-r.r)),s=Math.round(r.g+o*(e.g-r.g)),i=Math.round(r.b+o*(e.b-r.b));return"#"+this.RGBToHex(n,s,i)}}export{c as T}; 2 | //# sourceMappingURL=tri-color-blend-CUFlaG2k.js.map 3 | -------------------------------------------------------------------------------- /src/templates/_components/widgets/Metrics_settings.twig: -------------------------------------------------------------------------------- 1 | {# @var craft \craft\web\twig\variables\CraftVariable #} 2 | {# 3 | /** 4 | * Webperf plugin for Craft CMS 5 | * 6 | * DataSamples Widget Settings 7 | * 8 | * @author nystudio107 9 | * @copyright Copyright (c) 2019 nystudio107 10 | * @link https://nystudio107.com 11 | * @package Webperf 12 | * @since 1.0.0 13 | */ 14 | #} 15 | 16 | {% import "_includes/forms" as forms %} 17 | 18 | {% do view.registerAssetBundle("nystudio107\\webperf\\assetbundles\\webperf\\WebperfAsset") %} 19 | 20 | {{ forms.textField({ 21 | label: 'Message', 22 | instructions: 'Enter a message here.', 23 | id: 'message', 24 | name: 'message', 25 | value: widget['message']}) 26 | }} 27 | -------------------------------------------------------------------------------- /src/base/RecommendationInterface.php: -------------------------------------------------------------------------------- 1 | \n
\n \n \n

\n \n Only {{ sample }} data samples.\n \n
\n\n\n"],"names":["_sfc_main"],"mappings":"iDAcA,MAAAA,EAAA,CACA,KAAA,oBACA,MAAA,CACA,OAAA,CACA,KAAA,OACA,QAAA,CACA,CACA,CACA"} -------------------------------------------------------------------------------- /src/models/CraftDbErrorSample.php: -------------------------------------------------------------------------------- 1 | type = 'craft'; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/models/BoomerangDbErrorSample.php: -------------------------------------------------------------------------------- 1 | type = 'boomerang'; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/events/DataSampleEvent.php: -------------------------------------------------------------------------------- 1 | sourcePath = "@nystudio107/webperf/assetbundles/boomerang/dist"; 31 | 32 | $this->js = [ 33 | 'js/boomerang-1.0.0.min.js', 34 | ]; 35 | 36 | parent::init(); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/web/assets/dist/assets/DataSampleDate-UiLkVbOT.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"DataSampleDate-UiLkVbOT.js","sources":["../../../../../buildchain/src/vue/tables/common/DataSampleDate.vue"],"sourcesContent":["\n\n"],"names":["_sfc_main","title"],"mappings":"iDAOA,MAAAA,EAAA,CACA,KAAA,iBACA,MAAA,CACA,KAAA,CACA,KAAA,OACA,QAAA,EACA,EACA,IAAA,CACA,KAAA,OACA,QAAA,EACA,EACA,MAAA,CACA,KAAA,OACA,QAAA,EACA,CACA,EACA,SAAA,CACA,OAAA,CACA,IAAAC,EAAA,GAEA,OAAAA,GAAA,QAAA,KAAA,IACA,KAAA,QACAA,GAAA;AAAA;AAAA,SAAA,KAAA,OAGAA,CACA,CACA,CACA"} -------------------------------------------------------------------------------- /src/base/BoomerangDataSample.php: -------------------------------------------------------------------------------- 1 | boomerangRules(), 38 | [ 39 | ] 40 | ); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/events/AlertEvent.php: -------------------------------------------------------------------------------- 1 | sourcePath = "@nystudio107/webperf/web/assets/dist"; 33 | 34 | $this->depends = [ 35 | CpAsset::class, 36 | VueAsset::class, 37 | ]; 38 | 39 | $this->js = [ 40 | ]; 41 | 42 | $this->css = [ 43 | ]; 44 | 45 | parent::init(); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/assetbundles/webperf/WebperfDashboardAsset.php: -------------------------------------------------------------------------------- 1 | sourcePath = "@nystudio107/webperf/web/assets/dist"; 33 | 34 | $this->depends = [ 35 | CpAsset::class, 36 | VueAsset::class, 37 | WebperfAsset::class, 38 | ]; 39 | 40 | $this->js = [ 41 | ]; 42 | 43 | $this->css = [ 44 | ]; 45 | 46 | parent::init(); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/base/CraftDataSample.php: -------------------------------------------------------------------------------- 1 | craftRules(), 43 | [ 44 | ] 45 | ); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/base/DbDataSampleInterface.php: -------------------------------------------------------------------------------- 1 | 17 |
18 |
19 |

Recommendations

20 | 28 | 29 |
30 |
31 | 32 | {% endif %} 33 | -------------------------------------------------------------------------------- /src/base/DbErrorSampleInterface.php: -------------------------------------------------------------------------------- 1 | sample) { 35 | $this->evaluate(); 36 | } 37 | } 38 | 39 | /** 40 | * Display the passed in ms in seconds, to two decimal places 41 | * 42 | * @param int $number 43 | * 44 | * @return string 45 | */ 46 | public function displayMs(int $number): string 47 | { 48 | return number_format((float)$number / 1000, 2) . 's'; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/web/assets/dist/assets/DataSampleDevice-D3lfOt8n.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"DataSampleDevice-D3lfOt8n.js","sources":["../../../../../buildchain/src/vue/tables/common/DataSampleDevice.vue"],"sourcesContent":["\n\n"],"names":["_sfc_main","className","title"],"mappings":"iDAQA,MAAAA,EAAA,CACA,KAAA,mBACA,MAAA,CACA,OAAA,CACA,KAAA,OACA,QAAA,EACA,EACA,OAAA,CACA,KAAA,QACA,QAAA,EACA,CACA,EACA,SAAA,CACA,WAAA,CACA,IAAAC,EAAA,GAEA,OAAA,KAAA,QAAA,KAAA,SAAA,SACAA,EAAA,KAAA,SAAA,GAAA,sBAAA,wBAGAA,CACA,EACA,OAAA,CACA,IAAAC,EAAA,GAEA,OAAA,KAAA,QAAA,KAAA,SAAA,SACAA,EAAA,KAAA,SAAA,GAAA,gBAAA,kBAGAA,CACA,CACA,CACA"} -------------------------------------------------------------------------------- /src/migrations/m190625_151715_add_indexes.php: -------------------------------------------------------------------------------- 1 | createIndex( 19 | $this->db->getIndexName(), 20 | '{{%webperf_data_samples}}', 21 | 'dateCreated', 22 | false 23 | ); 24 | $this->createIndex( 25 | $this->db->getIndexName(), 26 | '{{%webperf_data_samples}}', 27 | 'requestId', 28 | false 29 | ); 30 | // Add webperf_error_samples indexes 31 | $this->createIndex( 32 | $this->db->getIndexName(), 33 | '{{%webperf_error_samples}}', 34 | 'dateCreated', 35 | false 36 | ); 37 | $this->createIndex( 38 | $this->db->getIndexName(), 39 | '{{%webperf_error_samples}}', 40 | 'requestId', 41 | false 42 | ); 43 | } 44 | 45 | /** 46 | * @inheritdoc 47 | */ 48 | public function safeDown() 49 | { 50 | echo "m190625_151715_add_indexes cannot be reverted.\n"; 51 | return false; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/helpers/Permission.php: -------------------------------------------------------------------------------- 1 | getUser()->getIdentity()) === null) { 38 | throw new ForbiddenHttpException('Your account has no identity.'); 39 | } 40 | 41 | if (!$currentUser->can($permission)) { 42 | throw new ForbiddenHttpException("Your account doesn't have permission to assign access this resource."); 43 | } 44 | } 45 | 46 | // Protected Static Methods 47 | // ========================================================================= 48 | } 49 | -------------------------------------------------------------------------------- /src/validators/DbStringValidator.php: -------------------------------------------------------------------------------- 1 | max === null) { 39 | throw new InvalidConfigException('The "max" property must be set.'); 40 | } 41 | } 42 | 43 | /** 44 | * @inheritdoc 45 | */ 46 | public function validateAttribute($model, $attribute): void 47 | { 48 | $value = $model->$attribute; 49 | $value = TextHelper::cleanupText($value); 50 | $value = StringHelper::encodeMb4($value); 51 | $value = TextHelper::truncate($value, $this->max); 52 | $model->$attribute = $value; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/base/RecommendationTrait.php: -------------------------------------------------------------------------------- 1 | asRaw(Webperf::$plugin->beacons->ampHtmlIframe()); 41 | } 42 | 43 | /** 44 | * @param string $title 45 | * 46 | * @return Response 47 | */ 48 | public function actionBeaconScript(string $title = ''): Response 49 | { 50 | return $this->asRaw(Webperf::$plugin->beacons->htmlBeaconScript(true, $title)); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/base/CleanModel.php: -------------------------------------------------------------------------------- 1 | $propValue) { 47 | if (!property_exists($class, $propName)) { 48 | unset($config[$propName]); 49 | } 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/templates/_frontend/scripts/webperf-boomerang-custom.twig: -------------------------------------------------------------------------------- 1 | // Modern browsers 2 | if (document.addEventListener) { 3 | document.addEventListener("onBoomerangLoaded", function(e) { 4 | // e.detail.BOOMR is a reference to the BOOMR global object 5 | if (webperf_config.title) { 6 | e.detail.BOOMR.addVar({ 7 | 'doc_title': webperf_config.title, 8 | }); 9 | } 10 | if (webperf_config.requestId) { 11 | e.detail.BOOMR.addVar({ 12 | 'request_id': webperf_config.requestId, 13 | }); 14 | } 15 | }); 16 | } 17 | // IE 6, 7, 8 we use onPropertyChange and look for propertyName === "onBoomerangLoaded" 18 | else if (document.attachEvent) { 19 | document.attachEvent("onpropertychange", function(e) { 20 | if (!e) e=event; 21 | if (e.propertyName === "onBoomerangLoaded") { 22 | // e.detail.BOOMR is a reference to the BOOMR global object 23 | if (webperf_config.title) { 24 | e.detail.BOOMR.addVar({ 25 | 'doc_title': webperf_config.title, 26 | }); 27 | } 28 | if (webperf_config.requestId) { 29 | e.detail.BOOMR.addVar({ 30 | 'request_id': webperf_config.requestId, 31 | }); 32 | } 33 | } 34 | }); 35 | } 36 | -------------------------------------------------------------------------------- /src/assetbundles/boomerang/build-boomerang.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | BOOMERANG_BASE_DIR="../../../node_modules/boomerangjs" 3 | BOOMERANG_PLUGINS_JSON_DIR="src/json" 4 | BOOMERANG_PLUGINS_JS_DIR="src/js" 5 | BOOMERANG_DIST_DIR="dist/js" 6 | WORKING_DIR=$(pwd) 7 | PLUGINS_FILE="plugins.json" 8 | COPY_PLUGIN_JS_FILES=( 9 | "webperf-boomer-init.js" 10 | ) 11 | COPY_BUILD_FILES=( 12 | "boomerang-1.0.0.min.js" 13 | "boomerang-1.0.0.min.js.map" 14 | ) 15 | 16 | if [[ ! -f "${BOOMERANG_BASE_DIR}/${PLUGINS_FILE}.bak" ]]; then 17 | echo "Backing up ${BOOMERANG_BASE_DIR}/${PLUGINS_FILE}" 18 | cp "${BOOMERANG_BASE_DIR}/${PLUGINS_FILE}" "${BOOMERANG_BASE_DIR}/${PLUGINS_FILE}.bak" 19 | fi 20 | 21 | echo "Copying ${BOOMERANG_PLUGINS_JSON_DIR}/${PLUGINS_FILE} to ${BOOMERANG_BASE_DIR}/${PLUGINS_FILE}" 22 | cp "${BOOMERANG_PLUGINS_JSON_DIR}/${PLUGINS_FILE}" "${BOOMERANG_BASE_DIR}/${PLUGINS_FILE}" 23 | 24 | for file in "${COPY_PLUGIN_JS_FILES[@]}" 25 | do 26 | echo "Copying ${BOOMERANG_PLUGINS_JS_DIR}/${file} to ${BOOMERANG_BASE_DIR}/plugins/${file}" 27 | cp "${BOOMERANG_PLUGINS_JS_DIR}/${file}" "${BOOMERANG_BASE_DIR}/plugins/${file}" 28 | done 29 | 30 | echo "Building Boomerang" 31 | cd "${BOOMERANG_BASE_DIR}" 32 | grunt clean build --build-number="0" --build-revision="0" 33 | 34 | cd "${WORKING_DIR}" 35 | for file in "${COPY_BUILD_FILES[@]}" 36 | do 37 | echo "Copying ${BOOMERANG_BASE_DIR}/build/${file} to ${BOOMERANG_DIST_DIR}/${file}" 38 | cp "${BOOMERANG_BASE_DIR}/build/${file}" "${BOOMERANG_DIST_DIR}/${file}" 39 | done 40 | 41 | exit 0 42 | -------------------------------------------------------------------------------- /src/base/FluentModel.php: -------------------------------------------------------------------------------- 1 | hasProperty($method)) { 38 | throw new InvalidArgumentException("Property {$method} doesn't exist"); 39 | } 40 | $property = $reflector->getProperty($method); 41 | if (empty($args)) { 42 | // Return the property 43 | return $property->getValue(); 44 | } 45 | // Set the property 46 | $property->setValue($this, $args[0]); 47 | 48 | // Make it chainable 49 | return $this; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/models/RecommendationDataSample.php: -------------------------------------------------------------------------------- 1 | craftRules(), 54 | $this->boomerangRules(), 55 | [ 56 | ] 57 | ); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/base/DbErrorSample.php: -------------------------------------------------------------------------------- 1 | dbErrorRules(), 43 | [ 44 | ] 45 | ); 46 | } 47 | 48 | /** 49 | * @return array 50 | */ 51 | public function behaviors(): array 52 | { 53 | return array_merge( 54 | parent::behaviors(), 55 | [ 56 | 'typecast' => [ 57 | 'class' => AttributeTypecastBehavior::class, 58 | // 'attributeTypes' will be composed automatically according to `rules()` 59 | ], 60 | ] 61 | ); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/icon-mask.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 17 | 18 | -------------------------------------------------------------------------------- /src/templates/alerts/index.twig: -------------------------------------------------------------------------------- 1 | {# @var craft \craft\web\twig\variables\CraftVariable #} 2 | {# 3 | /** 4 | * Webperf plugin for Craft CMS 3.x 5 | * 6 | * Monitor the performance of your webpages through real-world user timing data 7 | * 8 | * @link https://nystudio107.com 9 | * @copyright Copyright (c) 2019 nystudio107 10 | */ 11 | #} 12 | 13 | {% requirePermission "webperf:alerts" %} 14 | 15 | {% extends "webperf/_layouts/webperf-cp.twig" %} 16 | 17 | {% import "_includes/forms" as forms %} 18 | 19 | {% block contextMenu %} 20 | {% include "webperf/_includes/sites-menu.twig" with {'params': {}} %} 21 | {% endblock %} 22 | 23 | {% block actionButton %} 24 | {% endblock %} 25 | 26 | {% block content %} 27 | 32 | 33 | {{ csrfInput() }} 34 | {{ redirectInput("webperf/alerts") }} 35 | 36 |
37 |
38 | {% if not webhooks %} 39 |
40 |

The Webhooks plugin must be installed for the Alerts to function.

41 |
42 | {% endif %} 43 |
44 |
45 | {% endblock %} 46 | 47 | {% block foot %} 48 | {{ parent() }} 49 | {% set tagOptions = { 50 | 'depends': [ 51 | 'nystudio107\\webperf\\assetbundles\\webperf\\WebperfAsset' 52 | ], 53 | } %} 54 | {{ craft.webperf.register('src/js/alerts.js', false, tagOptions, tagOptions) }} 55 | {% endblock %} 56 | -------------------------------------------------------------------------------- /src/models/CraftDbDataSample.php: -------------------------------------------------------------------------------- 1 | dbRules(), 43 | [ 44 | ] 45 | ); 46 | } 47 | 48 | /** 49 | * @return array 50 | */ 51 | public function behaviors(): array 52 | { 53 | return array_merge( 54 | parent::behaviors(), 55 | [ 56 | 'typecast' => [ 57 | 'class' => AttributeTypecastBehavior::class, 58 | // 'attributeTypes' will be composed automatically according to `rules()` 59 | ], 60 | ] 61 | ); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/base/DbDataSampleTrait.php: -------------------------------------------------------------------------------- 1 | 120], 62 | ['url', DbStringValidator::class, 'max' => 255], 63 | ['queryString', DbStringValidator::class, 'max' => 255], 64 | ['title', 'string'], 65 | ['url', 'string'], 66 | ['queryString', 'string'], 67 | ]; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/templates/settings/index.twig: -------------------------------------------------------------------------------- 1 | {# @var craft \craft\web\twig\variables\CraftVariable #} 2 | {# 3 | /** 4 | * Webperf plugin for Craft CMS 3.x 5 | * 6 | * Monitor the performance of your webpages through real-world user timing data 7 | * 8 | * @link https://nystudio107.com 9 | * @copyright Copyright (c) 2019 nystudio107 10 | */ 11 | #} 12 | 13 | {% requirePermission "webperf:settings" %} 14 | 15 | {% extends "_layouts/cp" %} 16 | 17 | {% import "_includes/forms" as forms %} 18 | {% from "webperf/_includes/macros.twig" import configWarning %} 19 | 20 | {% set tabs = { 21 | "general": {label: "General"|t('webperf'), url: "#general"}, 22 | "performance": {label: "Performance"|t('webperf'), url: "#performance"}, 23 | "errors": {label: "Errors"|t('webperf'), url: "#errors"}, 24 | "appearance": {label: "Appearance"|t('webperf'), url: "#appearance"}, 25 | } %} 26 | 27 | {% block content %} 28 | 29 | 30 | 31 | 32 | {{ redirectInput("webperf/settings") }} 33 | 34 | {# -- General settings -- #} 35 | {% include "webperf/settings/_includes/general.twig" %} 36 | 37 | {# -- Performance settings -- #} 38 | {% include "webperf/settings/_includes/performance.twig" %} 39 | 40 | {# -- Errors settings -- #} 41 | {% include "webperf/settings/_includes/errors.twig" %} 42 | 43 | {# -- Appearance settings -- #} 44 | {% include "webperf/settings/_includes/appearance.twig" %} 45 | 46 | {# -- Threshold settings -- #} 47 | {# Disabled from view for now 48 | {% include "webperf/settings/_includes/thresholds.twig" %} 49 | #} 50 | 51 | {# include our JavaScript modules #} 52 | {{ parent() }} 53 | {% endblock %} 54 | -------------------------------------------------------------------------------- /src/recommendations/CraftTotalTime.php: -------------------------------------------------------------------------------- 1 | sample->craftTotalMs >= self::MAX_TOTAL_TIME) { 38 | $this->hasRecommendation = true; 39 | $this->summary = Craft::t( 40 | 'webperf', 41 | 'Look into utilizing the `{% cache %}` tag', 42 | [] 43 | ); 44 | $this->detail = Craft::t( 45 | 'webperf', 46 | 'It took Craft a total of {displayCraftTotalMs} to render. Ensure you are utilizing the `{% cache %}` tag effectively to solve concurrency issues.', 47 | [ 48 | 'displayCraftTotalMs' => $this->displayMs($this->sample->craftTotalMs), 49 | ] 50 | ); 51 | $this->learnMoreUrl = 'https://nystudio107.com/blog/the-craft-cache-tag-in-depth'; 52 | 53 | return; 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/recommendations/CraftQueryCount.php: -------------------------------------------------------------------------------- 1 | sample->craftDbCnt >= self::MAX_QUERIES) { 38 | $this->hasRecommendation = true; 39 | $this->summary = Craft::t( 40 | 'webperf', 41 | 'Look into Eager Loading to decrease database queries', 42 | [] 43 | ); 44 | $this->detail = Craft::t( 45 | 'webperf', 46 | 'A total of {dbQueries} database queries were executed. Look into decreasing the number of database queries needed by leveraging Eager Loading in Craft CMS.', 47 | [ 48 | 'dbQueries' => round($this->sample->craftDbCnt), 49 | ] 50 | ); 51 | $this->learnMoreUrl = 'https://nystudio107.com/blog/speed-up-your-craft-cms-templates-with-eager-loading'; 52 | 53 | return; 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/recommendations/CraftQueryTime.php: -------------------------------------------------------------------------------- 1 | sample->craftDbMs >= self::MAX_QUERY_TIME) { 38 | $this->hasRecommendation = true; 39 | $this->summary = Craft::t( 40 | 'webperf', 41 | 'Look into decreasing the time database queries are taking', 42 | [] 43 | ); 44 | $this->detail = Craft::t( 45 | 'webperf', 46 | 'The database queries took {displayCraftDbMs} to execute. Try to simplify the database queries, or leverage Eager Loading in Craft to speed them up.', 47 | [ 48 | 'displayCraftDbMs' => $this->displayMs($this->sample->craftDbMs), 49 | ] 50 | ); 51 | $this->learnMoreUrl = 'https://nystudio107.com/blog/speed-up-your-craft-cms-templates-with-eager-loading'; 52 | 53 | return; 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/templates/settings/_includes/errors.twig: -------------------------------------------------------------------------------- 1 | {% import "_includes/forms" as forms %} 2 | {% from "webperf/_includes/macros.twig" import configWarning %} 3 | 38 | -------------------------------------------------------------------------------- /src/recommendations/CraftTwigTime.php: -------------------------------------------------------------------------------- 1 | sample->craftTwigMs >= self::MAX_TWIG_TIME) { 38 | $this->hasRecommendation = true; 39 | $this->summary = Craft::t( 40 | 'webperf', 41 | 'Look into decreasing the Twig template rendering time', 42 | [] 43 | ); 44 | $this->detail = Craft::t( 45 | 'webperf', 46 | 'The Twig templates took {displayCraftTwigMs} to render. Try to simplify the templates by doing less computation in Twig, and profiling them to see where the bottlenecks are.', 47 | [ 48 | 'displayCraftTwigMs' => $this->displayMs($this->sample->craftTwigMs), 49 | ] 50 | ); 51 | $this->learnMoreUrl = 'https://nystudio107.com/blog/profiling-your-website-with-craft-cms-3s-debug-toolbar'; 52 | 53 | return; 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nystudio107/craft-webperf", 3 | "description": "Webperf helps you build & maintain high quality websites through Real User Measurement of your website's performance", 4 | "type": "craft-plugin", 5 | "version": "5.0.0", 6 | "minimum-stability": "dev", 7 | "prefer-stable": true, 8 | "keywords": [ 9 | "craft", 10 | "cms", 11 | "craftcms", 12 | "craft-plugin", 13 | "webperf" 14 | ], 15 | "support": { 16 | "docs": "https://nystudio107.com/docs/webperf", 17 | "issues": "https://nystudio107.com/plugins/webperf/support", 18 | "source": "https://github.com/nystudio107/craft-webperf" 19 | }, 20 | "license": "proprietary", 21 | "authors": [ 22 | { 23 | "name": "nystudio107", 24 | "homepage": "https://nystudio107.com" 25 | } 26 | ], 27 | "require": { 28 | "php": "^8.2", 29 | "craftcms/cms": "^5.0.0", 30 | "nystudio107/craft-plugin-vite": "^5.0.0", 31 | "jaybizzle/crawler-detect": "^1.2.37", 32 | "league/csv": "^8.2 || ^9.0", 33 | "whichbrowser/parser": "^2.0.37" 34 | }, 35 | "require-dev": { 36 | "craftcms/ecs": "dev-main", 37 | "craftcms/phpstan": "dev-main", 38 | "craftcms/rector": "dev-main", 39 | "nystudio107/craft-minify": "^5.0.0", 40 | "nystudio107/craft-seomatic": "^5.0.0", 41 | "putyourlightson/craft-blitz": "^5.0.0" 42 | }, 43 | "scripts": { 44 | "phpstan": "phpstan --ansi --memory-limit=2G", 45 | "check-cs": "ecs check --ansi", 46 | "fix-cs": "ecs check --fix --ansi" 47 | }, 48 | "autoload": { 49 | "psr-4": { 50 | "nystudio107\\webperf\\": "src/" 51 | } 52 | }, 53 | "config": { 54 | "allow-plugins": { 55 | "craftcms/plugin-installer": true, 56 | "yiisoft/yii2-composer": true 57 | }, 58 | "optimize-autoloader": true, 59 | "sort-packages": true 60 | }, 61 | "extra": { 62 | "class": "nystudio107\\webperf\\Webperf", 63 | "handle": "webperf", 64 | "name": "Webperf" 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/recommendations/DomInteractive.php: -------------------------------------------------------------------------------- 1 | sample->domInteractive >= self::MAX_DOM_INTERACTIVE_TIME) { 38 | $this->hasRecommendation = true; 39 | $this->summary = Craft::t( 40 | 'webperf', 41 | 'The time before the user can interact with the page is too long', 42 | [] 43 | ); 44 | $this->detail = Craft::t( 45 | 'webperf', 46 | 'The time to interactive was {displayDomInteractive}. Try to reduce this by reducing the amount of JavaScript that is executed on your page, and ensure that [Marketing Tags](https://nystudio107.com/blog/tags-gone-wild) are kept in check.', 47 | [ 48 | 'displayDomInteractive' => $this->displayMs($this->sample->domInteractive), 49 | ] 50 | ); 51 | $this->learnMoreUrl = 'https://developers.google.com/web/tools/lighthouse/audits/time-to-interactive'; 52 | 53 | return; 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/web/assets/dist/assets/RecommendationsList-T8Et27n2.js: -------------------------------------------------------------------------------- 1 | import{n as i,A as r}from"./vue-apexcharts-C2g27_eS.js";import{S as o}from"./SamplePaneFooter-DUo1brgB.js";const l=s=>({baseURL:s,headers:{"X-Requested-With":"XMLHttpRequest"}}),d=(s,e,t,a)=>{s.get(e,{params:t}).then(n=>{a&&a(n.data)}).catch(n=>{console.log(n)})},p={components:{"sample-pane-footer":o},props:{start:{type:String,default:""},end:{type:String,default:""},devModeWarning:{type:Boolean,default:!1},pageUrl:{type:String,default:""},siteId:{type:Number,default:0},apiUrl:{type:String,default:""}},data:function(){return{series:[],displayStart:this.start,displayEnd:this.end}},created(){this.getSeriesData()},mounted(){this.$events.$on("change-range",s=>this.onChangeRange(s))},methods:{getSeriesData:async function(){const s=r.create(l(this.apiUrl));let e={start:this.displayStart,end:this.displayEnd,pageUrl:this.pageUrl,siteId:this.siteId};await d(s,"",e,t=>{t[0]!==void 0&&(this.series=t)})},onChangeRange(s){this.displayStart=s.start,this.displayEnd=s.end,this.getSeriesData()}}};var c=function(){var e=this,t=e._self._c;return t("div",[e.series.length?e._e():t("div",{staticClass:"text-3xl text-center py-10"},[e._v(" 🎉 No recommendations found. Nice job! ")]),e._l(e.series,function(a,n){return t("div",{key:n},[t("div",{staticClass:"field pb-4"},[t("p",{staticClass:"warning text-2xl leading-normal"},[t("span",{domProps:{innerHTML:e._s(a.summary)}})]),t("div",{staticClass:"heading",staticStyle:{"padding-left":"26px"}},[t("p",{staticClass:"instructions text-xl leading-tight"},[t("span",{domProps:{innerHTML:e._s(a.detail)}}),t("span",{staticClass:"field inline-block m-0"},[a.learnMoreUrl!==""?t("a",{staticClass:"go notice",attrs:{href:a.learnMoreUrl,rel:"noopener,nofollow",target:"_blank"}},[e._v("Learn More")]):e._e()])])])])])}),t("sample-pane-footer",{attrs:{"display-dev-mode-warning":e.devModeWarning,"page-url":e.pageUrl,"site-id":e.siteId,column:"id",end:"end",start:"start",subject:"recommendations"}})],2)},g=[],u=i(p,c,g,!1,null,null,null,null);const h=u.exports;export{h as R}; 2 | //# sourceMappingURL=RecommendationsList-T8Et27n2.js.map 3 | -------------------------------------------------------------------------------- /src/base/CraftDataSampleTrait.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 11 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /src/web/assets/dist/img/Webperf-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 11 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /src/models/BoomerangDbDataSample.php: -------------------------------------------------------------------------------- 1 | dbRules(), 44 | [ 45 | ['countryCode', DbStringValidator::class, 'max' => 2], 46 | ['device', DbStringValidator::class, 'max' => 50], 47 | ['browser', DbStringValidator::class, 'max' => 50], 48 | ['os', DbStringValidator::class, 'max' => 50], 49 | ] 50 | ); 51 | } 52 | 53 | /** 54 | * @return array 55 | */ 56 | public function behaviors(): array 57 | { 58 | return array_merge( 59 | parent::behaviors(), 60 | [ 61 | 'typecast' => [ 62 | 'class' => AttributeTypecastBehavior::class, 63 | // 'attributeTypes' will be composed automatically according to `rules()` 64 | ], 65 | ] 66 | ); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/recommendations/FirstByte.php: -------------------------------------------------------------------------------- 1 | sample->firstByte >= self::MAX_TOTAL_TIME) { 38 | $this->hasRecommendation = true; 39 | $this->summary = Craft::t( 40 | 'webperf', 41 | 'The Time To First Byte (TTFB) is high', 42 | [] 43 | ); 44 | $this->detail = Craft::t( 45 | 'webperf', 46 | 'The time it took for the client to receive the first byte of data from the server was {displayFirstByte}. Look into decreasing that via the `{% cache %}` tag or some kind of static caching such as the [Blitz](https://github.com/putyourlightson/craft-blitz) plugin, [FastCGI Cache](https://nystudio107.com/blog/static-caching-with-craft-cms), or [Varnish](https://supercool.github.io/2015/06/08/making-craft-sing-with-varnish-and-nginx.html).', 47 | [ 48 | 'displayFirstByte' => $this->displayMs($this->sample->firstByte), 49 | ] 50 | ); 51 | $this->learnMoreUrl = 'https://craftcms.com/guides/why-is-my-site-slow'; 52 | 53 | return; 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/nystudio107/craft-webperf/badges/quality-score.png?b=v5)](https://scrutinizer-ci.com/g/nystudio107/craft-webperf/?branch=v5) [![Code Coverage](https://scrutinizer-ci.com/g/nystudio107/craft-webperf/badges/coverage.png?b=v5)](https://scrutinizer-ci.com/g/nystudio107/craft-webperf/?branch=v5) [![Build Status](https://scrutinizer-ci.com/g/nystudio107/craft-webperf/badges/build.png?b=v5)](https://scrutinizer-ci.com/g/nystudio107/craft-webperf/build-status/v5) [![Code Intelligence Status](https://scrutinizer-ci.com/g/nystudio107/craft-webperf/badges/code-intelligence.svg?b=v5)](https://scrutinizer-ci.com/code-intelligence) 2 | 3 | # Webperf plugin for Craft CMS 5.x 4 | 5 | Webperf helps you build & maintain high quality websites through Real User Measurement of your website's performance 6 | 7 | ![Screenshot](./docs/docs/resources/img/plugin-banner.jpg) 8 | 9 | **Note**: _The license fee for this plugin is $59.00 via the Craft Plugin Store._ 10 | 11 | ## Requirements 12 | 13 | This plugin requires Craft CMS 5.0.0 or later. 14 | 15 | ## Installation 16 | 17 | To install the plugin, follow these instructions. 18 | 19 | 1. Open your terminal and go to your Craft project: 20 | 21 | cd /path/to/project 22 | 23 | 2. Then tell Composer to load the plugin: 24 | 25 | composer require nystudio107/craft-webperf 26 | 27 | 3. Install the plugin via `./craft install/plugin webperf` via the CLI, or in the Control Panel, go to Settings → Plugins and click the “Install” button for Webperf. 28 | 29 | You can also install Webperf via the **Plugin Store** in the Craft Control Panel. 30 | 31 | ## Documentation 32 | 33 | Click here -> [Webperf Documentation](https://nystudio107.com/plugins/webperf/documentation) 34 | 35 | ## Webperf Roadmap 36 | 37 | Some things to do, and ideas for potential features: 38 | 39 | * User definable "Alerts" that can be consumed by the Webhooks plugin 40 | * Have the Recommendations be a type of "Alert" 41 | * Comparison of multiple periods side by side to show comparative performance 42 | 43 | Brought to you by [nystudio107](https://nystudio107.com) 44 | -------------------------------------------------------------------------------- /src/web/assets/dist/assets/ErrorsDetailAreaChart-CoSEsohF.js: -------------------------------------------------------------------------------- 1 | import{n as o,a as h,A as p}from"./vue-apexcharts-C2g27_eS.js";const n=t=>t.map(function(s){return Math.max.apply(null,s)}),d=t=>({baseURL:t,headers:{"X-Requested-With":"XMLHttpRequest"}}),c=(t,s,e,r)=>{t.get(s,{params:e}).then(a=>{r&&r(a.data)}).catch(a=>{console.log(a)})},u={components:{apexcharts:h},props:{title:{type:String,default:""},start:{type:String,default:""},end:{type:String,default:""},pageUrl:{type:String,default:""},siteId:{type:Number,default:0},apiUrl:{type:String,default:""}},data:function(){return{chartOptions:{chart:{id:"vuechart-pages-detail",toolbar:{show:!1},sparkline:{enabled:!1},animations:{enabled:!1}},tooltip:{enabled:!0,inverseOrder:!0,x:{show:!1}},colors:["#1F9D55","#CC1F1A"],stroke:{curve:"smooth",width:3},fill:{type:"solid",opacity:.5,gradient:{enabled:!1}},legend:{formatter:void 0,offsetX:0,offsetY:-10},xaxis:{labels:{show:!1,minHeight:"20px"},crosshairs:{width:1}},yaxis:{min:0,max:0,seriesName:"Errors",tickAmount:1,labels:{formatter:t=>Math.round(t)}},labels:[],title:{text:this.title,offsetX:0,style:{fontSize:"24px",cssClass:"apexcharts-yaxis-title"}}},series:[{name:"empty",data:[0]}],displayStart:this.start,displayEnd:this.end,displayMaxValue:this.maxValue}},created(){this.getSeriesData()},mounted(){this.$events.$on("change-range",t=>this.onChangeRange(t))},methods:{getSeriesData:async function(){const t=p.create(d(this.apiUrl));let s={start:this.displayStart,end:this.displayEnd,pageUrl:this.pageUrl,siteId:this.siteId};await c(t,"",s,e=>{if(e[0]!==void 0){let r=n([e[0].data])[0],a=n([e[1].data])[0],i=r>a?r:a;this.chartOptions={...this.chartOptions,yaxis:{min:0,max:i,tickAmount:i>10?10:i,labels:{formatter:l=>Math.round(l)}},xaxis:{categories:e[0].labels,type:"category",labels:{show:!1,minHeight:"20px"},crosshairs:{width:1}},labels:e[0].labels},this.series=e}})},onChangeRange(t){this.displayStart=t.start,this.displayEnd=t.end,this.getSeriesData()}}};var f=function(){var s=this,e=s._self._c;return e("apexcharts",{attrs:{options:s.chartOptions,series:s.series,height:"450px",type:"area",width:"100%"}})},m=[],g=o(u,f,m,!1,null,null,null,null);const x=g.exports;export{x as E}; 2 | //# sourceMappingURL=ErrorsDetailAreaChart-CoSEsohF.js.map 3 | -------------------------------------------------------------------------------- /src/recommendations/FirstContentfulPaint.php: -------------------------------------------------------------------------------- 1 | sample->firstContentfulPaint >= self::MAX_FIRST_CONTENTFUL_PAINT_TIME) { 38 | $this->hasRecommendation = true; 39 | $this->summary = Craft::t( 40 | 'webperf', 41 | 'The wait is too long before content is displayed', 42 | [] 43 | ); 44 | $this->detail = Craft::t( 45 | 'webperf', 46 | 'The first contentful paint took {displayFirstContentfulPaint}. Try to avoid blocking the render by implementing [CriticalCSS](https://nystudio107.com/blog/implementing-critical-css), optimizing the [Critical Path](https://developers.google.com/web/fundamentals/performance/critical-rendering-path/) by loading JavaScript asynchronously, and using the [font-display](https://css-tricks.com/font-display-masses/) property.', 47 | [ 48 | 'displayFirstContentfulPaint' => $this->displayMs($this->sample->firstContentfulPaint), 49 | ] 50 | ); 51 | $this->learnMoreUrl = 'https://developers.google.com/web/tools/lighthouse/audits/first-contentful-paint'; 52 | 53 | return; 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/controllers/RecommendationsController.php: -------------------------------------------------------------------------------- 1 | recommendations->data($pageUrl, $start, $end, $siteId); 61 | if (!empty($stats)) { 62 | $recSample = new RecommendationDataSample($stats); 63 | $data = Webperf::$plugin->recommendations->list($recSample); 64 | } 65 | 66 | return $this->asJson($data); 67 | } 68 | 69 | // Protected Methods 70 | // ========================================================================= 71 | } 72 | -------------------------------------------------------------------------------- /src/templates/settings/_includes/appearance.twig: -------------------------------------------------------------------------------- 1 | {% import "_includes/forms" as forms %} 2 | {% from "webperf/_includes/macros.twig" import configWarning %} 3 | 46 | -------------------------------------------------------------------------------- /src/base/DbErrorSampleTrait.php: -------------------------------------------------------------------------------- 1 | 120], 73 | ['url', DbStringValidator::class, 'max' => 255], 74 | ['queryString', DbStringValidator::class, 'max' => 255], 75 | ['type', DbStringValidator::class, 'max' => 16], 76 | ['pageErrors', ArrayValidator::class], 77 | [ 78 | [ 79 | 'title', 80 | 'type', 81 | 'url', 82 | 'queryString', 83 | ], 84 | 'string', 85 | ], 86 | ]; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/web/assets/dist/assets/SamplePaneFooter-DUo1brgB.js: -------------------------------------------------------------------------------- 1 | import{n as i,A as r}from"./vue-apexcharts-C2g27_eS.js";const l=t=>({baseURL:t,headers:{"X-Requested-With":"XMLHttpRequest"}}),o=(t,e,a,n)=>{t.get(e,{params:a}).then(s=>{n&&n(s.data)}).catch(s=>{console.log(s)})},d={components:{},props:{start:{type:String,default:""},end:{type:String,default:""},column:{type:String,default:""},displayDevModeWarning:{type:Boolean,default:!1},pageUrl:{type:String,default:""},subject:{type:String,default:""},siteId:{type:Number,default:0},apiUrl:{type:String,default:""}},data:function(){return{samples:0,displayStart:this.start,displayEnd:this.end,displayMaxValue:this.maxValue}},created(){this.getSeriesData()},mounted(){this.$events.$on("change-range",t=>this.onChangeRange(t))},methods:{getSeriesData:async function(){const t=r.create(l(this.apiUrl));let e={column:this.column,start:this.displayStart,end:this.displayEnd,pageUrl:this.pageUrl,siteId:this.siteId};await o(t,"",e,a=>{a.cnt!==void 0&&(this.samples=a.cnt)})},onChangeRange(t){this.displayStart=t.start,this.displayEnd=t.end,this.getSeriesData()},formatNumber(t){return t.toString().replace(/\B(?=(\d{3})+(?!\d))/g,",")}}};var c=function(){var e=this,a=e._self._c;return a("div",{staticClass:"field"},[a("div",{staticClass:"heading"},[a("p",{staticClass:"instructions"},[e._v(" The "+e._s(e.subject)+" data is an "),a("em",[e._v("average")]),e._v(" of "),a("strong",[e._v(e._s(e.formatNumber(e.samples)))]),e._v(" data sample"),e.samples!==1?a("span",[e._v("s")]):e._e(),e._v(". ")])]),e.samples<100?a("p",{staticClass:"warning"},[e._v(" Webperf has collected less than "),a("strong",[e._v("100")]),e._v(" data samples. The sample size is not statistically significant, so above averaged results may not be meaningful. ")]):e._e(),e.displayDevModeWarning?a("p",{staticClass:"warning"},[e._v(" Craft performance will be slower than normal with "),a("code",[e._v("devMode")]),e._v(" enabled due to extensive logging and disabling of some caches. "),e._m(0)]):e._e()])},p=[function(){var t=this,e=t._self._c;return e("span",{staticClass:"field inline-block m-0"},[e("a",{staticClass:"notice go",attrs:{href:"https://craftcms.com/guides/what-dev-mode-does",target:"_blank"}},[t._v("Learn More")])])}],m=i(d,c,p,!1,null,null,null,null);const g=m.exports;export{g as S}; 2 | //# sourceMappingURL=SamplePaneFooter-DUo1brgB.js.map 3 | -------------------------------------------------------------------------------- /src/web/assets/dist/assets/PerformanceDetailAreaChart-BW1ni5m3.js: -------------------------------------------------------------------------------- 1 | import{n as i,a as n,A as l}from"./vue-apexcharts-C2g27_eS.js";const o=t=>t.map(function(e){return Math.max.apply(null,e)}),h=t=>({baseURL:t,headers:{"X-Requested-With":"XMLHttpRequest"}}),p=(t,e,a,s)=>{t.get(e,{params:a}).then(r=>{s&&s(r.data)}).catch(r=>{console.log(r)})},d={components:{apexcharts:n},props:{title:{type:String,default:""},start:{type:String,default:""},end:{type:String,default:""},pageUrl:{type:String,default:""},siteId:{type:Number,default:0},apiUrl:{type:String,default:""}},data:function(){return{chartOptions:{chart:{id:"vuechart-pages-detail",toolbar:{show:!1},sparkline:{enabled:!1},animations:{enabled:!1}},dataLabels:{enabled:!1},tooltip:{enabled:!0,inverseOrder:!0,x:{show:!1}},colors:["#CC1F1A","#E3342F","#EF5753","#DE751F","#F6993F","#FAAD63","#2779BD","#3490DC","#6CB2EB","#BCDEFA"],stroke:{curve:"smooth",width:3},fill:{type:"solid",opacity:.9,gradient:{enabled:!1}},legend:{formatter:void 0,offsetX:0,offsetY:-10},xaxis:{type:"category",labels:{show:!1,minHeight:"20px"},crosshairs:{width:1}},yaxis:{min:0,max:0,seriesName:"Time",labels:{formatter:t=>this.statFormatter(t)}},labels:[],title:{text:this.title,offsetX:0,style:{fontSize:"24px",cssClass:"apexcharts-yaxis-title"}}},series:[{name:"empty",data:[0]}],displayStart:this.start,displayEnd:this.end,displayMaxValue:this.maxValue}},created(){this.getSeriesData()},mounted(){this.$events.$on("change-range",t=>this.onChangeRange(t))},methods:{getSeriesData:async function(){const t=l.create(h(this.apiUrl));let e={start:this.displayStart,end:this.displayEnd,pageUrl:this.pageUrl,siteId:this.siteId};await p(t,"",e,a=>{if(a[0]!==void 0){let s=o([a[9].data])[0];s=Math.ceil(s/1e3)*1e3,this.chartOptions={...this.chartOptions,yaxis:{min:0,max:s,labels:{formatter:r=>this.statFormatter(r)}},xaxis:{categories:a[0].labels,type:"category",labels:{show:!1,minHeight:"20px"},crosshairs:{width:1}},labels:a[0].labels},this.series=a}})},onChangeRange(t){this.displayStart=t.start,this.displayEnd=t.end,this.getSeriesData()},statFormatter(t){return Number(t/1e3).toFixed(2)+"s"}}};var c=function(){var e=this,a=e._self._c;return a("apexcharts",{attrs:{options:e.chartOptions,series:e.series,height:"450px",type:"area",width:"100%"}})},f=[],u=i(d,c,f,!1,null,null,null,null);const g=u.exports;export{g as P}; 2 | //# sourceMappingURL=PerformanceDetailAreaChart-BW1ni5m3.js.map 3 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright © nystudio107 2 | 3 | Permission is hereby granted to any person obtaining a copy of this software 4 | (the “Software”) to use, copy, modify, merge, publish and/or distribute copies 5 | of the Software, and to permit persons to whom the Software is furnished to do 6 | so, subject to the following conditions: 7 | 8 | 1. **Don’t plagiarize.** The above copyright notice and this license shall be 9 | included in all copies or substantial portions of the Software. 10 | 11 | 2. **Don’t use the same license on more than one project.** Each licensed copy 12 | of the Software shall be actively installed in no more than one production 13 | environment at a time. 14 | 15 | 3. **Don’t mess with the licensing features.** Software features related to 16 | licensing shall not be altered or circumvented in any way, including (but 17 | not limited to) license validation, payment prompts, feature restrictions, 18 | and update eligibility. 19 | 20 | 4. **Pay up.** Payment shall be made immediately upon receipt of any notice, 21 | prompt, reminder, or other message indicating that a payment is owed. 22 | 23 | 5. **Follow the law.** All use of the Software shall not violate any applicable 24 | law or regulation, nor infringe the rights of any other person or entity. 25 | 26 | Failure to comply with the foregoing conditions will automatically and 27 | immediately result in termination of the permission granted hereby. This 28 | license does not include any right to receive updates to the Software or 29 | technical support. Licensees bear all risk related to the quality and 30 | performance of the Software and any modifications made or obtained to it, 31 | including liability for actual and consequential harm, such as loss or 32 | corruption of data, and any necessary service, repair, or correction. 33 | 34 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 35 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 36 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 37 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER 38 | LIABILITY, INCLUDING SPECIAL, INCIDENTAL AND CONSEQUENTIAL DAMAGES, WHETHER IN 39 | AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 40 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 41 | -------------------------------------------------------------------------------- /src/console/controllers/SamplesController.php: -------------------------------------------------------------------------------- 1 | dataSamples->trimDataSamples($this->limit); 65 | echo Craft::t( 66 | 'webperf', 67 | 'Trimmed {rows} from webperf_data_samples table', 68 | ['rows' => $affectedRows] 69 | ) . PHP_EOL; 70 | echo Craft::t('webperf', 'Trimming error samples') . PHP_EOL; 71 | $affectedRows = Webperf::$plugin->errorSamples->trimErrorSamples($this->limit); 72 | echo Craft::t( 73 | 'webperf', 74 | 'Trimmed {rows} from webperf_error_samples table', 75 | ['rows' => $affectedRows] 76 | ) . PHP_EOL; 77 | 78 | return 0; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/records/Alerts.php: -------------------------------------------------------------------------------- 1 | [ 87 | 'class' => AttributeTypecastBehavior::class, 88 | // 'attributeTypes' will be composed automatically according to `rules()` 89 | ], 90 | ] 91 | ); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/templates/_frontend/scripts/capture-errors.twig: -------------------------------------------------------------------------------- 1 | // from https://github.com/akamai/boomerang/blob/master/tests/page-template-snippets/captureErrorsSnippetNoScript.tpl 2 | (function(w){ 3 | w.BOOMR = w.BOOMR || {}; 4 | 5 | w.BOOMR.globalOnErrorOrig = w.BOOMR.globalOnError = w.onerror; 6 | w.BOOMR.globalErrors = []; 7 | 8 | var now = (function() { 9 | try { 10 | if ("performance" in w) { 11 | return function() { 12 | return Math.round(w.performance.now() + performance.timing.navigationStart); 13 | }; 14 | } 15 | } 16 | catch (ignore) {} 17 | 18 | return Date.now || function() { 19 | return new Date().getTime(); 20 | }; 21 | })(); 22 | 23 | w.onerror = function BOOMR_plugins_errors_onerror(message, fileName, lineNumber, columnNumber, error) { 24 | if (w.BOOMR.version) { 25 | // If Boomerang has already loaded, the only reason this function would still be alive would be if 26 | // we're in the chain from another handler that overwrote window.onerror. In that case, we should 27 | // run globalOnErrorOrig which presumably hasn't been overwritten by Boomerang. 28 | if (typeof w.BOOMR.globalOnErrorOrig === "function") { 29 | w.BOOMR.globalOnErrorOrig.apply(w, arguments); 30 | } 31 | 32 | return; 33 | } 34 | 35 | if (typeof error !== "undefined" && error !== null) { 36 | error.timestamp = now(); 37 | w.BOOMR.globalErrors.push(error); 38 | } 39 | else { 40 | w.BOOMR.globalErrors.push({ 41 | message: message, 42 | fileName: fileName, 43 | lineNumber: lineNumber, 44 | columnNumber: columnNumber, 45 | noStack: true, 46 | timestamp: now() 47 | }); 48 | } 49 | 50 | if (typeof w.BOOMR.globalOnError === "function") { 51 | w.BOOMR.globalOnError.apply(w, arguments); 52 | } 53 | }; 54 | 55 | // make it easier to detect this is our wrapped handler 56 | w.onerror._bmr = true; 57 | })(window); 58 | -------------------------------------------------------------------------------- /src/web/assets/dist/assets/tri-color-blend-CUFlaG2k.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"tri-color-blend-CUFlaG2k.js","sources":["../../../../../buildchain/src/js/tri-color-blend.js"],"sourcesContent":["export default class TriColorBlend {\n\n constructor(clr1 = '#00C800', clr2 = '#FFFF00', clr3 = '#C80000')\n {\n this.clr1 = this.HexToRGB(clr1);\n this.clr2 = this.HexToRGB(clr2);\n this.clr3 = this.HexToRGB(clr3);\n }\n\n RGBToHex(r, g, b)\n {\n let bin = r << 16 | g << 8 | b;\n return (function (h) {\n return new Array(7 - h.length).join(\"0\") + h\n })(bin.toString(16).toUpperCase())\n }\n\n HexToRGB(hex)\n {\n let result = /^#?([a-f\\d]{2})([a-f\\d]{2})([a-f\\d]{2})$/i.exec(hex);\n return result ? {\n r: parseInt(result[1], 16),\n g: parseInt(result[2], 16),\n b: parseInt(result[3], 16)\n } : null;\n }\n\n colorFromPercentage(val)\n {\n let startColor = this.clr1;\n let endColor = this.clr2;\n if (val >= 50) {\n startColor = this.clr2;\n endColor = this.clr3;\n val = val - 50;\n }\n const multiplier = (val / 50);\n const r = Math.round(startColor.r + multiplier * (endColor.r - startColor.r));\n const g = Math.round(startColor.g + multiplier * (endColor.g - startColor.g));\n const b = Math.round(startColor.b + multiplier * (endColor.b - startColor.b));\n return '#' + this.RGBToHex(r,g,b);\n }\n}\n"],"names":["TriColorBlend","clr1","clr2","clr3","r","g","b","bin","h","hex","result","val","startColor","endColor","multiplier"],"mappings":"AAAe,MAAMA,CAAc,CAE/B,YAAYC,EAAO,UAAWC,EAAO,UAAWC,EAAO,UACvD,CACI,KAAK,KAAO,KAAK,SAASF,CAAI,EAC9B,KAAK,KAAO,KAAK,SAASC,CAAI,EAC9B,KAAK,KAAO,KAAK,SAASC,CAAI,CACjC,CAED,SAASC,EAAGC,EAAGC,EACf,CACI,IAAIC,EAAMH,GAAK,GAAKC,GAAK,EAAIC,EAC7B,OAAQ,SAAUE,EAAG,CACjB,OAAO,IAAI,MAAM,EAAIA,EAAE,MAAM,EAAE,KAAK,GAAG,EAAIA,CAC9C,EAAED,EAAI,SAAS,EAAE,EAAE,YAAW,CAAE,CACpC,CAED,SAASE,EACT,CACI,IAAIC,EAAS,4CAA4C,KAAKD,CAAG,EACjE,OAAOC,EAAS,CACZ,EAAG,SAASA,EAAO,CAAC,EAAG,EAAE,EACzB,EAAG,SAASA,EAAO,CAAC,EAAG,EAAE,EACzB,EAAG,SAASA,EAAO,CAAC,EAAG,EAAE,CAC5B,EAAG,IACP,CAED,oBAAoBC,EACpB,CACI,IAAIC,EAAa,KAAK,KAClBC,EAAW,KAAK,KAChBF,GAAO,KACPC,EAAa,KAAK,KAClBC,EAAW,KAAK,KAChBF,EAAMA,EAAM,IAEhB,MAAMG,EAAcH,EAAM,GACpBP,EAAI,KAAK,MAAMQ,EAAW,EAAIE,GAAcD,EAAS,EAAID,EAAW,EAAE,EACtEP,EAAI,KAAK,MAAMO,EAAW,EAAIE,GAAcD,EAAS,EAAID,EAAW,EAAE,EACtEN,EAAI,KAAK,MAAMM,EAAW,EAAIE,GAAcD,EAAS,EAAID,EAAW,EAAE,EAC5E,MAAO,IAAM,KAAK,SAASR,EAAEC,EAAEC,CAAC,CACnC,CACL"} -------------------------------------------------------------------------------- /src/variables/WebperfVariable.php: -------------------------------------------------------------------------------- 1 | includeBeacon = $include; 37 | } 38 | 39 | /** 40 | * Whether to include the Craft beacon or not 41 | * 42 | * @param bool $include 43 | */ 44 | public function includeCraftBeacon(bool $include): void 45 | { 46 | Webperf::$settings->includeCraftProfiling = $include; 47 | } 48 | 49 | /** 50 | * Change the type of render; either `html` or `amp-html` are valid for $renderType 51 | * 52 | * @param string $renderType 53 | */ 54 | public function renderType(string $renderType): void 55 | { 56 | Webperf::$renderType = $renderType; 57 | } 58 | 59 | /** 60 | * @param int $siteId 61 | * @param string $column 62 | * 63 | * @return int|string 64 | */ 65 | public function totalSamples(int $siteId, string $column): int|string 66 | { 67 | return Webperf::$plugin->dataSamples->totalSamples($siteId, $column); 68 | } 69 | 70 | /** 71 | * Get the total number of errors optionally limited by siteId, between 72 | * $start and $end 73 | * 74 | * @param int $siteId 75 | * @param string $start 76 | * @param string $end 77 | * @param string|null $pageUrl 78 | * @param string|null $type 79 | * 80 | * @return int 81 | */ 82 | public function totalErrorSamplesRange(int $siteId, string $start, string $end, $pageUrl = null, $type = null): int 83 | { 84 | return Webperf::$plugin->errorSamples->totalErrorSamplesRange($siteId, $start, $end, $pageUrl, $type); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/templates/_includes/sites-menu.twig: -------------------------------------------------------------------------------- 1 | {% import "_includes/forms" as forms %} 2 | 3 | {% set baseUrl = "webperf/#{controllerHandle}/" %} 4 | {% set params = params ?? [] %} 5 | 6 | {% if showSites %} 7 | {{ sitesMenuLabel }} 8 | 10 | 11 | 52 | {% endif %} 53 | -------------------------------------------------------------------------------- /src/log/ErrorsTarget.php: -------------------------------------------------------------------------------- 1 | 'error', 29 | 2 => 'warning', 30 | ]; 31 | 32 | // Public Properties 33 | // ========================================================================= 34 | 35 | /** 36 | * @var array accumulated page errors 37 | */ 38 | public $pageErrors = []; 39 | 40 | // Public Methods 41 | // ========================================================================= 42 | 43 | /** 44 | * Processes the given log messages. 45 | * This method will filter the given messages with [[levels]] and 46 | * [[categories]]. And if requested, it will also export the filtering 47 | * result to specific medium (e.g. email). 48 | * 49 | * @param array $messages log messages to be processed. See 50 | * [[Logger::messages]] for the structure of each 51 | * message. 52 | * @param bool $final whether this method is called at the end of the 53 | * current application 54 | */ 55 | public function collect($messages, $final) 56 | { 57 | // Merge in any messages intended for us 58 | $this->messages = array_merge( 59 | $this->messages, 60 | static::filterMessages($messages, $this->getLevels(), $this->categories, $this->except) 61 | ); 62 | foreach ($this->messages as $message) { 63 | // Ignore objects/arrays 64 | if (!is_string($message[0])) { 65 | continue; 66 | } 67 | $this->pageErrors[] = [ 68 | 'level' => self::ERROR_LEVELS[$message[1]], 69 | 'message' => $message[0], 70 | 'category' => $message[2], 71 | ]; 72 | } 73 | $this->messages = []; 74 | if ($final) { 75 | $this->export(); 76 | Webperf::$plugin->beacons->includeCraftErrorsBeacon(); 77 | } 78 | } 79 | 80 | /** 81 | * @inheritdoc 82 | */ 83 | public function export() 84 | { 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/widgets/Metrics.php: -------------------------------------------------------------------------------- 1 | 'Hello, world.'], 74 | ] 75 | ); 76 | return $rules; 77 | } 78 | 79 | /** 80 | * @inheritdoc 81 | */ 82 | public function getSettingsHtml(): ?string 83 | { 84 | return Craft::$app->getView()->renderTemplate( 85 | 'webperf/_components/widgets/Metrics_settings', 86 | [ 87 | 'widget' => $this, 88 | ] 89 | ); 90 | } 91 | 92 | /** 93 | * @inheritdoc 94 | */ 95 | public function getBodyHtml(): ?string 96 | { 97 | return Craft::$app->getView()->renderTemplate( 98 | 'webperf/_components/widgets/Metrics_body', 99 | [ 100 | 'message' => $this->message, 101 | ] 102 | ); 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/base/BoomerangDataSampleTrait.php: -------------------------------------------------------------------------------- 1 | 4 | {% namespace "settings" %} 5 | {{ forms.textField({ 6 | label: "Webperf Data Samples to Store"|t("webperf"), 7 | instructions: "How many unique Webperf Data Samples should be stored before they are trimmed."|t("webperf"), 8 | id: "dataSamplesStoredLimit", 9 | name: "dataSamplesStoredLimit", 10 | size: 10, 11 | maxlength: 10, 12 | value: settings.dataSamplesStoredLimit, 13 | warning: configWarning("dataSamplesStoredLimit", "webperf"), 14 | errors: settings.getErrors("dataSamplesStoredLimit"), 15 | }) }} 16 | 17 | {{ forms.lightswitchField({ 18 | label: "Automatically Trim Data Samples"|t("webperf"), 19 | instructions: "Whether the Data Samples should be trimmed after each new Data Sample is added"|t("webperf"), 20 | id: "automaticallyTrimDataSamples", 21 | name: "automaticallyTrimDataSamples", 22 | on: settings.automaticallyTrimDataSamples, 23 | warning: configWarning("automaticallyTrimDataSamples", "webperf"), 24 | errors: settings.getErrors("automaticallyTrimDataSamples"), 25 | }) }} 26 | 27 | {{ forms.lightswitchField({ 28 | label: "Trim Outlier Data Samples"|t("webperf"), 29 | instructions: "Whether outlier data samples that are `10x` the mean should be deleted"|t("webperf"), 30 | id: "trimOutlierDataSamples", 31 | name: "trimOutlierDataSamples", 32 | on: settings.trimOutlierDataSamples, 33 | warning: configWarning("trimOutlierDataSamples", "webperf"), 34 | errors: settings.getErrors("trimOutlierDataSamples"), 35 | }) }} 36 | 37 | {{ forms.selectField({ 38 | label: "Sample Trimming Rate Limit"|t("webperf"), 39 | instructions: "The amount of time required between trimming of data samples."|t("webperf"), 40 | id: "samplesRateLimitMs", 41 | name: "samplesRateLimitMs", 42 | options: { 43 | 3600000: "Once per hour"|t("webperf"), 44 | 86400000: "Once per day"|t("webperf"), 45 | 604800000: "Once per week"|t("webperf"), 46 | }, 47 | value: settings.samplesRateLimitMs, 48 | warning: configWarning("samplesRateLimitMs", "webperf"), 49 | errors: settings.getErrors("samplesRateLimitMs"), 50 | }) }} 51 | 52 | {{ forms.textField({ 53 | label: "Rate Limit in ms"|t("webperf"), 54 | instructions: "The number of milliseconds required between recording of frontend beacon data samples."|t("webperf"), 55 | id: "rateLimitMs", 56 | name: "rateLimitMs", 57 | size: 6, 58 | maxlength: 5, 59 | value: settings.rateLimitMs, 60 | warning: configWarning("rateLimitMs", "webperf"), 61 | errors: settings.getErrors("rateLimitMs"), 62 | }) }} 63 | {% endnamespace %} 64 | 65 | -------------------------------------------------------------------------------- /src/web/assets/dist/assets/RequestBarChart-Ee0Vh_Iz.js: -------------------------------------------------------------------------------- 1 | import{n as r}from"./vue-apexcharts-C2g27_eS.js";const o={name:"RequestBarRecursive",props:{column:{type:String,default:""},color:{type:String,default:""},label:{type:String,default:""},value:{type:Number,default:0},parentValue:{type:Number,default:0},nodes:{type:Array,default:()=>[]}},methods:{statFormatter(a){return Number(a/1e3).toFixed(2)+"s"}}};var n=function(){var e=this,t=e._self._c;return t("div",{staticClass:"h-5",class:e.color,style:{width:e.value/e.parentValue*100+"%"},attrs:{title:e.label+" "+e.statFormatter(e.value)}},e._l(e.nodes,function(l){return t("request-bar-recursive",{key:l.column,attrs:{color:l.color,column:l.column,label:l.label,nodes:l.nodes,"parent-value":l.parentValue,value:l.value}})}),1)},s=[],u=r(o,n,s,!1,null,null,null,null);const c=u.exports,i=[{column:"pageLoad",color:"bg-blue-200",label:"Page Loaded"},{column:"domInteractive",color:"bg-blue-400",label:"DOM Interactive"},{column:"firstContentfulPaint",color:"bg-blue-500",label:"First Contentful Paint"},{column:"firstPaint",color:"bg-blue-700",label:"First Paint"},{column:"firstByte",color:"bg-orange-400",label:"First Byte"},{column:"connect",color:"bg-orange-500",label:"Connect"},{column:"dns",color:"bg-orange-700",label:"DNS Lookup"},{column:"craftTotalMs",color:"bg-red-400",label:"Craft Rendering"},{column:"craftTwigMs",color:"bg-red-500",label:"Twig Rendering"},{column:"craftDbMs",color:"bg-red-700",label:"Database Queries"}],d={name:"RequestBarChart",components:{"request-bar-recursive":c},props:{rowData:{type:Object,default:()=>({})}},data:function(){return{root:void 0}},mounted(){this.$events!==void 0&&this.$events.$on("refresh-table-components",a=>this.onTableRefresh(a))},created(){this.calculateNodes()},methods:{onTableRefresh:function(){this.calculateNodes()},statFormatter(a){return Number(a/1e3).toFixed(2)+"s"},calculateNodes:function(){this.root=void 0,i.forEach(a=>{let e={column:a.column,color:a.color,label:a.label,value:parseFloat(this.rowData[a.column])||null,parentValue:parseFloat(this.rowData.maxTotalPageLoad)||null,nodes:void 0};if(e.value)if(this.root){let t=this.root;for(;t;)!t.nodes||!t.value||e.value>t.value?(e.nodes=t.nodes,e.parentValue=t.parentValue||t.value,t.nodes=[e],t=e.nodes||void 0):t=t.nodes[0]||void 0}else this.root=e})}}};var f=function(){var e=this,t=e._self._c;return t("div",{staticClass:"flex flex-no-wrap"},[e.rowData.type==="both"?t("div",{staticClass:"flex-shrink",attrs:{title:"Combined Frontend & Craft Beacon"}},[t("div",{staticClass:"w-2 h-2 bg-blue-700 rounded-full mb-1"}),t("div",{staticClass:"w-2 h-2 bg-orange-700 rounded-full"})]):e._e(),e.rowData.type==="frontend"?t("div",{staticClass:"flex-shrink",attrs:{title:"Frontend Beacon only"}},[t("div",{staticClass:"w-2 h-2 bg-blue-700 rounded-full mb-1"}),t("div",{staticClass:"w-2 h-2 bg-transparent rounded-full"})]):e._e(),e.rowData.type==="craft"?t("div",{staticClass:"flex-shrink",attrs:{title:"Craft Beacon only"}},[t("div",{staticClass:"w-2 h-2 bg-transparent rounded-full mb-1"}),t("div",{staticClass:"w-2 h-2 bg-orange-700 rounded-full"})]):e._e(),t("div",{staticClass:"flex-grow"},[t("request-bar-recursive",{attrs:{color:e.root.color,column:e.root.column,label:e.root.label,nodes:e.root.nodes,"parent-value":e.root.parentValue,value:e.root.value}})],1),t("div",{staticClass:"flex-shrink"},[e._v(" "+e._s(e.statFormatter(e.root.value))+" ")])])},b=[],v=r(d,f,b,!1,null,null,null,null);const p=v.exports;export{p as R}; 2 | //# sourceMappingURL=RequestBarChart-Ee0Vh_Iz.js.map 3 | -------------------------------------------------------------------------------- /src/helpers/PluginTemplate.php: -------------------------------------------------------------------------------- 1 | getView()->renderString($templateString, $params); 38 | } catch (\Exception $e) { 39 | $html = Craft::t( 40 | 'webperf', 41 | 'Error rendering template string -> {error}', 42 | ['error' => $e->getMessage()] 43 | ); 44 | Craft::error($html, __METHOD__); 45 | } 46 | 47 | return $html; 48 | } 49 | 50 | /** 51 | * Render a plugin template 52 | * 53 | * @param string $templatePath 54 | * @param array $params 55 | * @param string|null $minifier 56 | * 57 | * @return string 58 | */ 59 | public static function renderPluginTemplate( 60 | string $templatePath, 61 | array $params = [], 62 | string $minifier = null, 63 | ): string { 64 | // Stash the old template mode, and set it Control Panel template mode 65 | $oldMode = Craft::$app->view->getTemplateMode(); 66 | try { 67 | Craft::$app->view->setTemplateMode(View::TEMPLATE_MODE_CP); 68 | } catch (Exception $e) { 69 | Craft::error($e->getMessage(), __METHOD__); 70 | } 71 | 72 | // Render the template with our vars passed in 73 | try { 74 | $htmlText = Craft::$app->view->renderTemplate('webperf/' . $templatePath, $params); 75 | if ($minifier) { 76 | // If Minify is installed, minify the html 77 | /** @var Minify|null $minify */ 78 | $minify = Craft::$app->getPlugins()->getPlugin(self::MINIFY_PLUGIN_HANDLE); 79 | if ($minify) { 80 | $htmlText = $minify->minify->$minifier($htmlText); 81 | } 82 | } 83 | } catch (\Exception $e) { 84 | $htmlText = Craft::t( 85 | 'webperf', 86 | 'Error rendering `{template}` -> {error}', 87 | ['template' => $templatePath, 'error' => $e->getMessage()] 88 | ); 89 | Craft::error($htmlText, __METHOD__); 90 | } 91 | 92 | // Restore the old template mode 93 | try { 94 | Craft::$app->view->setTemplateMode($oldMode); 95 | } catch (Exception $e) { 96 | Craft::error($e->getMessage(), __METHOD__); 97 | } 98 | 99 | return Template::raw($htmlText); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/templates/errors/index.twig: -------------------------------------------------------------------------------- 1 | {# @var craft \craft\web\twig\variables\CraftVariable #} 2 | {# 3 | /** 4 | * Webperf plugin for Craft CMS 3.x 5 | * 6 | * Monitor the performance of your webpages through real-world user timing data 7 | * 8 | * @link https://nystudio107.com 9 | * @copyright Copyright (c) 2019 nystudio107 10 | */ 11 | #} 12 | 13 | {% requirePermission "webperf:errors" %} 14 | 15 | {% extends "webperf/_layouts/webperf-cp.twig" %} 16 | 17 | {% import "_includes/forms" as forms %} 18 | 19 | {% block contextMenu %} 20 | {% include "webperf/_includes/sites-menu.twig" with {'params': {}} %} 21 | {% endblock %} 22 | 23 | {% block actionButton %} 24 |
25 |
26 | {{ "Export Error Samples"|t("webperf") }} 27 |
28 | {% if currentUser.can("webperf:delete-error-samples") %} 29 | 34 | {{ "Delete Error Samples"|t("webperf") }} 35 | 36 | {% endif %} 37 |
38 | {% endblock %} 39 | 40 | {% block content %} 41 | 49 |
50 | {# -- Range Picker -- #} 51 | {% include "webperf/_includes/range-picker.twig" %} 52 |
53 |

54 | {{ "Aggregate Page Errors History"|t("webperf") }} 55 |

56 | 64 | 65 |
66 |
67 |

68 | {{ "Aggregate Page Errors"|t("webperf") }} 69 |

70 | 79 | 80 |
81 |
82 | {% endblock %} 83 | 84 | {% block foot %} 85 | {{ parent() }} 86 | {% set tagOptions = { 87 | 'depends': [ 88 | 'nystudio107\\webperf\\assetbundles\\webperf\\WebperfAsset' 89 | ], 90 | } %} 91 | {{ craft.webperf.register('src/js/errors-index.js', false, tagOptions, tagOptions) }} 92 | {% endblock %} 93 | -------------------------------------------------------------------------------- /src/templates/performance/index.twig: -------------------------------------------------------------------------------- 1 | {# @var craft \craft\web\twig\variables\CraftVariable #} 2 | {# 3 | /** 4 | * Webperf plugin for Craft CMS 3.x 5 | * 6 | * Monitor the performance of your webpages through real-world user timing data 7 | * 8 | * @link https://nystudio107.com 9 | * @copyright Copyright (c) 2019 nystudio107 10 | */ 11 | #} 12 | 13 | {% requirePermission "webperf:performance" %} 14 | 15 | {% extends "webperf/_layouts/webperf-cp.twig" %} 16 | 17 | {% import "_includes/forms" as forms %} 18 | 19 | {% block contextMenu %} 20 | {% include "webperf/_includes/sites-menu.twig" with {'params': {}} %} 21 | {% endblock %} 22 | 23 | {% block actionButton %} 24 |
25 |
26 | {{ "Export Data Samples"|t("webperf") }} 27 |
28 | {% if currentUser.can("webperf:delete-data-samples") %} 29 | 34 | {{ "Delete Data Samples"|t("webperf") }} 35 | 36 | {% endif %} 37 |
38 | {% endblock %} 39 | 40 | {% block content %} 41 | 49 |
50 | {# -- Range Picker -- #} 51 | {% include "webperf/_includes/range-picker.twig" %} 52 |
53 |

Aggregate Page Performance History

54 | 62 | 63 |
64 | 65 | {# -- Recommendations -- #} 66 | {% include "webperf/_includes/recommendations.twig" %} 67 | 68 |
69 |

Aggregate Page Performance Data Samples

70 | 80 | 81 |
82 |
83 | {% endblock %} 84 | 85 | {% block foot %} 86 | {{ parent() }} 87 | {% set tagOptions = { 88 | 'depends': [ 89 | 'nystudio107\\webperf\\assetbundles\\webperf\\WebperfAsset' 90 | ], 91 | } %} 92 | {{ craft.webperf.register('src/js/performance-index.js', false, tagOptions, tagOptions) }} 93 | {% endblock %} 94 | -------------------------------------------------------------------------------- /src/templates/settings/_includes/general.twig: -------------------------------------------------------------------------------- 1 | {% import "_includes/forms" as forms %} 2 | {% from "webperf/_includes/macros.twig" import configWarning %} 3 |
4 | {% namespace "settings" %} 5 | {{ forms.textField({ 6 | label: "Plugin name"|t("webperf"), 7 | instructions: "The public-facing name of the plugin"|t("webperf"), 8 | id: "pluginName", 9 | name: "pluginName", 10 | value: settings.pluginName, 11 | warning: configWarning("pluginName", "webperf"), 12 | errors: settings.getErrors("pluginName"), 13 | }) }} 14 | 15 | {{ forms.lightswitchField({ 16 | label: "Include Browser Beacon"|t("webperf"), 17 | instructions: "Whether or not the asynchronous User Timing Beacon should be rendered on frontend pages."|t("webperf"), 18 | id: "includeBeacon", 19 | name: "includeBeacon", 20 | on: settings.includeBeacon, 21 | warning: configWarning("includeBeacon", "webperf"), 22 | errors: settings.getErrors("includeBeacon"), 23 | }) }} 24 | 25 | {{ forms.lightswitchField({ 26 | label: "Include Craft Beacon"|t("webperf"), 27 | instructions: "Whether or not the Craft profiling data should be recorded for site requests."|t("webperf"), 28 | id: "includeCraftProfiling", 29 | name: "includeCraftProfiling", 30 | on: settings.includeCraftProfiling, 31 | warning: configWarning("includeCraftProfiling", "webperf"), 32 | errors: settings.getErrors("includeCraftProfiling"), 33 | }) }} 34 | 35 | {{ forms.lightswitchField({ 36 | label: "Static Cached Site"|t("webperf"), 37 | instructions: "If the site is static cached, turn this option on to prevent Webperf from generating a unique beacon token."|t("webperf"), 38 | id: "staticCachedSite", 39 | name: "staticCachedSite", 40 | on: settings.staticCachedSite, 41 | warning: configWarning("staticCachedSite", "webperf"), 42 | errors: settings.getErrors("staticCachedSite"), 43 | }) }} 44 | 45 | {{ forms.autosuggestField({ 46 | label: "WebPageTest.org API Key"|t("webperf"), 47 | instructions: "To run pages through [WebPageTest.org](https://www.webpagetest.org/) via an API, you need an API key. Enter your WebpageTest.org API key here. If you don't have one, they are free and easy to obtain. [Learn More](https://www.webpagetest.org/getkey.php)"|t("webperf"), 48 | suggestEnvVars: true, 49 | id: "webpageTestApiKey", 50 | name: "webpageTestApiKey", 51 | value: settings.webpageTestApiKey, 52 | warning: configWarning("webpageTestApiKey", "webperf"), 53 | errors: settings.getErrors("webpageTestApiKey"), 54 | }) }} 55 | 56 | {{ forms.editableTableField({ 57 | label: "Exclude Patterns"|t("webperf"), 58 | instructions: "[Regular expressions](https://regexr.com/) to match URIs that should be excluded from tracking."|t("webperf"), 59 | id: 'excludePatterns', 60 | name: 'excludePatterns', 61 | required: false, 62 | defaultValues: { 63 | pattern: "", 64 | }, 65 | cols: { 66 | pattern: { 67 | heading: "RegEx pattern to exclude"|t("webperf"), 68 | type: "singleline", 69 | width: "100%", 70 | code: true, 71 | }, 72 | }, 73 | rows: settings.excludePatterns, 74 | errors: settings.getErrors("excludePatterns"), 75 | }) }} 76 | {% endnamespace %} 77 |
78 | -------------------------------------------------------------------------------- /src/helpers/Text.php: -------------------------------------------------------------------------------- 1 | truncate($length, $substring); 46 | } 47 | 48 | return $result; 49 | } 50 | 51 | /** 52 | * Truncates the string to a given length, while ensuring that it does not 53 | * split words. If $substring is provided, and truncating occurs, the 54 | * string is further truncated so that the substring may be appended without 55 | * exceeding the desired length. 56 | * 57 | * @param string $string The string to truncate 58 | * @param int $length Desired length of the truncated string 59 | * @param string $substring The substring to append if it can fit 60 | * 61 | * @return string with the resulting $str after truncating 62 | */ 63 | public static function truncateOnWord($string, $length, $substring = '…'): string 64 | { 65 | $result = $string; 66 | 67 | if (!empty($string)) { 68 | $string = strip_tags($string); 69 | $result = (string)Stringy::create($string)->safeTruncate($length, $substring); 70 | } 71 | 72 | return $result; 73 | } 74 | 75 | /** 76 | * Clean up the passed in text by converting it to UTF-8, stripping tags, 77 | * removing whitespace, and decoding HTML entities 78 | * 79 | * @param string $text 80 | * 81 | * @return string 82 | */ 83 | public static function cleanupText($text): string 84 | { 85 | if (empty($text)) { 86 | return ''; 87 | } 88 | // Convert to UTF-8 89 | if (\function_exists('iconv')) { 90 | $text = iconv(mb_detect_encoding($text, mb_detect_order(), true), 'UTF-8//IGNORE', $text); 91 | } else { 92 | ini_set('mbstring.substitute_character', 'none'); 93 | $text = mb_convert_encoding($text, 'UTF-8', 'UTF-8'); 94 | } 95 | // Strip HTML tags 96 | $text = strip_tags($text); 97 | // Remove excess whitespace 98 | $text = preg_replace('/\s{2,}/u', ' ', $text); 99 | // Decode any HTML entities 100 | $text = html_entity_decode($text); 101 | 102 | return $text; 103 | } 104 | 105 | // Protected Static Methods 106 | // ========================================================================= 107 | } 108 | -------------------------------------------------------------------------------- /src/templates/dashboard/index.twig: -------------------------------------------------------------------------------- 1 | {# @var craft \craft\web\twig\variables\CraftVariable #} 2 | {# 3 | /** 4 | * Webperf plugin for Craft CMS 3.x 5 | * 6 | * Monitor the performance of your webpages through real-world user timing data 7 | * 8 | * @link https://nystudio107.com 9 | * @copyright Copyright (c) 2019 nystudio107 10 | */ 11 | #} 12 | 13 | {% requirePermission "webperf:dashboard" %} 14 | 15 | {% extends "webperf/_layouts/webperf-cp.twig" %} 16 | 17 | {% import "_includes/forms" as forms %} 18 | 19 | {% block contextMenu %} 20 | {% include "webperf/_includes/sites-menu.twig" with {'params': {}} %} 21 | {% endblock %} 22 | 23 | {% block actionButton %} 24 |
25 |
26 | {{ "Export Data Samples"|t("webperf") }} 27 |
28 | {% if currentUser.can("webperf:delete-data-samples") %} 29 | 34 | {{ "Delete Data Samples"|t("webperf") }} 35 | 36 | {% endif %} 37 |
38 | {% endblock %} 39 | 40 | {% block content %} 41 | 46 |
47 | {% if showWelcome %} 48 |
49 | 50 | 51 |
52 | 54 |

Thanks for using Webperf!

55 |

56 | Monitor the performance of your webpages through Real User Measurement. Pass the RUM! 57 |

58 |

59 | Webperf has been installed, and will collect anonymous timing data as people visit your website. 60 |

61 |

We hope you love it! For more information, please see the documentation.

62 |
63 |
64 | {% endif %} 65 | {# -- Range Picker -- #} 66 | {% include "webperf/_includes/range-picker.twig" %} 67 | {% if settings.includeBeacon %} 68 | {# -- Frontend -- #} 69 | {% include "webperf/dashboard/_includes/frontend.twig" %} 70 | 71 | {# -- Backend -- #} 72 | {% include "webperf/dashboard/_includes/backend.twig" %} 73 | {% endif %} 74 | 75 | {% if settings.includeCraftProfiling %} 76 | {# -- Craft -- #} 77 | {% include "webperf/dashboard/_includes/craft.twig" %} 78 | {% endif %} 79 | 80 | {# -- Chart color key -- #} 81 | {% include "webperf/_includes/speed-color-key.twig" %} 82 | 83 | {# -- Recommendations -- #} 84 | {% include "webperf/_includes/recommendations.twig" %} 85 |
86 | {% endblock %} 87 | 88 | {% block foot %} 89 | {{ parent() }} 90 | {% set tagOptions = { 91 | 'depends': [ 92 | 'nystudio107\\webperf\\assetbundles\\webperf\\WebperfAsset' 93 | ], 94 | } %} 95 | {{ craft.webperf.register('src/js/dashboard.js', false, tagOptions, tagOptions) }} 96 | {% endblock %} 97 | -------------------------------------------------------------------------------- /src/services/ServicesTrait.php: -------------------------------------------------------------------------------- 1 | = 8.2, and config() is called before __construct(), 40 | // so we can't extract it from the passed in $config 41 | $majorVersion = '5'; 42 | // Dev server container name & port are based on the major version of this plugin 43 | $devPort = 3000 + (int)$majorVersion; 44 | $versionName = 'v' . $majorVersion; 45 | return [ 46 | 'components' => [ 47 | 'beacons' => Beacons::class, 48 | 'dataSamples' => DataSamples::class, 49 | 'errorSamples' => ErrorSamples::class, 50 | 'recommendations' => Recommendations::class, 51 | // Register the vite service 52 | 'vite' => [ 53 | 'assetClass' => WebperfAsset::class, 54 | 'checkDevServer' => true, 55 | 'class' => VitePluginService::class, 56 | 'devServerInternal' => 'http://craft-webperf-' . $versionName . '-buildchain-dev:' . $devPort, 57 | 'devServerPublic' => 'http://localhost:' . $devPort, 58 | 'errorEntry' => 'src/js/webperf.js', 59 | 'useDevServer' => true, 60 | ], 61 | ], 62 | ]; 63 | } 64 | 65 | // Public Methods 66 | // ========================================================================= 67 | 68 | /** 69 | * Returns the beacons service 70 | * 71 | * @return Beacons The beacons service 72 | * @throws InvalidConfigException 73 | */ 74 | public function getBeacons(): Beacons 75 | { 76 | return $this->get('beacons'); 77 | } 78 | 79 | /** 80 | * Returns the dataSamples service 81 | * 82 | * @return DataSamples The dataSamples service 83 | * @throws InvalidConfigException 84 | */ 85 | public function getDataSamples(): DataSamples 86 | { 87 | return $this->get('dataSamples'); 88 | } 89 | 90 | /** 91 | * Returns the errorSamples service 92 | * 93 | * @return ErrorSamples The errorSamples service 94 | * @throws InvalidConfigException 95 | */ 96 | public function getErrorSamples(): ErrorSamples 97 | { 98 | return $this->get('errorSamples'); 99 | } 100 | 101 | /** 102 | * Returns the recommendations service 103 | * 104 | * @return Recommendations The recommendations service 105 | * @throws InvalidConfigException 106 | */ 107 | public function getRecommendations(): Recommendations 108 | { 109 | return $this->get('recommendations'); 110 | } 111 | 112 | /** 113 | * Returns the vite service 114 | * 115 | * @return VitePluginService The vite service 116 | * @throws InvalidConfigException 117 | */ 118 | public function getVite(): VitePluginService 119 | { 120 | return $this->get('vite'); 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/controllers/DataSamplesController.php: -------------------------------------------------------------------------------- 1 | dataSamples->deleteSampleById($id)) { 45 | // Clear the caches and continue on 46 | Webperf::$plugin->clearAllCaches(); 47 | Craft::$app->getSession()->setNotice(Craft::t('webperf', 'Data sample deleted.')); 48 | 49 | return $this->redirect(Craft::$app->getRequest()->referrer); 50 | } 51 | Craft::$app->getSession()->setError(Craft::t('webperf', "Couldn't delete data sample.")); 52 | 53 | return $this->redirect(Craft::$app->getRequest()->referrer); 54 | } 55 | 56 | /** 57 | * @param string $pageUrl 58 | * @param int|null $siteId 59 | * 60 | * @return Response 61 | * @throws \craft\errors\MissingComponentException 62 | * @throws \yii\web\ForbiddenHttpException 63 | */ 64 | public function actionDeleteSamplesByUrl(string $pageUrl, int $siteId = null): Response 65 | { 66 | // We may be passed 0 or other "empty" values, so coerce to null 67 | if (empty($siteId)) { 68 | $siteId = null; 69 | } 70 | PermissionHelper::controllerPermissionCheck('webperf:delete-data-samples'); 71 | if (Webperf::$plugin->dataSamples->deleteDataSamplesByUrl($pageUrl, $siteId)) { 72 | // Clear the caches and continue on 73 | Webperf::$plugin->clearAllCaches(); 74 | Craft::$app->getSession()->setNotice(Craft::t('webperf', 'Data samples deleted.')); 75 | 76 | return $this->redirect(Craft::$app->getRequest()->referrer); 77 | } 78 | Craft::$app->getSession()->setError(Craft::t('webperf', "Couldn't delete data samples.")); 79 | 80 | return $this->redirect(Craft::$app->getRequest()->referrer); 81 | } 82 | 83 | /** 84 | * @param int|null $siteId 85 | * 86 | * @return Response 87 | * @throws \craft\errors\MissingComponentException 88 | * @throws \yii\web\ForbiddenHttpException 89 | */ 90 | public function actionDeleteAllSamples(int $siteId = null): Response 91 | { 92 | // We may be passed 0 or other "empty" values, so coerce to null 93 | if (empty($siteId)) { 94 | $siteId = null; 95 | } 96 | PermissionHelper::controllerPermissionCheck('webperf:delete-data-samples'); 97 | if (Webperf::$plugin->dataSamples->deleteAllDataSamples($siteId)) { 98 | // Clear the caches and continue on 99 | Webperf::$plugin->clearAllCaches(); 100 | Craft::$app->getSession()->setNotice(Craft::t('webperf', 'All data samples deleted.')); 101 | 102 | return $this->redirect(Craft::$app->getRequest()->referrer); 103 | } 104 | Craft::$app->getSession()->setError(Craft::t('webperf', "Couldn't delete data samples.")); 105 | 106 | return $this->redirect(Craft::$app->getRequest()->referrer); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/controllers/ErrorSamplesController.php: -------------------------------------------------------------------------------- 1 | errorSamples->deleteErrorSampleById($id)) { 45 | // Clear the caches and continue on 46 | Webperf::$plugin->clearAllCaches(); 47 | Craft::$app->getSession()->setNotice(Craft::t('webperf', 'Error sample deleted.')); 48 | 49 | return $this->redirect(Craft::$app->getRequest()->referrer); 50 | } 51 | Craft::$app->getSession()->setError(Craft::t('webperf', "Couldn't delete error sample.")); 52 | 53 | return $this->redirect(Craft::$app->getRequest()->referrer); 54 | } 55 | 56 | /** 57 | * @param string $pageUrl 58 | * @param int|null $siteId 59 | * 60 | * @return Response 61 | * @throws \craft\errors\MissingComponentException 62 | * @throws \yii\web\ForbiddenHttpException 63 | */ 64 | public function actionDeleteSamplesByUrl(string $pageUrl, int $siteId = null): Response 65 | { 66 | // We may be passed 0 or other "empty" values, so coerce to null 67 | if (empty($siteId)) { 68 | $siteId = null; 69 | } 70 | PermissionHelper::controllerPermissionCheck('webperf:delete-error-samples'); 71 | if (Webperf::$plugin->errorSamples->deleteErrorSamplesByUrl($pageUrl, $siteId)) { 72 | // Clear the caches and continue on 73 | Webperf::$plugin->clearAllCaches(); 74 | Craft::$app->getSession()->setNotice(Craft::t('webperf', 'Error samples deleted.')); 75 | 76 | return $this->redirect(Craft::$app->getRequest()->referrer); 77 | } 78 | Craft::$app->getSession()->setError(Craft::t('webperf', "Couldn't delete error samples.")); 79 | 80 | return $this->redirect(Craft::$app->getRequest()->referrer); 81 | } 82 | 83 | /** 84 | * @param int|null $siteId 85 | * 86 | * @return Response 87 | * @throws \craft\errors\MissingComponentException 88 | * @throws \yii\web\ForbiddenHttpException 89 | */ 90 | public function actionDeleteAllSamples(int $siteId = null): Response 91 | { 92 | // We may be passed 0 or other "empty" values, so coerce to null 93 | if (empty($siteId)) { 94 | $siteId = null; 95 | } 96 | PermissionHelper::controllerPermissionCheck('webperf:delete-error-samples'); 97 | if (Webperf::$plugin->errorSamples->deleteAllErrorSamples($siteId)) { 98 | // Clear the caches and continue on 99 | Webperf::$plugin->clearAllCaches(); 100 | Craft::$app->getSession()->setNotice(Craft::t('webperf', 'All error samples deleted.')); 101 | 102 | return $this->redirect(Craft::$app->getRequest()->referrer); 103 | } 104 | Craft::$app->getSession()->setError(Craft::t('webperf', "Couldn't delete error samples.")); 105 | 106 | return $this->redirect(Craft::$app->getRequest()->referrer); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/web/assets/dist/assets/SimpleBarChart-DDG34REw.js: -------------------------------------------------------------------------------- 1 | import{n as i,a as o,A as l}from"./vue-apexcharts-C2g27_eS.js";import{T as n}from"./tri-color-blend-CUFlaG2k.js";const d=t=>({baseURL:t,headers:{"X-Requested-With":"XMLHttpRequest"}}),p=(t,e,a,s)=>{t.get(e,{params:a}).then(r=>{s&&s(r.data)}).catch(r=>{console.log(r)})},h={components:{apexcharts:o},props:{title:{type:String,default:""},start:{type:String,default:""},end:{type:String,default:""},column:{type:String,default:""},pageUrl:{type:String,default:""},fastColor:{type:String,default:"#00C800"},averageColor:{type:String,default:"#FFFF00"},slowColor:{type:String,default:"#C80000"},maxValue:{type:Number,default:1e4},siteId:{type:Number,default:0},apiUrl:{type:String,default:""}},data:function(){return{chartOptions:{chart:{id:"vuechart-dashboard-radial-bar",fontFamily:"inherit",toolbar:{show:!1}},states:{hover:{filter:{type:"none",value:0}}},colors:["#000000"],plotOptions:{radialBar:{startAngle:-135,endAngle:135,hollow:{size:"65%"},track:{background:"#f1f5f8",strokeWidth:"97%",margin:5,shadow:{enabled:!0,top:2,left:0,color:"#999",opacity:1,blur:2}},dataLabels:{name:{show:!1,fontSize:"16px",color:"#333",offsetY:100},value:{offsetY:10,fontSize:"40px",color:"#333",style:{cssClass:"apexcharts-datalabel-value"},formatter:t=>(t=t*this.displayMaxValue/100,Number(t).toFixed(2)+"s")}}}},labels:[this.title],title:{text:this.title,offsetY:18,align:"center",style:{fontSize:"16px",cssClass:"apexcharts-title-text"}},stroke:{width:1,lineCap:"round"}},series:[0],displayStart:this.start,displayEnd:this.end,displayMaxValue:this.maxValue,triBlend:new n(this.fastColor,this.averageColor,this.slowColor)}},created(){this.getSeriesData()},mounted(){this.$events!==void 0&&this.$events.$on("change-range",t=>this.onChangeRange(t))},methods:{getSeriesData:async function(){const t=l.create(d(this.apiUrl));let e={column:this.column,start:this.displayStart,end:this.displayEnd,pageUrl:this.pageUrl,siteId:this.siteId};await p(t,"",e,a=>{if(a.avg!==void 0){let s=a.avg/1e3;s>this.displayMaxValue&&(this.displayMaxValue=s),s=s*100/this.displayMaxValue;let r=this.triBlend.colorFromPercentage(s);this.chartOptions={...this.chartOptions,colors:[r],plotOptions:{radialBar:{dataLabels:{value:{color:r}}}}},this.series=[s]}})},onChangeRange(t){this.displayStart=t.start,this.displayEnd=t.end,this.getSeriesData()}}};var u=function(){var e=this,a=e._self._c;return a("apexcharts",{attrs:{options:e.chartOptions,series:e.series,height:"300px",type:"radialBar",width:"100%"}})},c=[],f=i(h,u,c,!1,null,null,null,null);const b=f.exports,g=t=>({baseURL:t,headers:{"X-Requested-With":"XMLHttpRequest"}}),y=(t,e,a,s)=>{t.get(e,{params:a}).then(r=>{s&&s(r.data)}).catch(r=>{console.log(r)})},m={components:{},props:{title:{type:String,default:""},start:{type:String,default:""},end:{type:String,default:""},column:{type:String,default:""},pageUrl:{type:String,default:""},fastColor:{type:String,default:"#00C800"},averageColor:{type:String,default:"#FFFF00"},slowColor:{type:String,default:"#C80000"},maxValue:{type:Number,default:1e4},siteId:{type:Number,default:0},apiUrl:{type:String,default:""}},data:function(){return{barColor:"#000",series:[0],displayStart:this.start,displayEnd:this.end,displayMaxValue:this.maxValue,triBlend:new n(this.fastColor,this.averageColor,this.slowColor)}},created(){this.getSeriesData()},mounted(){this.$events!==void 0&&this.$events.$on("change-range",t=>this.onChangeRange(t))},methods:{getSeriesData:async function(){const t=l.create(g(this.apiUrl));let e={column:this.column,start:this.displayStart,end:this.displayEnd,pageUrl:this.pageUrl,siteId:this.siteId};await y(t,"",e,a=>{if(a.avg!==void 0){let s=a.avg/1e3;s>this.displayMaxValue&&(this.displayMaxValue=s),s=s*100/this.displayMaxValue,this.barColor=this.triBlend.colorFromPercentage(s),this.series=[s]}})},onChangeRange(t){this.displayStart=t.start,this.displayEnd=t.end,this.getSeriesData()},statFormatter(t){return t=t*this.displayMaxValue/100,Number(t).toFixed(2)+"s"}}};var C=function(){var e=this,a=e._self._c;return a("div",{staticClass:"simple-bar-chart-wrapper px-5 py-3"},[a("div",{staticClass:"clearafter py-2"},[a("div",{staticClass:"simple-bar-chart-label text-base font-bold"},[e._v(" "+e._s(e.title)+" ")]),a("div",{staticClass:"simple-bar-chart-value text-base font-bold"},[e._v(" "+e._s(e.statFormatter(e.series[0]))+" ")])]),a("div",{staticClass:"py-2"},[a("div",{staticClass:"simple-bar-chart-track rounded-full bg-gray-200"},[a("div",{staticClass:"simple-bar-line h-3 rounded-full",style:{width:e.series[0]+"%",backgroundColor:e.barColor}})])])])},v=[],S=i(m,C,v,!1,null,null,null,null);const F=S.exports;export{b as R,F as S}; 2 | //# sourceMappingURL=SimpleBarChart-DDG34REw.js.map 3 | -------------------------------------------------------------------------------- /src/web/assets/dist/assets/SamplePaneFooter-DUo1brgB.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"SamplePaneFooter-DUo1brgB.js","sources":["../../../../../buildchain/src/vue/common/SamplePaneFooter.vue"],"sourcesContent":["\n\n\n"],"names":["configureApi","url","queryApi","api","uri","params","callback","result","error","_sfc_main","eventData","chartsAPI","Axios","data","range","number"],"mappings":"wDAoCA,MAAAA,EAAAC,IACA,CACA,QAAAA,EACA,QAAA,CACA,mBAAA,gBACA,CACA,GAGAC,EAAA,CAAAC,EAAAC,EAAAC,EAAAC,IAAA,CACAH,EAAA,IAAAC,EAAA,CAAA,OAAAC,CAAA,CAAA,EACA,KAAAE,GAAA,CACAD,GACAA,EAAAC,EAAA,IAAA,CAEA,CAAA,EACA,MAAAC,GAAA,CACA,QAAA,IAAAA,CAAA,CACA,CAAA,CACA,EAGAC,EAAA,CACA,WAAA,CAAA,EACA,MAAA,CACA,MAAA,CACA,KAAA,OACA,QAAA,EACA,EACA,IAAA,CACA,KAAA,OACA,QAAA,EACA,EACA,OAAA,CACA,KAAA,OACA,QAAA,EACA,EACA,sBAAA,CACA,KAAA,QACA,QAAA,EACA,EACA,QAAA,CACA,KAAA,OACA,QAAA,EACA,EACA,QAAA,CACA,KAAA,OACA,QAAA,EACA,EACA,OAAA,CACA,KAAA,OACA,QAAA,CACA,EACA,OAAA,CACA,KAAA,OACA,QAAA,EACA,CACA,EACA,KAAA,UAAA,CACA,MAAA,CACA,QAAA,EACA,aAAA,KAAA,MACA,WAAA,KAAA,IACA,gBAAA,KAAA,QACA,CACA,EACA,SAAA,CACA,KAAA,cAAA,CACA,EACA,SAAA,CACA,KAAA,QAAA,IAAA,eAAAC,GAAA,KAAA,cAAAA,CAAA,CAAA,CACA,EACA,QAAA,CAEA,cAAA,gBAAA,CACA,MAAAC,EAAAC,EAAA,OAAAZ,EAAA,KAAA,MAAA,CAAA,EACA,IAAAK,EAAA,CACA,OAAA,KAAA,OACA,MAAA,KAAA,aACA,IAAA,KAAA,WACA,QAAA,KAAA,QACA,OAAA,KAAA,MACA,EACA,MAAAH,EAAAS,EAAA,GAAAN,EAAAQ,GAAA,CACAA,EAAA,MAAA,SACA,KAAA,QAAAA,EAAA,IAEA,CAAA,CACA,EACA,cAAAC,EAAA,CACA,KAAA,aAAAA,EAAA,MACA,KAAA,WAAAA,EAAA,IACA,KAAA,cAAA,CACA,EACA,aAAAC,EAAA,CACA,OAAAA,EAAA,SAAA,EAAA,QAAA,wBAAA,GAAA,CACA,CACA,CACA"} -------------------------------------------------------------------------------- /src/web/assets/dist/assets/RecommendationsList-T8Et27n2.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"RecommendationsList-T8Et27n2.js","sources":["../../../../../buildchain/src/vue/common/RecommendationsList.vue"],"sourcesContent":["\n\n\n"],"names":["configureApi","url","queryApi","api","uri","params","callback","result","error","_sfc_main","SamplePaneFooter","eventData","chartsAPI","Axios","data","range"],"mappings":"2GAwDA,MAAAA,EAAAC,IACA,CACA,QAAAA,EACA,QAAA,CACA,mBAAA,gBACA,CACA,GAGAC,EAAA,CAAAC,EAAAC,EAAAC,EAAAC,IAAA,CACAH,EAAA,IAAAC,EAAA,CAAA,OAAAC,CAAA,CAAA,EACA,KAAAE,GAAA,CACAD,GACAA,EAAAC,EAAA,IAAA,CAEA,CAAA,EACA,MAAAC,GAAA,CACA,QAAA,IAAAA,CAAA,CACA,CAAA,CACA,EAGAC,EAAA,CACA,WAAA,CACA,qBAAAC,CACA,EACA,MAAA,CACA,MAAA,CACA,KAAA,OACA,QAAA,EACA,EACA,IAAA,CACA,KAAA,OACA,QAAA,EACA,EACA,eAAA,CACA,KAAA,QACA,QAAA,EACA,EACA,QAAA,CACA,KAAA,OACA,QAAA,EACA,EACA,OAAA,CACA,KAAA,OACA,QAAA,CACA,EACA,OAAA,CACA,KAAA,OACA,QAAA,EACA,CACA,EACA,KAAA,UAAA,CACA,MAAA,CACA,OAAA,CAAA,EACA,aAAA,KAAA,MACA,WAAA,KAAA,GACA,CACA,EACA,SAAA,CACA,KAAA,cAAA,CACA,EACA,SAAA,CACA,KAAA,QAAA,IAAA,eAAAC,GAAA,KAAA,cAAAA,CAAA,CAAA,CACA,EACA,QAAA,CAEA,cAAA,gBAAA,CACA,MAAAC,EAAAC,EAAA,OAAAb,EAAA,KAAA,MAAA,CAAA,EACA,IAAAK,EAAA,CACA,MAAA,KAAA,aACA,IAAA,KAAA,WACA,QAAA,KAAA,QACA,OAAA,KAAA,MACA,EACA,MAAAH,EAAAU,EAAA,GAAAP,EAAAS,GAAA,CACAA,EAAA,CAAA,IAAA,SACA,KAAA,OAAAA,EAEA,CAAA,CACA,EACA,cAAAC,EAAA,CACA,KAAA,aAAAA,EAAA,MACA,KAAA,WAAAA,EAAA,IACA,KAAA,cAAA,CACA,CACA,CACA"} -------------------------------------------------------------------------------- /src/services/Recommendations.php: -------------------------------------------------------------------------------- 1 | $sample]); 65 | if ($rec->hasRecommendation) { 66 | $data[] = [ 67 | 'summary' => Markdown::processParagraph($rec->summary), 68 | 'detail' => Markdown::processParagraph($rec->detail), 69 | 'learnMoreUrl' => $rec->learnMoreUrl, 70 | ]; 71 | } 72 | } 73 | return $data; 74 | } 75 | 76 | /** 77 | * Return a list of recommendations 78 | * 79 | * @param string $pageUrl 80 | * @param string $start 81 | * @param string $end 82 | * @param int $siteId 83 | * 84 | * @return array 85 | */ 86 | public function data( 87 | $pageUrl = '', 88 | string $start = '', 89 | string $end = '', 90 | $siteId = 0, 91 | ): array { 92 | $data = []; 93 | $db = Craft::$app->getDb(); 94 | // Add a day since YYYY-MM-DD is really YYYY-MM-DD 00:00:00 95 | $end = date('Y-m-d', strtotime($end . '+1 day')); 96 | $pageUrl = urldecode($pageUrl); 97 | // Query the db table 98 | $query = (new Query()) 99 | ->select([ 100 | 'COUNT([[url]]) AS [[cnt]]', 101 | 102 | 'AVG([[pageLoad]]) AS [[pageLoad]]', 103 | 'AVG([[domInteractive]]) AS [[domInteractive]]', 104 | 'AVG([[firstContentfulPaint]]) AS [[firstContentfulPaint]]', 105 | 'AVG([[firstPaint]]) AS [[firstPaint]]', 106 | 'AVG([[firstByte]]) AS [[firstByte]]', 107 | 'AVG([[connect]]) AS [[connect]]', 108 | 'AVG([[dns]]) AS [[dns]]', 109 | 110 | 'AVG([[craftTotalMs]]) AS [[craftTotalMs]]', 111 | 'AVG([[craftDbMs]]) AS [[craftDbMs]]', 112 | 'AVG([[craftDbCnt]]) AS [[craftDbCnt]]', 113 | 'AVG([[craftTwigMs]]) AS [[craftTwigMs]]', 114 | 'AVG([[craftTwigCnt]]) AS [[craftTwigCnt]]', 115 | 'AVG([[craftOtherMs]]) AS [[craftOtherMs]]', 116 | 'AVG([[craftOtherCnt]]) AS [[craftOtherCnt]]', 117 | 'AVG([[craftTotalMemory]]) AS [[craftTotalMemory]]', 118 | ]) 119 | ->from(['{{%webperf_data_samples}}']) 120 | ->where(['between', 'dateCreated', $start, $end]); 121 | if (!empty($pageUrl)) { 122 | $query->andWhere(['url' => $pageUrl]); 123 | } 124 | if ((int)$siteId !== 0) { 125 | $query->andWhere(['siteId' => $siteId]); 126 | } 127 | $stats = $query->all(); 128 | if ($stats) { 129 | $data = $stats[0]; 130 | } 131 | 132 | return $data; 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/templates/errors/page-detail.twig: -------------------------------------------------------------------------------- 1 | {# @var craft \craft\web\twig\variables\CraftVariable #} 2 | {# 3 | /** 4 | * Webperf plugin for Craft CMS 3.x 5 | * 6 | * Monitor the performance of your webpages through real-world user timing data 7 | * 8 | * @link https://nystudio107.com 9 | * @copyright Copyright (c) 2019 nystudio107 10 | */ 11 | #} 12 | 13 | {% requirePermission "webperf:errors-detail" %} 14 | 15 | {% extends "webperf/_layouts/webperf-cp.twig" %} 16 | 17 | {% import "_includes/forms" as forms %} 18 | 19 | {% block contextMenu %} 20 | {% include "webperf/_includes/sites-menu.twig" with {'params': {'pageUrl': pageUrl}} %} 21 | {% endblock %} 22 | 23 | {% block actionButton %} 24 |
25 |
26 | {{ "Export Error Samples"|t("webperf") }} 27 |
28 | {% if currentUser.can("webperf:delete-error-samples") %} 29 | 34 | {{ "Delete Error Samples"|t("webperf") }} 35 | 36 | {% endif %} 37 |
38 | {% endblock %} 39 | 40 | {% block content %} 41 | 49 |
50 | {# -- Range Picker -- #} 51 | {% include "webperf/_includes/range-picker.twig" %} 52 |
53 |
54 | {% if pageTitle | length %} 55 |

57 | 61 | {{ pageTitle }} 62 | 63 |

64 | {% else %} 65 |

67 | {{ "Craft backend route"|t("webperf") }} 68 |

69 | {% endif %} 70 |

72 | 77 | {{ pageUrl }} 78 | 79 |

80 | {% set devModeWarning = 'false' %} 81 |
82 |
83 |
84 |

{{ "Page Error History"|t("webperf") }}

85 | 93 | 94 |
95 |
96 |

{{ "Page Errors Detail"|t("webperf") }}

97 | 104 | 105 |
106 |
107 | {% endblock %} 108 | 109 | {% block foot %} 110 | {{ parent() }} 111 | {% set tagOptions = { 112 | 'depends': [ 113 | 'nystudio107\\webperf\\assetbundles\\webperf\\WebperfAsset' 114 | ], 115 | } %} 116 | {{ craft.webperf.register('src/js/errors-detail.js', false, tagOptions, tagOptions) }} 117 | {% endblock %} 118 | -------------------------------------------------------------------------------- /src/controllers/SettingsController.php: -------------------------------------------------------------------------------- 1 | pluginName; 65 | $templateTitle = Craft::t('webperf', 'Settings'); 66 | $view = Craft::$app->getView(); 67 | // Asset bundle 68 | try { 69 | $view->registerAssetBundle(WebperfAsset::class); 70 | } catch (InvalidConfigException $e) { 71 | Craft::error($e->getMessage(), __METHOD__); 72 | } 73 | $variables['baseAssetsUrl'] = Craft::$app->assetManager->getPublishedUrl( 74 | '@nystudio107/webperf/web/assets/dist', 75 | true 76 | ); 77 | // Basic variables 78 | $variables['fullPageForm'] = true; 79 | $variables['docsUrl'] = self::DOCUMENTATION_URL; 80 | $variables['pluginName'] = $pluginName; 81 | $variables['title'] = $templateTitle; 82 | $variables['crumbs'] = [ 83 | [ 84 | 'label' => $pluginName, 85 | 'url' => UrlHelper::cpUrl('webperf'), 86 | ], 87 | [ 88 | 'label' => $templateTitle, 89 | 'url' => UrlHelper::cpUrl('webperf/settings'), 90 | ], 91 | ]; 92 | $variables['docTitle'] = "{$pluginName} - {$templateTitle}"; 93 | $variables['selectedSubnavItem'] = 'settings'; 94 | $variables['settings'] = $settings; 95 | 96 | // Render the template 97 | return $this->renderTemplate('webperf/settings', $variables); 98 | } 99 | 100 | /** 101 | * Saves a plugin’s settings. 102 | * 103 | * @return Response|null 104 | * @throws NotFoundHttpException if the requested plugin cannot be found 105 | * @throws BadRequestHttpException 106 | * @throws MissingComponentException 107 | * @throws ForbiddenHttpException 108 | */ 109 | public function actionSavePluginSettings() 110 | { 111 | PermissionHelper::controllerPermissionCheck('webperf:settings'); 112 | $this->requirePostRequest(); 113 | $pluginHandle = Craft::$app->getRequest()->getRequiredBodyParam('pluginHandle'); 114 | $settings = Craft::$app->getRequest()->getBodyParam('settings', []); 115 | $plugin = Craft::$app->getPlugins()->getPlugin($pluginHandle); 116 | 117 | if ($plugin === null) { 118 | throw new NotFoundHttpException('Plugin not found'); 119 | } 120 | 121 | if (!Craft::$app->getPlugins()->savePluginSettings($plugin, $settings)) { 122 | Craft::$app->getSession()->setError(Craft::t('app', "Couldn't save plugin settings.")); 123 | 124 | // Send the plugin back to the template 125 | /** @var UrlManager $urlManager */ 126 | $urlManager = Craft::$app->getUrlManager(); 127 | $urlManager->setRouteParams([ 128 | 'plugin' => $plugin, 129 | ]); 130 | 131 | return null; 132 | } 133 | 134 | Webperf::$plugin->clearAllCaches(); 135 | Craft::$app->getSession()->setNotice(Craft::t('app', 'Plugin settings saved.')); 136 | 137 | return $this->redirectToPostedUrl(); 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/recommendations/MemoryLimit.php: -------------------------------------------------------------------------------- 1 | memoryLimit(); 38 | $this->sample->craftTotalMemory = (int)$this->sample->craftTotalMemory; 39 | if ($phpMemoryLimit && $this->sample->craftTotalMemory) { 40 | $ratio = $phpMemoryLimit / $this->sample->craftTotalMemory; 41 | $displayCraftTotalMemory = (($this->sample->craftTotalMemory / 1024) / 1024) . 'M'; 42 | $displayPhpMemoryLimit = (($phpMemoryLimit / 1024) / 1024) . 'M'; 43 | $displayCraftMinMemory = ((self::MIN_CRAFT_MEMORY / 1024) / 1024) . 'M'; 44 | $displayCraftMaxMemory = ((self::MAX_CRAFT_MEMORY / 1024) / 1024) . 'M'; 45 | $this->summary = Craft::t( 46 | 'webperf', 47 | 'Check the `memory_limit` setting in your `php.ini` file', 48 | [] 49 | ); 50 | // See if they have enough memory allocated 51 | if ($phpMemoryLimit < self::MIN_CRAFT_MEMORY) { 52 | $this->hasRecommendation = true; 53 | $this->detail = Craft::t( 54 | 'webperf', 55 | 'Pixel & Tonic recommends at least {displayCraftMinMemory} allocated to PHP for Craft CMS 3. You have only {displayPhpMemoryLimit} allocated in your `php.ini` file.', 56 | [ 57 | 'displayPhpMemoryLimit' => $displayPhpMemoryLimit, 58 | 'displayCraftMinMemory' => $displayCraftMinMemory, 59 | ] 60 | ); 61 | $this->learnMoreUrl = 'https://docs.craftcms.com/v3/requirements.html'; 62 | 63 | return; 64 | } 65 | // See if they have too much memory allocated 66 | if ($phpMemoryLimit >= self::MAX_CRAFT_MEMORY) { 67 | $this->hasRecommendation = true; 68 | $this->detail = Craft::t( 69 | 'webperf', 70 | 'Your `php.ini` file has `memory_limit` set to {displayPhpMemoryLimit}. This may be set too high, since it is a per-process memory limit, and memory-intensive image transforms are done in a process separate from PHP.', 71 | [ 72 | 'displayPhpMemoryLimit' => $displayPhpMemoryLimit, 73 | ] 74 | ); 75 | $this->learnMoreUrl = 'https://docs.craftcms.com/v3/requirements.html'; 76 | 77 | return; 78 | } 79 | // See if they have too much memory allocated 80 | if ($ratio < 1.5) { 81 | $this->hasRecommendation = true; 82 | $this->detail = Craft::t( 83 | 'webperf', 84 | 'Your `php.ini` file has `memory_limit` set to {displayPhpMemoryLimit}, but Craft is using {displayCraftTotalMemory}. Consider raising your `memory_limit` to maintain a `1.5x` buffer of available memory.', 85 | [ 86 | 'displayPhpMemoryLimit' => $displayPhpMemoryLimit, 87 | 'displayCraftTotalMemory' => $displayCraftTotalMemory, 88 | ] 89 | ); 90 | $this->learnMoreUrl = 'https://docs.craftcms.com/v3/requirements.html'; 91 | 92 | return; 93 | } 94 | } 95 | } 96 | 97 | // Protected Methods 98 | // ========================================================================= 99 | 100 | /** 101 | * @return int 102 | */ 103 | protected function memoryLimit(): int 104 | { 105 | $memoryLimit = ini_get('memory_limit'); 106 | if (preg_match('/^(\d+)(.)$/', $memoryLimit, $matches)) { 107 | if (strtoupper($matches[2]) === 'G') { 108 | $memoryLimit = (int)$matches[1] * 1024 * 1024 * 1024; // nnnG -> nnn GB 109 | } elseif (strtoupper($matches[2]) === 'M') { 110 | $memoryLimit = (int)$matches[1] * 1024 * 1024; // nnnM -> nnn MB 111 | } elseif (strtoupper($matches[2]) === 'K') { 112 | $memoryLimit = (int)$matches[1] * 1024; // nnnK -> nnn KB 113 | } 114 | } 115 | 116 | return (int)$memoryLimit; 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/controllers/FileController.php: -------------------------------------------------------------------------------- 1 | exportCsvFile( 89 | 'webperf-data-samples', 90 | '{{%webperf_data_samples}}', 91 | self::EXPORT_DATA_SAMPLES_COLUMNS, 92 | $pageUrl, 93 | $siteId 94 | ); 95 | } catch (CannotInsertRecord $e) { 96 | Craft::error($e->getMessage(), __METHOD__); 97 | } 98 | } 99 | 100 | /** 101 | * Export the error samples table as a CSV file 102 | * 103 | * @param string $pageUrl 104 | * @param int|null $siteId 105 | * 106 | * @throws \yii\web\ForbiddenHttpException 107 | */ 108 | public function actionExportErrorSamples(string $pageUrl = '', int $siteId = null) 109 | { 110 | PermissionHelper::controllerPermissionCheck('webperf:errors'); 111 | try { 112 | $this->exportCsvFile( 113 | 'webperf-error-samples', 114 | '{{%webperf_error_samples}}', 115 | self::EXPORT_ERROR_SAMPLES_COLUMNS, 116 | $pageUrl, 117 | $siteId 118 | ); 119 | } catch (CannotInsertRecord $e) { 120 | Craft::error($e->getMessage(), __METHOD__); 121 | } 122 | } 123 | 124 | // Public Methods 125 | // ========================================================================= 126 | 127 | /** 128 | * @param string $filename 129 | * @param string $table 130 | * @param array $columns 131 | * @param string $pageUrl 132 | * @param int|null $siteId 133 | * 134 | * @throws \League\Csv\CannotInsertRecord 135 | */ 136 | protected function exportCsvFile( 137 | string $filename, 138 | string $table, 139 | array $columns, 140 | string $pageUrl = '', 141 | int $siteId = null, 142 | ) { 143 | // If your CSV document was created or is read on a Macintosh computer, 144 | // add the following lines before using the library to help PHP detect line ending in Mac OS X 145 | if (!ini_get('auto_detect_line_endings')) { 146 | ini_set('auto_detect_line_endings', '1'); 147 | } 148 | // Query the db table 149 | $query = (new Query()) 150 | ->from([$table]) 151 | ->select($columns) 152 | ; 153 | if (!empty($siteId)) { 154 | $query 155 | ->where(['siteId' => $siteId]) 156 | ; 157 | } 158 | if (!empty($pageUrl)) { 159 | $query 160 | ->where(['pageUrl' => $pageUrl]) 161 | ; 162 | } 163 | $data = $query 164 | ->all(); 165 | // Create our CSV file writer 166 | $csv = Writer::createFromFileObject(new \SplTempFileObject()); 167 | $csv->insertOne($columns); 168 | $csv->insertAll($data); 169 | $csv->output($filename . '.csv'); 170 | exit(0); 171 | } 172 | } 173 | --------------------------------------------------------------------------------