PluginProbe
404 Solution / 4.2.0
404 Solution v4.2.0
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 / ViewTrait_Stats.php

ViewTrait_Stats.php in 404 Solution 4.2.0, at includes/ViewTrait_Stats.php

926 lines 48.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 if (!defined('ABSPATH')) {
4 exit;
5 }
6
7 /**
8 * ViewTrait_Stats methods.
9 */
10 trait ViewTrait_Stats {
11
12
13 /**
14 * Output the stats page.
15 * @return void
16 */
17 function outputAdminStatsPage() {
18 global $abj404view;
19
20 $statsSnapshot = $this->statsRepository->getStatsDashboardSnapshot(true);
21 $statsData = $statsSnapshot['data'];
22 $statsHash = $statsSnapshot['hash'];
23
24 // Main container
25 echo "<div class=\"abj404-container\">";
26 echo "<div class=\"abj404-settings-content\">";
27
28 // Header row with Expand All button
29 echo "<div class=\"abj404-header-row\">";
30 echo "<h2>" . esc_html__('Statistics', '404-solution') . "</h2>";
31 echo "<div class=\"abj404-header-controls\">";
32 echo '<button type="button" id="abj404-expand-collapse-all" class="button">';
33 echo esc_html__('Expand All', '404-solution');
34 echo '</button>';
35 echo "</div>";
36 echo "</div>";
37
38 // Config for stale-while-refresh stats snapshot updates (no visible table overwrite).
39 echo '<div class="abj404-stats-refresh-config" style="display:none"'
40 . ' data-stats-refresh-enabled="1"'
41 . ' data-stats-refresh-action="ajaxRefreshStatsDashboard"'
42 . ' data-stats-refresh-nonce="' . esc_attr(wp_create_nonce('abj404_refreshStatsDashboard')) . '"'
43 . ' data-stats-refresh-current-hash="' . esc_attr($statsHash) . '"'
44 . ' data-stats-refresh-available-text="' . esc_attr(__('Refresh available', '404-solution')) . '"></div>';
45
46 // Flow layout for stats cards
47 echo "<div class=\"abj404-flow-layout\">";
48
49 // Redirects Statistics Card
50 $redirectStats = (is_array($statsData) && isset($statsData['redirects']) && is_array($statsData['redirects']))
51 ? $statsData['redirects']
52 : array();
53 $auto301 = intval($redirectStats['auto301'] ?? 0);
54 $auto302 = intval($redirectStats['auto302'] ?? 0);
55 $manual301 = intval($redirectStats['manual301'] ?? 0);
56 $manual302 = intval($redirectStats['manual302'] ?? 0);
57 $trashed = intval($redirectStats['trashed'] ?? 0);
58
59 $total = $auto301 + $auto302 + $manual301 + $manual302 + $trashed;
60
61 $content = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/html/statsRedirectsBox.html");
62
63 // In Simple mode, replace technical "301"/"302" labels with plain language
64 if ($this->logic->getSettingsMode() === 'simple') {
65 $content = $this->f->str_replace('{Automatic 301 Redirects}', esc_html__('Automatic Permanent Redirects', '404-solution'), $content);
66 $content = $this->f->str_replace('{Automatic 302 Redirects}', esc_html__('Automatic Temporary Redirects', '404-solution'), $content);
67 $content = $this->f->str_replace('{Manual 301 Redirects}', esc_html__('Manual Permanent Redirects', '404-solution'), $content);
68 $content = $this->f->str_replace('{Manual 302 Redirects}', esc_html__('Manual Temporary Redirects', '404-solution'), $content);
69 }
70
71 $content = $this->f->str_replace('{auto301}', esc_html((string)$auto301), $content);
72 $content = $this->f->str_replace('{auto302}', esc_html((string)$auto302), $content);
73 $content = $this->f->str_replace('{manual301}', esc_html((string)$manual301), $content);
74 $content = $this->f->str_replace('{manual302}', esc_html((string)$manual302), $content);
75 $content = $this->f->str_replace('{trashed}', esc_html((string)$trashed), $content);
76 $content = $this->f->str_replace('{total}', esc_html((string)$total), $content);
77 $content = $this->f->doNormalReplacements($content);
78 $abj404view->echoOptionsSection('stats-redirects', 'abj404-redirectStats', __('Redirects', '404-solution'), $content, true, $abj404view->getCardIcon('chart'));
79
80 // Captured URLs Statistics Card
81 $capturedStats = (is_array($statsData) && isset($statsData['captured']) && is_array($statsData['captured']))
82 ? $statsData['captured']
83 : array();
84 $captured = intval($capturedStats['captured'] ?? 0);
85 $ignored = intval($capturedStats['ignored'] ?? 0);
86 $trashed = intval($capturedStats['trashed'] ?? 0);
87
88 $total = $captured + $ignored + $trashed;
89
90 $content = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/html/statsCapturedURLsBox.html");
91 $content = $this->f->str_replace('{captured}', esc_html((string)$captured), $content);
92 $content = $this->f->str_replace('{ignored}', esc_html((string)$ignored), $content);
93 $content = $this->f->str_replace('{trashed}', esc_html((string)$trashed), $content);
94 $content = $this->f->str_replace('{total}', esc_html((string)$total), $content);
95 $content = $this->f->doNormalReplacements($content);
96 $abj404view->echoOptionsSection('stats-captured', 'abj404-capturedStats', __('Captured URLs', '404-solution'), $content, true, $abj404view->getCardIcon('warning'));
97
98 // Periodic Stats Cards
99 $periodicStats = (is_array($statsData) && isset($statsData['periods']) && is_array($statsData['periods']))
100 ? $statsData['periods']
101 : array();
102 $periodMeta = array(
103 array('title' => __("Today's Stats", '404-solution'), 'key' => 'today'),
104 array('title' => __("This Month", '404-solution'), 'key' => 'month'),
105 array('title' => __("This Year", '404-solution'), 'key' => 'year'),
106 array('title' => __("All Stats", '404-solution'), 'key' => 'all'),
107 );
108
109 for ($x = 0; $x <= 3; $x++) {
110 $title = $periodMeta[$x]['title'];
111 $periodKey = $periodMeta[$x]['key'];
112 $periodStats = (is_array($periodicStats) && isset($periodicStats[$periodKey]) && is_array($periodicStats[$periodKey]))
113 ? $periodicStats[$periodKey]
114 : array();
115 $disp404 = intval($periodStats['disp404'] ?? 0);
116 $distinct404 = intval($periodStats['distinct404'] ?? 0);
117 $visitors404 = intval($periodStats['visitors404'] ?? 0);
118 $refer404 = intval($periodStats['refer404'] ?? 0);
119 $redirected = intval($periodStats['redirected'] ?? 0);
120 $distinctredirected = intval($periodStats['distinctredirected'] ?? 0);
121 $distinctvisitors = intval($periodStats['distinctvisitors'] ?? 0);
122 $distinctrefer = intval($periodStats['distinctrefer'] ?? 0);
123
124 $content = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/html/statsPeriodicBox.html");
125 $content = $this->f->str_replace('{disp404}', esc_html((string)$disp404), $content);
126 $content = $this->f->str_replace('{distinct404}', esc_html((string)$distinct404), $content);
127 $content = $this->f->str_replace('{visitors404}', esc_html((string)$visitors404), $content);
128 $content = $this->f->str_replace('{refer404}', esc_html((string)$refer404), $content);
129 $content = $this->f->str_replace('{redirected}', esc_html((string)$redirected), $content);
130 $content = $this->f->str_replace('{distinctredirected}', esc_html((string)$distinctredirected), $content);
131 $content = $this->f->str_replace('{distinctvisitors}', esc_html((string)$distinctvisitors), $content);
132 $content = $this->f->str_replace('{distinctrefer}', esc_html((string)$distinctrefer), $content);
133 $content = $this->f->doNormalReplacements($content);
134 $abj404view->echoOptionsSection('stats-periodic-' . $x, 'abj404-stats' . $x, $title, $content, ($x == 0), $abj404view->getCardIcon('clock'));
135 }
136
137 echo "</div>"; // Close flow layout
138
139 // Match Confidence distribution card (full-width)
140 $this->echoConfidenceDistributionSection();
141
142 // Trend Analytics section (full-width, below the flow layout cards)
143 $this->echoTrendsSection();
144
145 // Broken Internal Links section
146 $this->echoBrokenInternalLinksSection();
147
148 echo "</div>"; // Close settings content
149 echo "</div>"; // Close container
150 }
151
152 /**
153 * Output the Match Confidence distribution card on the Stats page.
154 * Queries the redirects table for score band counts and renders a Chart.js doughnut.
155 * @return void
156 */
157 private function echoConfidenceDistributionSection() {
158 global $abj404view, $wpdb;
159
160 if (!isset($wpdb)) {
161 return;
162 }
163
164 $dbCore = abj_service('db_core');
165 $redirectsTable = $dbCore->doTableNameReplacements('{wp_abj404_redirects}');
166
167 // Query score distribution bands. Route through the DAO so the
168 // 5x SUM(CASE...) aggregate inherits the centralized 60s SELECT
169 // timeout — the redirects table can be very large on busy sites.
170 $sql = "SELECT
171 SUM(CASE WHEN score IS NULL THEN 1 ELSE 0 END) AS manual_count,
172 SUM(CASE WHEN score >= 80 THEN 1 ELSE 0 END) AS high_count,
173 SUM(CASE WHEN score >= 50 AND score < 80 THEN 1 ELSE 0 END) AS medium_count,
174 SUM(CASE WHEN score IS NOT NULL AND score < 50 THEN 1 ELSE 0 END) AS low_count,
175 AVG(score) AS avg_score
176 FROM `{$redirectsTable}`
177 WHERE disabled = %d AND status != %d";
178
179 $result = $dbCore->queryAndGetResults($sql, array('query_params' => array(0, 0)));
180 if (!empty($result['timed_out']) || (isset($result['last_error']) && $result['last_error'] != '')) {
181 return;
182 }
183 $rows = is_array($result['rows'] ?? null) ? $result['rows'] : array();
184 if (empty($rows) || !is_array($rows[0] ?? null)) {
185 return;
186 }
187 $row = $rows[0];
188
189 $highCount = (int)($row['high_count'] ?? 0);
190 $mediumCount = (int)($row['medium_count'] ?? 0);
191 $lowCount = (int)($row['low_count'] ?? 0);
192 $manualCount = (int)($row['manual_count'] ?? 0);
193 $avgScore = ($row['avg_score'] !== null) ? round((float)$row['avg_score'], 1) : null;
194
195 $total = $highCount + $mediumCount + $lowCount + $manualCount;
196 if ($total === 0) {
197 return;
198 }
199
200 $labelHigh = esc_html__('High (≥80%)', '404-solution');
201 $labelMedium = esc_html__('Medium (50–79%)', '404-solution');
202 $labelLow = esc_html__('Low (<50%)', '404-solution');
203 $labelManual = esc_html__('Manual (no score)', '404-solution');
204
205 $avgLabel = ($avgScore !== null)
206 ? sprintf(
207 '<strong>' . esc_html__('Avg confidence: %s%%', '404-solution') . '</strong>',
208 esc_html(number_format($avgScore, 1))
209 )
210 : '';
211
212 $content = '<div class="abj404-confidence-dist">';
213 $content .= '<p class="abj404-confidence-avg">' . $avgLabel . '</p>';
214 $content .= '<canvas id="abj404-chart-confidence" style="max-height:200px;max-width:400px;"></canvas>';
215 $content .= '<ul class="abj404-confidence-legend">';
216 $content .= '<li><span class="abj404-legend-dot abj404-conf-high"></span>' . esc_html($labelHigh) . ' <strong>' . esc_html((string)$highCount) . '</strong></li>';
217 $content .= '<li><span class="abj404-legend-dot abj404-conf-medium"></span>' . esc_html($labelMedium) . ' <strong>' . esc_html((string)$mediumCount) . '</strong></li>';
218 $content .= '<li><span class="abj404-legend-dot abj404-conf-low"></span>' . esc_html($labelLow) . ' <strong>' . esc_html((string)$lowCount) . '</strong></li>';
219 $content .= '<li><span class="abj404-legend-dot abj404-conf-manual"></span>' . esc_html($labelManual) . ' <strong>' . esc_html((string)$manualCount) . '</strong></li>';
220 $content .= '</ul>';
221 $content .= '</div>';
222
223 $content .= '<style>'
224 . '.abj404-confidence-dist { display: flex; align-items: center; gap: 32px; flex-wrap: wrap; }'
225 . '.abj404-confidence-avg { font-size: 14px; margin-bottom: 8px; }'
226 . '.abj404-confidence-legend { list-style: none; margin: 0; padding: 0; }'
227 . '.abj404-confidence-legend li { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; font-size: 13px; }'
228 . '.abj404-legend-dot { display: inline-block; width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; }'
229 . '.abj404-conf-high { background: #28a745; }'
230 . '.abj404-conf-medium { background: #ffc107; }'
231 . '.abj404-conf-low { background: #dc3545; }'
232 . '.abj404-conf-manual { background: #adb5bd; }'
233 . '</style>';
234
235 $content .= '<script>'
236 . '(function() {'
237 . ' function renderConfidenceChart() {'
238 . ' var ctx = document.getElementById("abj404-chart-confidence");'
239 . ' if (!ctx || !window.Chart) return;'
240 . ' new Chart(ctx, {'
241 . ' type: "doughnut",'
242 . ' data: {'
243 . ' labels: [' . json_encode($labelHigh) . ',' . json_encode($labelMedium) . ',' . json_encode($labelLow) . ',' . json_encode($labelManual) . '],'
244 . ' datasets: [{ data: [' . $highCount . ',' . $mediumCount . ',' . $lowCount . ',' . $manualCount . '],'
245 . ' backgroundColor: ["#28a745","#ffc107","#dc3545","#adb5bd"] }]'
246 . ' },'
247 . ' options: { responsive: true, plugins: { legend: { display: false } } }'
248 . ' });'
249 . ' }'
250 . ' if (window.Chart) { renderConfidenceChart(); }'
251 . ' else { document.addEventListener("abj404ChartJsLoaded", renderConfidenceChart); }'
252 . '})();'
253 . '</script>';
254
255 $abj404view->echoOptionsSection(
256 'stats-confidence',
257 'abj404-confidenceSection',
258 __('Match Confidence', '404-solution'),
259 $content,
260 false,
261 $abj404view->getCardIcon('check')
262 );
263 }
264
265 /**
266 * Output the Trends (time-series charts) section on the Stats page.
267 * @return void
268 */
269 private function echoTrendsSection() {
270 global $abj404view;
271
272 $trendNonce = wp_create_nonce('abj404_trendData');
273 $ajaxUrl = admin_url('admin-ajax.php');
274
275 $label7d = esc_html__('7 days', '404-solution');
276 $label30d = esc_html__('30 days', '404-solution');
277 $label90d = esc_html__('90 days', '404-solution');
278
279 $trendsContent = '<div id="abj404-trends-container">';
280 $trendsContent .= '<div class="abj404-trends-period-selector" role="group" aria-label="' . esc_attr__('Period', '404-solution') . '">';
281 $trendsContent .= '<label class="abj404-trends-period-label"><input type="radio" name="abj404_trend_period" value="7"> ' . $label7d . '</label>';
282 $trendsContent .= '<label class="abj404-trends-period-label"><input type="radio" name="abj404_trend_period" value="30" checked> ' . $label30d . '</label>';
283 $trendsContent .= '<label class="abj404-trends-period-label"><input type="radio" name="abj404_trend_period" value="90"> ' . $label90d . '</label>';
284 $trendsContent .= '</div>';
285 $trendsContent .= '<p class="abj404-trends-loading">' . esc_html__('Loading chart data…', '404-solution') . '</p>';
286 $trendsContent .= '<div id="abj404-trends-charts" style="display:none">';
287 $trendsContent .= '<div class="abj404-trend-chart-wrap"><canvas id="abj404-chart-404s"></canvas></div>';
288 $trendsContent .= '<div class="abj404-trend-chart-wrap"><canvas id="abj404-chart-redirects"></canvas></div>';
289 $trendsContent .= '<div class="abj404-trend-chart-wrap"><canvas id="abj404-chart-captures"></canvas></div>';
290 $trendsContent .= '</div>';
291 $trendsContent .= '<p id="abj404-trends-error" style="display:none;color:#d63638">'
292 . esc_html__('Could not load chart data.', '404-solution') . '</p>';
293 $trendsContent .= '</div>';
294
295 $trendsContent .= '<style>'
296 . '.abj404-trends-period-selector { margin-bottom: 16px; display: flex; gap: 16px; flex-wrap: wrap; align-items: center; }'
297 . '.abj404-trends-period-label { cursor: pointer; font-weight: 500; }'
298 . '.abj404-trends-period-label input { margin-right: 4px; }'
299 . '.abj404-trend-chart-wrap { margin-bottom: 24px; }'
300 . '.abj404-trends-loading { color: #646970; font-style: italic; }'
301 . '</style>';
302
303 // Inline JS: load Chart.js from CDN then fetch data and render charts.
304 $label404 = esc_js(__('404 Hits per Day', '404-solution'));
305 $labelRedirect = esc_js(__('Redirects per Day', '404-solution'));
306 $labelCapture = esc_js(__('New Captures per Day', '404-solution'));
307 $ajaxUrlEsc = esc_js($ajaxUrl);
308 $nonceEsc = esc_js($trendNonce);
309
310 $trendsContent .= '<script>'
311 . '(function() {'
312 . ' var ajaxUrl = "' . $ajaxUrlEsc . '";'
313 . ' var nonce = "' . $nonceEsc . '";'
314 . ' function loadChartJs(cb) {'
315 . ' if (window.Chart) { cb(); return; }'
316 . ' var s = document.createElement("script");'
317 . ' s.src = "https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js";'
318 . ' s.onload = function() {'
319 . ' document.dispatchEvent(new Event("abj404ChartJsLoaded"));'
320 . ' cb();'
321 . ' };'
322 . ' s.onerror = function() {'
323 . ' var loadEl = document.querySelector(".abj404-trends-loading");'
324 . ' if (loadEl) loadEl.style.display = "none";'
325 . ' var errEl = document.getElementById("abj404-trends-error");'
326 . ' if (errEl) errEl.style.display = "";'
327 . ' };'
328 . ' document.head.appendChild(s);'
329 . ' }'
330 . ' function buildChart(canvasId, label, color, labels, values) {'
331 . ' var ctx = document.getElementById(canvasId);'
332 . ' if (!ctx) return null;'
333 . ' return new Chart(ctx, {'
334 . ' type: "line",'
335 . ' data: {'
336 . ' labels: labels,'
337 . ' datasets: [{'
338 . ' label: label,'
339 . ' data: values,'
340 . ' borderColor: color,'
341 . ' backgroundColor: color.replace("rgb(", "rgba(").replace(")", ", 0.15)"),'
342 . ' tension: 0.3,'
343 . ' fill: true,'
344 . ' pointRadius: 3'
345 . ' }]'
346 . ' },'
347 . ' options: {'
348 . ' responsive: true,'
349 . ' plugins: { legend: { display: true } },'
350 . ' scales: { y: { beginAtZero: true, ticks: { precision: 0 } } }'
351 . ' }'
352 . ' });'
353 . ' }'
354 . ' var chartInstances = {};'
355 . ' function getSelectedDays() {'
356 . ' var radios = document.querySelectorAll("input[name=abj404_trend_period]");'
357 . ' for (var i = 0; i < radios.length; i++) {'
358 . ' if (radios[i].checked) return parseInt(radios[i].value, 10);'
359 . ' }'
360 . ' return 30;'
361 . ' }'
362 . ' function destroyCharts() {'
363 . ' ["abj404-chart-404s","abj404-chart-redirects","abj404-chart-captures"].forEach(function(id) {'
364 . ' if (chartInstances[id]) { chartInstances[id].destroy(); delete chartInstances[id]; }'
365 . ' });'
366 . ' }'
367 . ' function fetchTrendData(days, allowRetry) {'
368 . ' return fetch(ajaxUrl + "?action=abj404getTrendData&nonce=" + encodeURIComponent(nonce) + "&days=" + days)'
369 . ' .then(function(r) {'
370 // B20: a 12-24h-idle nonce expires; admin-ajax replies 403.
371 // Mint a fresh nonce via the shared refresh helper (if loaded)
372 // and retry once. allowRetry guards against an infinite loop.
373 . ' if (r.status === 403 && allowRetry !== false && window.abj404NonceRefresh) {'
374 . ' return window.abj404NonceRefresh.fetchFresh().then(function(freshNonces) {'
375 . ' if (freshNonces && freshNonces["abj404_trendData"]) {'
376 . ' nonce = freshNonces["abj404_trendData"];'
377 . ' }'
378 . ' return fetchTrendData(days, false);'
379 . ' });'
380 . ' }'
381 . ' return r.json();'
382 . ' });'
383 . ' }'
384 . ' function fetchAndRender() {'
385 . ' var days = getSelectedDays();'
386 . ' var loadEl = document.querySelector(".abj404-trends-loading");'
387 . ' var errEl = document.getElementById("abj404-trends-error");'
388 . ' var chartsEl = document.getElementById("abj404-trends-charts");'
389 . ' if (loadEl) loadEl.style.display = "";'
390 . ' if (errEl) errEl.style.display = "none";'
391 . ' if (chartsEl) chartsEl.style.display = "none";'
392 . ' destroyCharts();'
393 . ' fetchTrendData(days, true)'
394 . ' .then(function(resp) {'
395 . ' if (loadEl) loadEl.style.display = "none";'
396 . ' if (!resp || !resp.success || !Array.isArray(resp.data)) {'
397 . ' if (errEl) errEl.style.display = "";'
398 . ' return;'
399 . ' }'
400 . ' var rows = resp.data;'
401 . ' var labels = rows.map(function(r) { return r.date; });'
402 . ' var vals404 = rows.map(function(r) { return r.hits_404; });'
403 . ' var valsRedir = rows.map(function(r) { return r.hits_redirect; });'
404 . ' var valsCapt = rows.map(function(r) { return r.new_captures; });'
405 . ' if (chartsEl) chartsEl.style.display = "";'
406 . ' chartInstances["abj404-chart-404s"] = buildChart("abj404-chart-404s", "' . $label404 . '", "rgb(0,115,170)", labels, vals404);'
407 . ' chartInstances["abj404-chart-redirects"] = buildChart("abj404-chart-redirects", "' . $labelRedirect . '", "rgb(70,170,100)", labels, valsRedir);'
408 . ' chartInstances["abj404-chart-captures"] = buildChart("abj404-chart-captures", "' . $labelCapture . '", "rgb(220,100,50)", labels, valsCapt);'
409 . ' })'
410 . ' .catch(function() {'
411 . ' if (loadEl) loadEl.style.display = "none";'
412 . ' if (errEl) errEl.style.display = "";'
413 . ' });'
414 . ' }'
415 . ' function onPeriodChange() { fetchAndRender(); }'
416 . ' document.addEventListener("DOMContentLoaded", function() {'
417 . ' loadChartJs(fetchAndRender);'
418 . ' document.querySelectorAll("input[name=abj404_trend_period]").forEach(function(r) {'
419 . ' r.addEventListener("change", onPeriodChange);'
420 . ' });'
421 . ' });'
422 . '})();'
423 . '</script>';
424
425 $abj404view->echoOptionsSection(
426 'stats-trends',
427 'abj404-trendsSection',
428 __('Trend Analytics', '404-solution'),
429 $trendsContent,
430 false,
431 $abj404view->getCardIcon('chart')
432 );
433 }
434
435 /**
436 * Output the Broken Internal Links section on the Stats page (if results are cached).
437 * @return void
438 */
439 private function echoBrokenInternalLinksSection() {
440 global $abj404view;
441
442 if (!class_exists('ABJ_404_Solution_InternalLinkScanner')) {
443 return;
444 }
445
446 $scanner = new ABJ_404_Solution_InternalLinkScanner();
447 $results = $scanner->getCachedResults();
448
449 if ($results === false || !is_array($results)) {
450 // No cached results yet — nothing to show.
451 return;
452 }
453
454 if (empty($results)) {
455 $content = '<p>' . esc_html__('No broken internal links found.', '404-solution') . '</p>';
456 } else {
457 $postCount = count(array_unique(array_column($results, 'post_id')));
458 $content = '<p>' . esc_html(sprintf(
459 /* translators: 1: number of broken links, 2: number of posts/pages */
460 __('Found %1$d broken internal link(s) across %2$d post(s)/page(s).', '404-solution'),
461 count($results),
462 $postCount
463 )) . '</p>';
464 $content .= '<table class="widefat striped"><thead><tr>'
465 . '<th>' . esc_html__('Post/Page', '404-solution') . '</th>'
466 . '<th>' . esc_html__('Broken URL', '404-solution') . '</th>'
467 . '<th>' . esc_html__('404 Hits', '404-solution') . '</th>'
468 . '</tr></thead><tbody>';
469 foreach ($results as $item) {
470 if (!is_array($item)) {
471 continue;
472 }
473 $postTitle = (string)$item['post_title'];
474 $brokenUrl = (string)$item['broken_url'];
475 $hitCount = intval($item['hit_count']);
476 $postId = intval($item['post_id']);
477 $editLink = ($postId > 0) ? get_edit_post_link($postId) : '';
478 $content .= '<tr>';
479 if ($editLink) {
480 $content .= '<td><a href="' . esc_url($editLink) . '">' . esc_html($postTitle) . '</a></td>';
481 } else {
482 $content .= '<td>' . esc_html($postTitle) . '</td>';
483 }
484 $content .= '<td><code>' . esc_html($brokenUrl) . '</code></td>';
485 $content .= '<td>' . esc_html((string)$hitCount) . '</td>';
486 $content .= '</tr>';
487 }
488 $content .= '</tbody></table>';
489 }
490
491 $abj404view->echoOptionsSection(
492 'stats-broken-links',
493 'abj404-brokenLinksSection',
494 __('Broken Internal Links', '404-solution'),
495 $content,
496 false,
497 $abj404view->getCardIcon('warning')
498 );
499 }
500
501 /** @return void */
502 function echoAdminDebugFile() {
503 if ($this->logic->userIsPluginAdmin()) {
504 $filesToEcho = array($this->logger->getDebugFilePath(),
505 $this->logger->getDebugFilePathOld());
506 for ($i = 0; $i < count($filesToEcho); $i++) {
507 $currentFile = $filesToEcho[$i];
508 echo "<div style=\"clear: both;\">";
509 echo "<BR/>Contents of: " . $currentFile . ": <BR/><BR/>";
510 // read the file and replace new lines with <BR/>.
511 $this->echoFileContents($currentFile);
512 echo "</div>";
513 }
514
515 } else {
516 echo "Non-admin request to view debug file.";
517 $current_user = ABJ_404_Solution_UserRef::fromWpUser(wp_get_current_user());
518 $userLogin = $current_user !== null ? $current_user->getLogin() : '';
519 $userDisplay = $current_user !== null ? $current_user->getDisplayName() : '';
520 $userEmail = $current_user !== null ? $current_user->getEmail() : '';
521 $userId = $current_user !== null ? $current_user->getId() : 0;
522 $userInfo = "Login: " . $userLogin . ", display name: " .
523 $userDisplay . ", Email: " . $userEmail .
524 ", UserID: " . $userId;
525 $this->logger->infoMessage("Non-admin request to view debug file. User info: " .
526 $userInfo);
527 }
528 }
529
530 /**
531 * @param string $fileName
532 * @return void
533 */
534 function echoFileContents($fileName) {
535
536 if (!is_string($fileName)) {
537 $fileName = '';
538 }
539
540 if (file_exists($fileName)) {
541 $linesRead = 0;
542 $handle = null;
543 try {
544 if ($handle = fopen($fileName, "r")) {
545 // read the file one line at a time.
546 while (($line = fgets($handle)) !== false) {
547 $linesRead++;
548 echo nl2br(esc_html($line));
549
550 if ($linesRead > 1000000) {
551 echo "<BR/><BR/>Read " . $linesRead . " lines. Download debug file to see more.";
552 break;
553 }
554 }
555 } else {
556 $this->logger->errorMessage("Error opening debug file.");
557 }
558
559 } catch (Exception $e) {
560 $this->logger->errorMessage("Error while reading debug file.", $e);
561 }
562
563 if ($handle != null) {
564 fclose($handle);
565 }
566
567 } else {
568 echo nl2br(__('(The log file does not exist.)', '404-solution'));
569 }
570 }
571
572 /**
573 * Display the tools page.
574 * @return void
575 */
576 function echoAdminToolsPage() {
577 global $abj404view;
578
579 // Main container
580 echo "<div class=\"abj404-container\">";
581 echo "<div class=\"abj404-settings-content\">";
582
583 // Header row with Expand All button
584 echo "<div class=\"abj404-header-row\">";
585 echo "<h2>" . esc_html__('Tools', '404-solution') . "</h2>";
586 echo "<div class=\"abj404-header-controls\">";
587 echo '<button type="button" id="abj404-expand-collapse-all" class="button">';
588 echo esc_html__('Expand All', '404-solution');
589 echo '</button>';
590 echo "</div>";
591 echo "</div>";
592
593 // Export Card
594 $link = wp_nonce_url("?page=" . ABJ404_PP . "&subpage=abj404_tools", "abj404_exportRedirects");
595 $html = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/html/toolsExportForm.html");
596 $html = $this->f->str_replace('{toolsExportRedirectsLink}', $link, $html);
597 $html = $this->f->doNormalReplacements($html);
598 $abj404view->echoOptionsSection('tools-export', 'abj404-exportRedirects', __('Export', '404-solution'), $html, true, $abj404view->getCardIcon('download'));
599
600 // Import Card
601 $link = wp_nonce_url("?page=" . ABJ404_PP . "&subpage=abj404_tools", "abj404_importRedirectsFile");
602 $html = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/html/toolsImportForm.html");
603 $html = $this->f->str_replace('{toolsImportRedirectsLink}', $link, $html);
604 $html = $this->f->doNormalReplacements($html);
605 $abj404view->echoOptionsSection('tools-import', 'abj404-importRedirects', __('Import', '404-solution'), $html, false, $abj404view->getCardIcon('upload'));
606
607 // Purge Card
608 $url = "?page=" . ABJ404_PP . "&subpage=abj404_tools";
609 $link = wp_nonce_url($url, "abj404_purgeRedirects");
610 $html = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/html/toolsPurgeForm.html");
611 $html = $this->f->str_replace('{toolsPurgeFormActionLink}', $link, $html);
612 $html = $this->f->doNormalReplacements($html);
613 $abj404view->echoOptionsSection('tools-purge', 'abj404-purgeRedirects', __('Purge Options', '404-solution'), $html, false, $abj404view->getCardIcon('trash'));
614
615 // Cache Management Card
616 $ngramLink = wp_nonce_url("?page=" . ABJ404_PP . "&subpage=abj404_tools", "abj404_rebuildNgramCache");
617 $spellingLink = wp_nonce_url("?page=" . ABJ404_PP . "&subpage=abj404_tools", "abj404_clearSpellingCache");
618 $html = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/html/toolsCacheForm.html");
619 $html = $this->f->str_replace('{toolsNgramCacheFormActionLink}', $ngramLink, $html);
620 $html = $this->f->str_replace('{toolsSpellingCacheFormActionLink}', $spellingLink, $html);
621 $html = $this->f->doNormalReplacements($html);
622 $abj404view->echoOptionsSection('tools-cache', 'abj404-cacheTools', __('Cache Management', '404-solution'), $html, false, $abj404view->getCardIcon('database'));
623
624 // Diagnostics Card
625 $html = $this->getToolsDiagnosticsMarkup();
626 $abj404view->echoOptionsSection('tools-diagnostics', 'abj404-diagnosticsTools', __('Diagnostics', '404-solution'), $html, false, $abj404view->getCardIcon('warning'));
627
628 // Etcetera Card
629 $link = wp_nonce_url("?page=" . ABJ404_PP . "&subpage=abj404_tools", "abj404_runMaintenance");
630 $link .= '&manually_fired=true';
631 $html = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/html/toolsEtcForm.html");
632 $html = $this->f->str_replace('{toolsMaintenanceFormActionLink}', $link, $html);
633 $html = $this->f->doNormalReplacements($html);
634 $abj404view->echoOptionsSection('tools-etc', 'abj404-etcTools', __('Etcetera', '404-solution'), $html, false, $abj404view->getCardIcon('cog'));
635
636 // Migrate from Another Plugin Card
637 $html = $this->getMigrateFromPluginMarkup();
638 $abj404view->echoOptionsSection('tools-migrate', 'abj404-migrateFromPlugin', __('Migrate from Another Plugin', '404-solution'), $html, false, $abj404view->getCardIcon('upload'));
639
640 echo "</div>";
641 echo "</div>";
642 }
643
644 /**
645 * Build the "Migrate from Another Plugin" card markup.
646 * Auto-detects installed redirect plugins and renders a two-step preview+import flow.
647 *
648 * Step 1: User selects a plugin and clicks "Preview Import" — an AJAX request fetches
649 * the count of available redirects without importing anything.
650 * Step 2: The count is shown. If N > 0, a "Confirm Import" button submits the real
651 * import form. If N = 0, "No redirects found" is shown with a Back button.
652 *
653 * @return string
654 */
655 private function getMigrateFromPluginMarkup(): string {
656 $redirectsRepo = abj_service('redirects_repository');
657 $logger = abj_service('logging');
658 $importer = new ABJ_404_Solution_CrossPluginImporter($redirectsRepo, $logger);
659
660 $detected = $importer->detectInstalledPlugins();
661
662 $pluginLabels = array(
663 'rankmath' => __('Rank Math', '404-solution'),
664 'yoast' => __('Yoast SEO Premium', '404-solution'),
665 'aioseo' => __('AIOSEO', '404-solution'),
666 'safe-redirect-manager' => __('Safe Redirect Manager', '404-solution'),
667 'redirection' => __('Redirection Plugin', '404-solution'),
668 );
669
670 $availableSources = array();
671 foreach ($detected as $slug => $isAvailable) {
672 if ($isAvailable) {
673 $availableSources[$slug] = isset($pluginLabels[$slug]) ? $pluginLabels[$slug] : $slug;
674 }
675 }
676
677 $migrateActionUrl = wp_nonce_url(
678 '?page=' . ABJ404_PP . '&subpage=abj404_tools',
679 'abj404_importFromPlugin'
680 );
681
682 $html = '<p>';
683 if (empty($availableSources)) {
684 $html .= esc_html__('No supported redirect plugins detected on this site.', '404-solution');
685 $html .= '</p>';
686 $html .= '<p>' . esc_html__('Supported plugins: Rank Math, Yoast SEO Premium, AIOSEO, Safe Redirect Manager, Redirection.', '404-solution') . '</p>';
687 return $html;
688 }
689
690 $detectedNames = array_values($availableSources);
691 $html .= esc_html__('Detected redirect plugins:', '404-solution') . ' ';
692 $html .= '<strong>' . esc_html(implode(', ', $detectedNames)) . '</strong>';
693 $html .= '</p>';
694
695 $previewNonce = wp_create_nonce('abj404_crossPluginPreview');
696 $ajaxUrl = admin_url('admin-ajax.php');
697
698 // Step 1: source selector + Preview button (visible by default)
699 $html .= '<div id="abj404-migrate-step1">';
700 $html .= '<p>';
701 $html .= '<label for="abj404-import-source"><strong>' . esc_html__('Source plugin:', '404-solution') . '</strong></label> ';
702 $html .= '<select name="import_source" id="abj404-import-source">';
703 foreach ($availableSources as $slug => $label) {
704 $html .= '<option value="' . esc_attr($slug) . '">' . esc_html($label) . '</option>';
705 }
706 $html .= '</select>';
707 $html .= '</p>';
708 $html .= '<p>';
709 $html .= '<button type="button" id="abj404-migrate-preview-btn" class="button-secondary">';
710 $html .= esc_html__('Preview Import', '404-solution');
711 $html .= '</button>';
712 $html .= ' <span id="abj404-migrate-preview-spinner" style="display:none;margin-left:6px;" class="spinner is-active"></span>';
713 $html .= '</p>';
714 $html .= '</div>';
715
716 // Step 2: preview result + confirm form (hidden until preview completes)
717 $html .= '<div id="abj404-migrate-step2" style="display:none;">';
718 $html .= '<p id="abj404-migrate-preview-msg"></p>';
719 $html .= '<form id="abj404-migrate-confirm-form" method="POST" action="' . esc_url($migrateActionUrl) . '" style="display:none;">';
720 $html .= '<input type="hidden" name="action" value="importFromPlugin">';
721 $html .= '<input type="hidden" name="import_source" id="abj404-migrate-confirm-source" value="">';
722 $html .= '<input type="submit" class="button-primary" value="' . esc_attr__('Confirm Import', '404-solution') . '">';
723 $html .= ' <button type="button" id="abj404-migrate-back-btn" class="button-secondary">';
724 $html .= esc_html__('Back', '404-solution');
725 $html .= '</button>';
726 $html .= '</form>';
727 $html .= '<div id="abj404-migrate-back-noform" style="display:none;">';
728 $html .= '<button type="button" id="abj404-migrate-back-btn2" class="button-secondary">';
729 $html .= esc_html__('Back', '404-solution');
730 $html .= '</button>';
731 $html .= '</div>';
732 $html .= '</div>';
733
734 $html .= '<p><em>' . esc_html__('This will import all active redirects from the selected plugin into 404 Solution. Regex and redirect codes are preserved.', '404-solution') . '</em></p>';
735
736 // Inline JS for the two-step flow
737 $html .= '<script>(function() {';
738 $html .= 'var ajaxUrl = ' . json_encode($ajaxUrl) . ';';
739 $html .= 'var nonce = ' . json_encode($previewNonce) . ';';
740 $html .= 'var msgFound = ' . json_encode(__('Found %d redirect(s) from %s — proceed with import?', '404-solution')) . ';';
741 $html .= 'var msgNone = ' . json_encode(__('No redirects found in %s. Nothing to import.', '404-solution')) . ';';
742 $html .= 'var msgError = ' . json_encode(__('Could not fetch preview. Please try again.', '404-solution')) . ';';
743 $html .= 'function showStep1() {';
744 $html .= ' document.getElementById("abj404-migrate-step1").style.display = "";';
745 $html .= ' document.getElementById("abj404-migrate-step2").style.display = "none";';
746 $html .= '}';
747 $html .= 'function showStep2(count, source, label) {';
748 $html .= ' document.getElementById("abj404-migrate-step1").style.display = "none";';
749 $html .= ' document.getElementById("abj404-migrate-step2").style.display = "";';
750 $html .= ' var msgEl = document.getElementById("abj404-migrate-preview-msg");';
751 $html .= ' var form = document.getElementById("abj404-migrate-confirm-form");';
752 $html .= ' var noForm = document.getElementById("abj404-migrate-back-noform");';
753 $html .= ' if (count > 0) {';
754 $html .= ' msgEl.textContent = msgFound.replace("%d", count).replace("%s", label);';
755 $html .= ' document.getElementById("abj404-migrate-confirm-source").value = source;';
756 $html .= ' form.style.display = "";';
757 $html .= ' noForm.style.display = "none";';
758 $html .= ' } else {';
759 $html .= ' msgEl.textContent = msgNone.replace("%s", label);';
760 $html .= ' form.style.display = "none";';
761 $html .= ' noForm.style.display = "";';
762 $html .= ' }';
763 $html .= '}';
764 $html .= 'function showError() {';
765 $html .= ' document.getElementById("abj404-migrate-step1").style.display = "none";';
766 $html .= ' document.getElementById("abj404-migrate-step2").style.display = "";';
767 $html .= ' document.getElementById("abj404-migrate-preview-msg").textContent = msgError;';
768 $html .= ' document.getElementById("abj404-migrate-confirm-form").style.display = "none";';
769 $html .= ' document.getElementById("abj404-migrate-back-noform").style.display = "";';
770 $html .= '}';
771 $html .= 'document.addEventListener("DOMContentLoaded", function() {';
772 $html .= ' var previewBtn = document.getElementById("abj404-migrate-preview-btn");';
773 $html .= ' var backBtn = document.getElementById("abj404-migrate-back-btn");';
774 $html .= ' var backBtn2 = document.getElementById("abj404-migrate-back-btn2");';
775 $html .= ' if (previewBtn) {';
776 $html .= ' previewBtn.addEventListener("click", function() {';
777 $html .= ' var select = document.getElementById("abj404-import-source");';
778 $html .= ' var source = select ? select.value : "";';
779 $html .= ' if (!source) return;';
780 $html .= ' var spinner = document.getElementById("abj404-migrate-preview-spinner");';
781 $html .= ' if (spinner) spinner.style.display = "";';
782 $html .= ' previewBtn.disabled = true;';
783 $html .= ' var fd = new FormData();';
784 $html .= ' fd.append("action", "abj404_crossPluginPreview");';
785 $html .= ' fd.append("nonce", nonce);';
786 $html .= ' fd.append("import_source", source);';
787 $html .= ' fetch(ajaxUrl, { method: "POST", body: fd, credentials: "same-origin" })';
788 $html .= ' .then(function(r) { return r.json(); })';
789 $html .= ' .then(function(resp) {';
790 $html .= ' if (spinner) spinner.style.display = "none";';
791 $html .= ' previewBtn.disabled = false;';
792 $html .= ' if (resp && resp.success && resp.data) {';
793 $html .= ' showStep2(parseInt(resp.data.count, 10) || 0, resp.data.source, resp.data.label);';
794 $html .= ' } else { showError(); }';
795 $html .= ' })';
796 $html .= ' .catch(function() {';
797 $html .= ' if (spinner) spinner.style.display = "none";';
798 $html .= ' previewBtn.disabled = false;';
799 $html .= ' showError();';
800 $html .= ' });';
801 $html .= ' });';
802 $html .= ' }';
803 $html .= ' if (backBtn) { backBtn.addEventListener("click", function() { showStep1(); }); }';
804 $html .= ' if (backBtn2) { backBtn2.addEventListener("click", function() { showStep1(); }); }';
805 $html .= '});';
806 $html .= '})();</script>';
807
808 return $html;
809 }
810
811 /**
812 * Build compact diagnostics markup for the Tools page.
813 * This is intentionally read-only and lightweight.
814 *
815 * @return string
816 */
817 private function getToolsDiagnosticsMarkup() {
818 $rows = $this->getToolsDiagnosticsRows();
819 $html = '<div class="abj404-diagnostics-summary">';
820 $html .= '<p>' . esc_html__('Quick environment checks for troubleshooting and support.', '404-solution') . '</p>';
821 $html .= '<table class="widefat striped"><tbody>';
822
823 foreach ($rows as $row) {
824 $label = array_key_exists('label', $row) ? $row['label'] : '';
825 $value = array_key_exists('value', $row) ? $row['value'] : '';
826 $valueHtml = array_key_exists('value_html', $row) ? $row['value_html'] : '';
827 $status = array_key_exists('status', $row) ? $row['status'] : 'info';
828 $statusLabel = ($status === 'ok') ? __('OK', '404-solution') : (($status === 'warn') ? __('Warning', '404-solution') : __('Info', '404-solution'));
829 $statusClass = ($status === 'ok') ? 'abj404-pill-success' : (($status === 'warn') ? 'abj404-pill-warning' : 'abj404-pill-info');
830
831 $html .= '<tr>';
832 $html .= '<td><strong>' . esc_html($label) . '</strong></td>';
833 if ($valueHtml !== '') {
834 $html .= '<td>' . wp_kses_post($valueHtml) . '</td>';
835 } else {
836 $html .= '<td>' . esc_html($value) . '</td>';
837 }
838 $html .= '<td><span class="abj404-status-pill ' . esc_attr($statusClass) . '">' . esc_html($statusLabel) . '</span></td>';
839 $html .= '</tr>';
840 }
841
842 $html .= '</tbody></table>';
843 $html .= '</div>';
844
845 return $html;
846 }
847
848 /**
849 * Collect diagnostics fields displayed on the Tools page.
850 *
851 * @return array<int, array<string, string>>
852 */
853 private function getToolsDiagnosticsRows() {
854 $wpVersion = get_bloginfo('version');
855 if (!is_string($wpVersion) || trim($wpVersion) === '') {
856 $wpVersion = __('Unknown', '404-solution');
857 }
858
859 $uploadDir = '';
860 if (function_exists('abj404_getUploadsDir')) {
861 $uploadDir = (string)abj404_getUploadsDir();
862 }
863 $uploadDirReadable = ($uploadDir !== '' && is_dir($uploadDir));
864 $uploadDirWritable = ($uploadDir !== '' && is_writable($uploadDir));
865
866 $rows = array();
867 $pluginVersion = defined('ABJ404_VERSION') ? ABJ404_VERSION : __('Unknown', '404-solution');
868 $rows[] = array('label' => __('Plugin Version', '404-solution'), 'value' => $pluginVersion, 'status' => 'info');
869 $rows[] = array('label' => __('WordPress Version', '404-solution'), 'value' => $wpVersion, 'status' => 'info');
870 $rows[] = array('label' => __('PHP Version', '404-solution'), 'value' => PHP_VERSION, 'status' => 'info');
871 $rows[] = array(
872 'label' => __('Uploads Directory', '404-solution'),
873 'value' => ($uploadDir !== '') ? $uploadDir : __('Not available', '404-solution'),
874 'status' => $uploadDirReadable ? 'ok' : 'warn',
875 );
876 $rows[] = array(
877 'label' => __('Uploads Writable', '404-solution'),
878 'value' => $uploadDirWritable ? __('Yes', '404-solution') : __('No', '404-solution'),
879 'status' => $uploadDirWritable ? 'ok' : 'warn',
880 );
881 $rows[] = array(
882 'label' => __('mbstring Extension', '404-solution'),
883 'value' => extension_loaded('mbstring') ? __('Loaded', '404-solution') : __('Missing', '404-solution'),
884 'status' => extension_loaded('mbstring') ? 'ok' : 'warn',
885 );
886 $rows[] = array(
887 'label' => __('ZipArchive Support', '404-solution'),
888 'value' => class_exists('ZipArchive') ? __('Available', '404-solution') : __('Missing', '404-solution'),
889 'status' => class_exists('ZipArchive') ? 'ok' : 'warn',
890 );
891 $rows[] = array(
892 'label' => __('WP_DEBUG', '404-solution'),
893 'value' => (defined('WP_DEBUG') && WP_DEBUG) ? __('Enabled', '404-solution') : __('Disabled', '404-solution'),
894 'status' => 'info',
895 );
896 if (function_exists('abj404_is_local_debug_host') && function_exists('abj404_get_simulated_db_latency_ms') &&
897 abj404_is_local_debug_host()) {
898 $latencyMs = absint(abj404_get_simulated_db_latency_ms());
899 $latencyUrls = array(
900 250 => wp_nonce_url(admin_url('options-general.php?page=' . ABJ404_PP . '&subpage=abj404_tools&abj404_set_sim_db_ms=250'), 'abj404_set_sim_db_ms'),
901 500 => wp_nonce_url(admin_url('options-general.php?page=' . ABJ404_PP . '&subpage=abj404_tools&abj404_set_sim_db_ms=500'), 'abj404_set_sim_db_ms'),
902 900 => wp_nonce_url(admin_url('options-general.php?page=' . ABJ404_PP . '&subpage=abj404_tools&abj404_set_sim_db_ms=900'), 'abj404_set_sim_db_ms'),
903 0 => wp_nonce_url(admin_url('options-general.php?page=' . ABJ404_PP . '&subpage=abj404_tools&abj404_set_sim_db_ms=0'), 'abj404_set_sim_db_ms'),
904 );
905 $controls = '<a href="' . esc_url($latencyUrls[250]) . '">' . esc_html(__('250ms', '404-solution')) . '</a>'
906 . ' | <a href="' . esc_url($latencyUrls[500]) . '">' . esc_html(__('500ms', '404-solution')) . '</a>'
907 . ' | <a href="' . esc_url($latencyUrls[900]) . '">' . esc_html(__('900ms', '404-solution')) . '</a>'
908 . ' | <a href="' . esc_url($latencyUrls[0]) . '">' . esc_html(__('Disable', '404-solution')) . '</a>';
909 $rows[] = array(
910 'label' => __('Simulated DB Latency', '404-solution'),
911 'value' => ($latencyMs > 0)
912 ? sprintf(__('ON (%d ms per plugin query)', '404-solution'), $latencyMs)
913 : __('OFF', '404-solution'),
914 'value_html' => '<div>' . esc_html(($latencyMs > 0)
915 ? sprintf(__('ON (%d ms per plugin query)', '404-solution'), $latencyMs)
916 : __('OFF', '404-solution')) . '</div><div>' . $controls . '</div>',
917 'status' => ($latencyMs > 0) ? 'warn' : 'info',
918 );
919 }
920
921 return $rows;
922 }
923
924
925 }
926