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 / ajax / AdminTableResponseParts.php

AdminTableResponseParts.php in 404 Solution trunk, at includes/ajax/AdminTableResponseParts.php

325 lines 14.4 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 * Builds the body of an admin table AJAX response: the table HTML, the status
9 * tab counts, the pagination links, and the data signature the browser compares
10 * against to decide whether anything changed.
11 *
12 * Two registries live here, which is why this is a module rather than a
13 * paragraph of the endpoint. The requested `part` selects a builder, and within
14 * the table builder the subpage selects a renderer -- a dispatch with real logic
15 * in each branch, and one that grows every time a tab or a part is added. The
16 * endpoint that hosted it was not the thing changing when a new tab appeared.
17 *
18 * Nothing here touches the request boundary. There is no $_POST read, no nonce
19 * check, no rate limit and no response emission: the inputs are a normalized
20 * subpage, the view, the view-read service, and the diagnostics context. That is
21 * what lets the same assembly run from anywhere the endpoint is not -- a preview,
22 * a future CLI dump -- and it is what makes the part builders directly testable
23 * without fabricating an authorized admin request.
24 *
25 * ABJ_404_Solution_Ajax_GetPaginationLinks owns serving the endpoint;
26 * ABJ_404_Solution_View and the view-read service own producing the data. This
27 * class owns only the shape of the response body and the query budget that
28 * shape is read under.
29 */
30 class ABJ_404_Solution_AdminTableResponseParts {
31
32 /**
33 * Per-query time budget for a foreground table build. The browser is
34 * waiting on this, so a query that cannot finish inside the budget must
35 * fail rather than hold the worker.
36 */
37 private const QUERY_TIMEOUT_SECONDS = 20;
38
39 /**
40 * Per-query budget for the background "did anything change?" poll. Tighter
41 * than the foreground one on purpose: nobody is waiting on it, and a slow
42 * poll competing for a worker slot is worse than a poll that gives up.
43 */
44 private const DETECT_ONLY_QUERY_TIMEOUT_SECONDS = 10;
45
46 /**
47 * Which subpage renders which table, and under which trace stage.
48 *
49 * A constant rather than a local, because the set is also the answer to
50 * "is there a table here at all" -- asked by
51 * ABJ_404_Solution_MeasuredTableResponseSize before it builds one to
52 * measure. Two copies of this list would let a new tab be renderable and
53 * unmeasurable, or measurable as the error sentinel's byte count.
54 */
55 private const TABLE_RENDERERS = array(
56 'abj404_redirects' => array('stage' => 'table_redirects', 'method' => 'getAdminRedirectsPageTable'),
57 'abj404_captured' => array('stage' => 'table_captured', 'method' => 'getCapturedURLSPageTable'),
58 'abj404_logs' => array('stage' => 'table_logs', 'method' => 'getAdminLogsPageTable'),
59 );
60
61 /**
62 * Whether this subpage has a table renderer at all.
63 *
64 * Callers that intend to BUILD a table part ask first: buildTablePart()
65 * answers an unknown subpage with an error sentinel, and a caller that
66 * measures the response would otherwise measure that sentinel.
67 */
68 public static function rendersTablePart(string $subpage): bool {
69 return isset(self::TABLE_RENDERERS[$subpage]);
70 }
71
72 /** @return array<string, int> */
73 public static function queryBudgetOptions(bool $detectOnly = false): array {
74 return self::queryBudgetOptionsForSeconds($detectOnly
75 ? self::DETECT_ONLY_QUERY_TIMEOUT_SECONDS
76 : self::QUERY_TIMEOUT_SECONDS);
77 }
78
79 /**
80 * The same budget under a caller-supplied ceiling.
81 *
82 * The two constants above are the budgets for the two callers that were
83 * here first; they are not properties of the query. A caller whose own
84 * client deadline is SHORTER than the foreground budget (the canary
85 * ladder's measurement step gives up at 15 seconds) would otherwise leave
86 * a query holding a worker for seconds after the only party waiting on it
87 * has gone, which is the opposite of what the foreground budget is for.
88 *
89 * @return array<string, int>
90 */
91 public static function queryBudgetOptionsForSeconds(int $timeoutSeconds): array {
92 return array(
93 '_abj404_query_timeout' => max(1, $timeoutSeconds),
94 // This endpoint owns a structured, admin-only failure envelope.
95 // Let view-query failures reach it instead of converting them to
96 // an HTTP 200 empty/pending table that discards index diagnostics.
97 '_abj404_throw_on_view_query_error' => 1,
98 );
99 }
100
101 /**
102 * Whether a subpage supports the cheap detect-only signature path. The
103 * redirects and captured tabs both read through getRedirectsForView, so the
104 * View can compute their signature from a single one-page read. The logs tab
105 * reads a different source and keeps the full path.
106 */
107 public static function canComputeCheapSignature(string $subpage): bool {
108 return $subpage === 'abj404_redirects' || $subpage === 'abj404_captured';
109 }
110
111 /**
112 * Whether the browser's signature is stale against the one just computed.
113 *
114 * Either side being empty means "cannot tell", which is reported as no
115 * update rather than as a change: a spurious change forces a full table
116 * fetch on every poll of a site whose signature could not be computed.
117 */
118 public static function hasSignatureUpdate(string $currentSignature, string $tableSignature): bool {
119 if ($currentSignature === '' || $tableSignature === '') {
120 return false;
121 }
122 if (function_exists('hash_equals')) {
123 return !hash_equals($currentSignature, $tableSignature);
124 }
125 return $currentSignature !== $tableSignature;
126 }
127
128 /**
129 * The requested part, or every part when 'all' was asked for.
130 *
131 * @param array{part: string, subpage: string, view: ABJ_404_Solution_View,
132 * viewReadService: ABJ_404_Solution_ViewReadServiceInterface,
133 * queryTimeoutSeconds?: int} $request One keyed bag rather than four
134 * positional arguments. It carried two adjacent strings (the part and the
135 * subpage) and two adjacent objects (the view and the view-read service),
136 * and both pairs transpose without a type error: a swapped part/subpage
137 * builds nothing and returns an empty response the client renders as an
138 * empty table, and a swapped view pair reaches a method_exists() guard
139 * that answers false and silently drops the sort-readiness metadata.
140 * `queryTimeoutSeconds` defaults to the foreground budget; pass it when
141 * the caller's own deadline is shorter.
142 * @param array<string, mixed> $context
143 * @return array<string, mixed>
144 */
145 public static function build(array $request, array &$context): array {
146 $builders = array(
147 'table' => 'buildTablePart',
148 'counts' => 'buildCountsPart',
149 'pagination' => 'buildPaginationPart',
150 );
151 $parts = $request['part'] === 'all' ? array_keys($builders) : array($request['part']);
152 $data = array();
153 foreach ($parts as $part) {
154 $method = $builders[$part] ?? '';
155 if ($method === '') {
156 continue;
157 }
158 $data = array_merge($data, self::$method($request, $context));
159 }
160 return $data;
161 }
162
163 /**
164 * The query budget this request runs under.
165 *
166 * @param array{part: string, subpage: string, view: ABJ_404_Solution_View,
167 * viewReadService: ABJ_404_Solution_ViewReadServiceInterface,
168 * queryTimeoutSeconds?: int} $request
169 * @return array<string, int>
170 */
171 private static function budgetFor(array $request): array {
172 return isset($request['queryTimeoutSeconds'])
173 ? self::queryBudgetOptionsForSeconds((int)$request['queryTimeoutSeconds'])
174 : self::queryBudgetOptions();
175 }
176
177 /**
178 * The table HTML for one subpage, plus the signature of the data it was
179 * rendered from.
180 *
181 * @param array{part: string, subpage: string, view: ABJ_404_Solution_View,
182 * viewReadService: ABJ_404_Solution_ViewReadServiceInterface,
183 * queryTimeoutSeconds?: int} $request
184 * @param array<string, mixed> $context
185 * @return array<string, mixed>
186 */
187 private static function buildTablePart(array $request, array &$context): array {
188 $subpage = (string)$request['subpage'];
189 $view = $request['view'];
190 $viewReadService = $request['viewReadService'];
191 $budget = self::budgetFor($request);
192 if (!isset(self::TABLE_RENDERERS[$subpage])) {
193 return array('table' => 'Error: Unexpected subpage requested.');
194 }
195 $renderer = self::TABLE_RENDERERS[$subpage];
196 $method = $renderer['method'];
197 return ABJ_404_Solution_AjaxStageDiagnostics::runStage(
198 $context,
199 $renderer['stage'],
200 static function () use ($subpage, $view, $viewReadService, $method, $budget, &$context) {
201 if (($subpage === 'abj404_redirects' || $subpage === 'abj404_captured')
202 && is_object($viewReadService)
203 && method_exists($viewReadService, 'sortReadinessStatusForOrderby')) {
204 $orderby = is_scalar($context['orderby'] ?? null) ? (string)$context['orderby'] : '';
205 ABJ_404_Solution_AjaxStageDiagnostics::addStageMetadata(array(
206 'sort_readiness' => $viewReadService->sortReadinessStatusForOrderby($orderby),
207 ));
208 }
209 return array(
210 'table' => $view->$method($subpage, $budget),
211 'tableSignature' => self::currentTableSignature($view, $subpage),
212 );
213 }
214 );
215 }
216
217 /**
218 * The status tab counts for one subpage. An incomplete read reports
219 * countsIncomplete rather than zeros: a zero count is a finding and a
220 * missing count is not, and the tabs must not claim an empty site.
221 *
222 * @param array{part: string, subpage: string, view: ABJ_404_Solution_View,
223 * viewReadService: ABJ_404_Solution_ViewReadServiceInterface,
224 * queryTimeoutSeconds?: int} $request
225 * @param array<string, mixed> $context
226 * @return array<string, mixed>
227 */
228 private static function buildCountsPart(array $request, array &$context): array {
229 $subpage = (string)$request['subpage'];
230 $viewReadService = $request['viewReadService'];
231 $queryOptions = self::budgetFor($request);
232 if ($subpage === 'abj404_redirects') {
233 $counts = ABJ_404_Solution_AjaxStageDiagnostics::runStage(
234 $context,
235 'redirect_status_counts',
236 static function () use ($viewReadService, $queryOptions) {
237 $result = $viewReadService->getRedirectStatusCounts(false, $queryOptions);
238 ABJ_404_Solution_AjaxStageDiagnostics::addStageMetadata(array(
239 'cache' => !empty($result['_incomplete']) ? 'miss' : 'hit',
240 ));
241 return $result;
242 }
243 );
244 if (!empty($counts['_incomplete'])) {
245 return array('countsIncomplete' => true);
246 }
247 return array('tabCounts' => array(
248 '0' => $counts['all'] ?? 0,
249 (string)ABJ404_STATUS_MANUAL => $counts['manual'] ?? 0,
250 (string)ABJ404_STATUS_AUTO => $counts['auto'] ?? 0,
251 (string)ABJ404_TRASH_FILTER => $counts['trash'] ?? 0,
252 ));
253 }
254 if ($subpage !== 'abj404_captured') {
255 return array();
256 }
257 $counts = ABJ_404_Solution_AjaxStageDiagnostics::runStage(
258 $context,
259 'captured_status_counts',
260 static function () use ($viewReadService, $queryOptions) {
261 $result = $viewReadService->getCapturedStatusCounts(false, $queryOptions);
262 ABJ_404_Solution_AjaxStageDiagnostics::addStageMetadata(array(
263 'cache' => !empty($result['_incomplete']) ? 'miss' : 'hit',
264 ));
265 return $result;
266 }
267 );
268 if (!empty($counts['_incomplete'])) {
269 return array('countsIncomplete' => true);
270 }
271 return array(
272 'statusCounts' => $counts,
273 'tabCounts' => array(
274 '0' => $counts['all'] ?? 0,
275 (string)ABJ404_STATUS_CAPTURED => $counts['captured'] ?? 0,
276 (string)ABJ404_STATUS_IGNORED => $counts['ignored'] ?? 0,
277 (string)ABJ404_STATUS_LATER => $counts['later'] ?? 0,
278 (string)ABJ404_TRASH_FILTER => $counts['trash'] ?? 0,
279 (string)ABJ404_HANDLED_FILTER => ($counts['ignored'] ?? 0) + ($counts['later'] ?? 0) + ($counts['trash'] ?? 0),
280 ),
281 );
282 }
283
284 /**
285 * The pagination strip, for both slots.
286 *
287 * The response carries two keys because the page has two strips, above
288 * and below the table. It does NOT carry two renders: the renderer takes
289 * no top/bottom argument and reads the same row count either way, so a
290 * per-slot render can only reproduce the first one -- at the price of a
291 * second count query and a second read of paginationLinks.html on every
292 * admin table request, on every tab, for every user.
293 *
294 * @param array{part: string, subpage: string, view: ABJ_404_Solution_View,
295 * viewReadService: ABJ_404_Solution_ViewReadServiceInterface,
296 * queryTimeoutSeconds?: int} $request
297 * @param array<string, mixed> $context
298 * @return array<string, string>
299 */
300 private static function buildPaginationPart(array $request, array &$context): array {
301 $subpage = (string)$request['subpage'];
302 $view = $request['view'];
303 $queryOptions = self::budgetFor($request);
304 $links = ABJ_404_Solution_AjaxStageDiagnostics::runStage(
305 $context,
306 'paginationLinks',
307 static fn() => $view->getPaginationLinks($subpage, $queryOptions)
308 );
309 return array(
310 'paginationLinksTop' => $links,
311 'paginationLinksBottom' => $links,
312 );
313 }
314
315 /**
316 * @param ABJ_404_Solution_View $view
317 */
318 private static function currentTableSignature($view, string $subpage): string {
319 if (is_object($view) && method_exists($view, 'getCurrentTableDataSignature')) {
320 return (string)$view->getCurrentTableDataSignature($subpage);
321 }
322 return '';
323 }
324 }
325