PluginProbe
404 Solution / trunk
404 Solution vtrunk
4.3.5 4.3.4 4.3.3 4.3.2 4.3.1 4.3.0 4.2.0 4.1.19 4.1.18 4.1.17 4.1.16 4.1.15 4.1.13 4.1.12 4.1.11 4.1.10 4.1.9 4.1.8 4.1.7 4.1.6 4.1.5 4.1.4 4.1.3 trunk 2.30.0 All 109 releases
404-solution / includes / js / statsTrends.js

statsTrends.js in 404 Solution trunk, at includes/js/statsTrends.js

197 lines 8.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Stats page: Trend Analytics line charts.
3 *
4 * Uses Chart.js (bundled with the plugin at includes/js/lib/ and enqueued
5 * as a hard dependency by AdminAssetEnqueuer), then fetches abj404getTrendData
6 * for the selected period (7 / 30 / 90 days) and renders three line
7 * charts: 404 hits, redirects, new captures.
8 *
9 * Reads ajaxUrl, nonce, and translatable labels from a JSON blob on
10 * the configuration carrier element's `data-abj404-trends` attribute
11 * (#abj404-trends-config). On 403 (expired nonce), refreshes via
12 * window.abj404NonceRefresh and retries once.
13 */
14 (function () {
15 'use strict';
16
17 function readConfig() {
18 var el = document.getElementById('abj404-trends-config');
19 if (!el) {
20 return null;
21 }
22 var raw = el.getAttribute('data-abj404-trends');
23 if (!raw) {
24 return null;
25 }
26 try {
27 return JSON.parse(raw);
28 } catch (e) {
29 // Malformed config JSON (encoding issue, WAF/proxy mangling the
30 // attribute) previously left the panel stuck on "Loading chart
31 // data..." forever with nothing in the console to diagnose. Log
32 // for diagnosis and surface the same recoverable error panel
33 // fetchAndRender()/loadChartJs() use for other failure modes.
34 if (window.console && window.console.error) {
35 window.console.error('404 Solution: abj404-trends-config data-abj404-trends attribute is not valid JSON', raw, e);
36 }
37 var loadEl = document.querySelector('.abj404-trends-loading');
38 if (loadEl) { loadEl.style.display = 'none'; }
39 var errEl = document.getElementById('abj404-trends-error');
40 if (errEl) { errEl.style.display = ''; }
41 return null;
42 }
43 }
44
45 var cfg = null;
46 var nonce = '';
47 var chartInstances = {};
48
49 // Trend data is a lighter read (three small aggregate series) than the
50 // one-shot admin actions elsewhere in this bundle, so a shorter bound is
51 // reasonable while still tolerating a slow-but-working shared host. A
52 // stalled response must not hold the loading state / browser resources
53 // open forever (M501).
54 var TREND_DATA_TIMEOUT_MS = 20000;
55
56 function loadChartJs(cb) {
57 // Chart.js is bundled with the plugin and enqueued as a hard dependency
58 // (see AdminAssetEnqueuer::addScripts), so window.Chart is already
59 // defined by the time this runs. We dispatch abj404ChartJsLoaded so
60 // statsConfidenceChart.js renders too, regardless of script order.
61 if (window.Chart) {
62 document.dispatchEvent(new Event('abj404ChartJsLoaded'));
63 cb();
64 return;
65 }
66 // Defensive: if the bundled library somehow failed to load, surface the
67 // trends error panel instead of silently rendering nothing.
68 var loadEl = document.querySelector('.abj404-trends-loading');
69 if (loadEl) { loadEl.style.display = 'none'; }
70 var errEl = document.getElementById('abj404-trends-error');
71 if (errEl) { errEl.style.display = ''; }
72 }
73
74 function buildChart(canvasId, label, color, labels, values) {
75 var ctx = document.getElementById(canvasId);
76 if (!ctx) { return null; }
77 return new window.Chart(ctx, {
78 type: 'line',
79 data: {
80 labels: labels,
81 datasets: [{
82 label: label,
83 data: values,
84 borderColor: color,
85 backgroundColor: color.replace('rgb(', 'rgba(').replace(')', ', 0.15)'),
86 tension: 0.3,
87 fill: true,
88 pointRadius: 3
89 }]
90 },
91 options: {
92 responsive: true,
93 plugins: { legend: { display: true } },
94 scales: { y: { beginAtZero: true, ticks: { precision: 0 } } }
95 }
96 });
97 }
98
99 function getSelectedDays() {
100 var radios = document.querySelectorAll('input[name=abj404_trend_period]');
101 for (var i = 0; i < radios.length; i++) {
102 if (radios[i].checked) {
103 return parseInt(radios[i].value, 10);
104 }
105 }
106 return 30;
107 }
108
109 function destroyCharts() {
110 ['abj404-chart-404s', 'abj404-chart-redirects', 'abj404-chart-captures'].forEach(function (id) {
111 if (chartInstances[id]) {
112 chartInstances[id].destroy();
113 delete chartInstances[id];
114 }
115 });
116 }
117
118 function fetchTrendData(days, allowRetry) {
119 // Each call (including the nonce-refresh retry below) gets its own
120 // bounded controller so a stalled admin-ajax response cannot hold
121 // the loading state open forever (M501). Abort surfaces as a
122 // rejection that flows through the same generic .catch() in
123 // fetchAndRender() already used for every other fetch failure.
124 var controller = new AbortController();
125 var timeoutId = setTimeout(function () { controller.abort(); }, TREND_DATA_TIMEOUT_MS);
126 // ajax-direct-approved: trend chart endpoint streams a GET response and owns nonce-refresh retry handling locally.
127 return fetch(cfg.ajaxUrl + '?action=abj404getTrendData&nonce=' + encodeURIComponent(nonce) + '&days=' + days, { signal: controller.signal }) // allow-direct-network: trend chart endpoint; no project-wide adapter exists for this AJAX surface
128 .then(function (r) {
129 clearTimeout(timeoutId);
130 // B20: a 12-24h-idle nonce expires; admin-ajax replies 403.
131 // Mint a fresh nonce via the shared refresh helper (if loaded)
132 // and retry once. allowRetry guards against an infinite loop.
133 if (r.status === 403 && allowRetry !== false && window.abj404NonceRefresh) {
134 return window.abj404NonceRefresh.fetchFresh().then(function (freshNonces) {
135 if (freshNonces && freshNonces['abj404_trendData']) {
136 nonce = freshNonces['abj404_trendData'];
137 }
138 return fetchTrendData(days, false);
139 });
140 }
141 return r.json();
142 })
143 .catch(function (e) {
144 clearTimeout(timeoutId);
145 throw e;
146 });
147 }
148
149 function fetchAndRender() {
150 var days = getSelectedDays();
151 var loadEl = document.querySelector('.abj404-trends-loading');
152 var errEl = document.getElementById('abj404-trends-error');
153 var chartsEl = document.getElementById('abj404-trends-charts');
154 if (loadEl) { loadEl.style.display = ''; }
155 if (errEl) { errEl.style.display = 'none'; }
156 if (chartsEl) { chartsEl.style.display = 'none'; }
157 destroyCharts();
158 fetchTrendData(days, true)
159 .then(function (resp) {
160 if (loadEl) { loadEl.style.display = 'none'; }
161 if (!resp || !resp.success || !Array.isArray(resp.data)) {
162 if (errEl) { errEl.style.display = ''; }
163 return;
164 }
165 var rows = resp.data;
166 var labels = rows.map(function (r) { return r.date; });
167 var vals404 = rows.map(function (r) { return r.hits_404; });
168 var valsRedir = rows.map(function (r) { return r.hits_redirect; });
169 var valsCapt = rows.map(function (r) { return r.new_captures; });
170 if (chartsEl) { chartsEl.style.display = ''; }
171 // allow-hardcoded-color: Chart.js dataset border colors must be JS string literals;
172 // Chart.js cannot read CSS custom properties (--abj404-*) from a canvas context.
173 chartInstances['abj404-chart-404s'] = buildChart('abj404-chart-404s', cfg.label404, 'rgb(0,115,170)', labels, vals404);
174 chartInstances['abj404-chart-redirects'] = buildChart('abj404-chart-redirects', cfg.labelRedirect, 'rgb(70,170,100)', labels, valsRedir);
175 chartInstances['abj404-chart-captures'] = buildChart('abj404-chart-captures', cfg.labelCapture, 'rgb(220,100,50)', labels, valsCapt);
176 })
177 .catch(function () {
178 if (loadEl) { loadEl.style.display = 'none'; }
179 if (errEl) { errEl.style.display = ''; }
180 });
181 }
182
183 function onPeriodChange() { fetchAndRender(); }
184
185 document.addEventListener('DOMContentLoaded', function () {
186 cfg = readConfig();
187 if (!cfg) {
188 return;
189 }
190 nonce = cfg.nonce || '';
191 loadChartJs(fetchAndRender);
192 document.querySelectorAll('input[name=abj404_trend_period]').forEach(function (r) {
193 r.addEventListener('change', onPeriodChange);
194 });
195 });
196 })();
197