PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.6
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.6
1.3.6 1.3.5 1.3.4 1.3.3 1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 All 32 releases
← All changes | includes/class-rest-api.php +378 -7 1.0.4 → 1.3.6 View file →
@@ -44,10 +44,23 @@
44 44 ),
45 45 )
46 46 );
47 47
48 + // Resolved white-label branding. The dashboard refetches this after
49 + // a white-label save so the chrome (sidebar name/logo, footer)
50 + // updates live without a reload. (FBS white-label-onboarding)
48 51 register_rest_route(
49 52 self::NAMESPACE_V1,
53 + '/branding',
54 + array(
55 + 'methods' => 'GET',
56 + 'callback' => array( $this, 'get_branding' ),
57 + 'permission_callback' => array( $this, 'permissions' ),
58 + )
59 + );
60 +
61 + register_rest_route(
62 + self::NAMESPACE_V1,
50 63 '/cache/purge',
51 64 array(
52 65 'methods' => 'POST',
53 66 'callback' => array( $this, 'purge' ),
@@ -64,10 +77,23 @@
64 77 'permission_callback' => array( $this, 'permissions' ),
65 78 )
66 79 );
67 80
81 + // Force a fresh static-rewrite probe. The result is otherwise cached
82 + // for five minutes with nothing to invalidate it, so a user who just
83 + // fixed their nginx config had no way to confirm it. (FBS-84012)
68 84 register_rest_route(
69 85 self::NAMESPACE_V1,
86 + '/cache/recheck-rewrite',
87 + array(
88 + 'methods' => 'POST',
89 + 'callback' => array( $this, 'recheck_rewrite' ),
90 + 'permission_callback' => array( $this, 'permissions' ),
91 + )
92 + );
93 +
94 + register_rest_route(
95 + self::NAMESPACE_V1,
70 96 '/cache/benchmark',
71 97 array(
72 98 'methods' => 'GET',
73 99 'callback' => array( $this, 'benchmark' ),
@@ -76,8 +102,101 @@
76 102 );
77 103
78 104 register_rest_route(
79 105 self::NAMESPACE_V1,
106 + '/cache/benchmark/history',
107 + array(
108 + 'methods' => 'GET',
109 + 'callback' => array( $this, 'benchmark_history' ),
110 + 'permission_callback' => array( $this, 'permissions' ),
111 + )
112 + );
113 +
114 + // Drill-downs behind the four dashboard stat cards. Each is a plain
115 + // GET so the same data reaches the CLI and MCP through
116 + // `wp xspeed cache inventory|size|purge-log`.
117 + register_rest_route(
118 + self::NAMESPACE_V1,
119 + '/cache/inventory',
120 + array(
121 + 'methods' => 'GET',
122 + 'callback' => array( $this, 'cache_inventory' ),
123 + 'permission_callback' => array( $this, 'permissions' ),
124 + 'args' => array(
125 + 'limit' => array(
126 + 'type' => 'integer',
127 + 'default' => 50,
128 + ),
129 + 'offset' => array(
130 + 'type' => 'integer',
131 + 'default' => 0,
132 + ),
133 + 'fresh' => array(
134 + 'type' => 'boolean',
135 + 'default' => false,
136 + ),
137 + ),
138 + )
139 + );
140 +
141 + register_rest_route(
142 + self::NAMESPACE_V1,
143 + '/cache/size',
144 + array(
145 + 'methods' => 'GET',
146 + 'callback' => array( $this, 'cache_size' ),
147 + 'permission_callback' => array( $this, 'permissions' ),
148 + )
149 + );
150 +
151 + register_rest_route(
152 + self::NAMESPACE_V1,
153 + '/cache/purge-log',
154 + array(
155 + 'methods' => 'GET',
156 + 'callback' => array( $this, 'cache_purge_log' ),
157 + 'permission_callback' => array( $this, 'permissions' ),
158 + 'args' => array(
159 + 'limit' => array(
160 + 'type' => 'integer',
161 + 'default' => 25,
162 + ),
163 + ),
164 + )
165 + );
166 +
167 + register_rest_route(
168 + self::NAMESPACE_V1,
169 + '/stats/hit-daily',
170 + array(
171 + 'methods' => 'GET',
172 + 'callback' => array( $this, 'hit_daily' ),
173 + 'permission_callback' => array( $this, 'permissions' ),
174 + )
175 + );
176 +
177 + register_rest_route(
178 + self::NAMESPACE_V1,
179 + '/recommendations',
180 + array(
181 + 'methods' => 'GET',
182 + 'callback' => array( $this, 'recommendations' ),
183 + 'permission_callback' => array( $this, 'permissions' ),
184 + )
185 + );
186 +
187 + register_rest_route(
188 + self::NAMESPACE_V1,
189 + '/recommendations/apply',
190 + array(
191 + 'methods' => 'POST',
192 + 'callback' => array( $this, 'recommendations_apply' ),
193 + 'permission_callback' => array( $this, 'permissions' ),
194 + )
195 + );
196 +
197 + register_rest_route(
198 + self::NAMESPACE_V1,
80 199 '/audit/pro',
81 200 array(
82 201 'methods' => 'GET',
83 202 'callback' => array( $this, 'pro_audit' ),
@@ -86,8 +205,18 @@
86 205 );
87 206
88 207 register_rest_route(
89 208 self::NAMESPACE_V1,
209 + '/activity',
210 + array(
211 + 'methods' => 'GET',
212 + 'callback' => array( $this, 'activity' ),
213 + 'permission_callback' => array( $this, 'permissions' ),
214 + )
215 + );
216 +
217 + register_rest_route(
218 + self::NAMESPACE_V1,
90 219 '/modules',
91 220 array(
92 221 'methods' => 'GET',
93 222 'callback' => array( $this, 'get_modules' ),
@@ -93,11 +222,53 @@
93 222 'callback' => array( $this, 'get_modules' ),
94 223 'permission_callback' => array( $this, 'permissions' ),
95 224 )
96 225 );
226 +
227 + // On-demand desktop-vs-mobile HTML equality probe (FBS-83145). POST so
228 + // it's never triggered by a prefetch/GET; runs only from the dashboard
229 + // "Check now" button behind manage_options.
230 + register_rest_route(
231 + self::NAMESPACE_V1,
232 + '/cache/mobile-probe',
233 + array(
234 + 'methods' => 'POST',
235 + 'callback' => array( $this, 'mobile_probe' ),
236 + 'permission_callback' => array( $this, 'permissions' ),
237 + )
238 + );
239 +
240 + // Dismiss the Separate-Mobile-Cache review prompt (FBS-83145).
241 + register_rest_route(
242 + self::NAMESPACE_V1,
243 + '/cache/mobile-review-dismiss',
244 + array(
245 + 'methods' => 'POST',
246 + 'callback' => array( $this, 'mobile_review_dismiss' ),
247 + 'permission_callback' => array( $this, 'permissions' ),
248 + )
249 + );
97 250 }
98 251
99 252 /**
253 + * Run the on-demand mobile-equality probe and return the fresh /status
254 + * mobile_separate block so the dashboard can update the callout in place.
255 + */
256 + public function mobile_probe( $request ) {
257 + unset( $request );
258 + return rest_ensure_response( Cache::probe_mobile_equality() );
259 + }
260 +
261 + /**
262 + * Clear the migration review flag so the callout stops nagging.
263 + */
264 + public function mobile_review_dismiss( $request ) {
265 + unset( $request );
266 + Cache::clear_mobile_separate_review();
267 + return rest_ensure_response( array( 'dismissed' => true ) );
268 + }
269 +
270 + /**
100 271 * The registered-module descriptors — same payload baked into the
101 272 * admin bootstrap (Admin::modules_payload), re-evaluated live. The
102 273 * dashboard re-fetches this after a license activate/deactivate so a
103 274 * Pro module's custom_panel flips between its real surface and
@@ -118,8 +289,20 @@
118 289 return rest_ensure_response( array( 'suggestions' => Pro_Audit::run() ) );
119 290 }
120 291
121 292 /**
293 + * Recent activity-log entries for the Overview activity strip —
294 + * newest-first (settings changes, purges, cache toggles, …).
295 + *
296 + * @param \WP_REST_Request $request Unused.
297 + * @return \WP_REST_Response
298 + */
299 + public function activity( $request ) {
300 + unset( $request );
301 + return rest_ensure_response( array( 'activity' => Activity_Log::entries() ) );
302 + }
303 +
304 + /**
122 305 * Cache before/after benchmark — fetches home_url() twice (with +
123 306 * without the bypass header) and returns side-by-side timings for
124 307 * the dashboard widget.
125 308 */
@@ -127,8 +310,70 @@
127 310 unset( $request );
128 311 return rest_ensure_response( Cache_Benchmark::run() );
129 312 }
130 313
314 + /**
315 + * Stored benchmark runs (oldest→newest) + the settings-change events
316 + * the trend chart overlays as annotations.
317 + */
318 + public function benchmark_history( $request ) {
319 + $limit = min( 100, max( 1, (int) ( $request['limit'] ?? 100 ) ) );
320 + return rest_ensure_response(
321 + array(
322 + 'runs' => Cache_Benchmark::history( $limit ),
323 + 'changes' => self::settings_change_events(),
324 + )
325 + );
326 + }
327 +
328 + /**
329 + * Daily hit/miss aggregates for the 7/30-day trend, plus change events
330 + * for annotation markers.
331 + */
332 + public function hit_daily( $request ) {
333 + $days = min( Hit_Counter::DAILY_MAX_DAYS, max( 1, (int) ( $request['days'] ?? 30 ) ) );
334 + return rest_ensure_response(
335 + array(
336 + 'days' => Hit_Counter::daily_series( $days ),
337 + 'changes' => self::settings_change_events(),
338 + )
339 + );
340 + }
341 +
342 + /** Ranked "next best action" recommendations (issue #48). */
343 + public function recommendations( $request ) {
344 + unset( $request );
345 + return rest_ensure_response( array( 'recommendations' => Recommendations::all() ) );
346 + }
347 +
348 + /** One-click apply of a recommendation's settings fix. */
349 + public function recommendations_apply( $request ) {
350 + $id = sanitize_key( (string) ( $request['id'] ?? '' ) );
351 + if ( '' === $id ) {
352 + return new \WP_Error( 'xspeed_rec_missing_id', __( 'The id argument is required.', 'xspeed' ), array( 'status' => 400 ) );
353 + }
354 + $result = Recommendations::apply( $id );
355 + return is_wp_error( $result ) ? $result : rest_ensure_response( $result );
356 + }
357 +
358 + /**
359 + * Recent settings_changed activity entries (the chart annotations).
360 + *
361 + * @return array<int,array{ts:int,message:string}>
362 + */
363 + private static function settings_change_events(): array {
364 + $out = array();
365 + foreach ( Activity_Log::entries() as $entry ) {
366 + if ( 'settings_changed' === ( $entry['type'] ?? '' ) ) {
367 + $out[] = array(
368 + 'ts' => (int) $entry['ts'],
369 + 'message' => (string) $entry['message'],
370 + );
371 + }
372 + }
373 + return $out;
374 + }
375 +
131 376 public function permissions() {
132 377 return current_user_can( 'manage_options' );
133 378 }
134 379
@@ -143,14 +388,26 @@
143 388 // omitted here, the snippet would only ever update on reload (the
144 389 // QA bug: "Server config snippet requires full page reload to
145 390 // reflect toggle changes"). Keep this in sync with Admin.
146 391 $server_type = Server::type();
147 - $rewrite_capable = ( $server_type === Server::NGINX || $server_type === Server::APACHE || $server_type === Server::LITESPEED );
392 + // LiteSpeed deliberately serves hits via the PHP drop-in (its
393 + // .htaccess can't add the HIT header or log a static hit), so the
394 + // static-rewrite probe is N/A there — surfacing it would pop the
395 + // "PHP fallback" nag for a setup that's working as designed. Only
396 + // nginx + Apache use a server-level rewrite worth probing.
397 + $rewrite_capable = ( $server_type === Server::NGINX || $server_type === Server::APACHE );
148 398 $rewrite_probe = null;
149 399 if ( $opts['cache_enabled'] && $rewrite_capable ) {
150 400 $probe = Cache::probe_static_rewrite();
151 401 $rewrite_probe = array(
152 402 'active' => (bool) ( $probe['active'] ?? false ),
403 + // Both flags were previously dropped here, so the dashboard
404 + // could not tell "proven inactive" from "no result yet" or
405 + // "probe failed" — and rendered the configure-your-server
406 + // banner for all three. (FBS-84012)
407 + 'pending' => (bool) ( $probe['pending'] ?? false ),
408 + 'inconclusive' => (bool) ( $probe['inconclusive'] ?? false ),
409 + 'reason' => (string) ( $probe['reason'] ?? '' ),
153 410 'server_type' => $server_type,
154 411 'snippet' => Cache::nginx_snippet(),
155 412 'topology' => Server::rewrite_topology(),
156 413 'behind_proxy' => Server::is_behind_proxy(),
@@ -168,8 +425,31 @@
168 425 'nginx_snippet' => Gzip::nginx_snippet(),
169 426 ),
170 427 'rewrite_probe' => $rewrite_probe,
171 428 'nginx_server_block' => Cache::full_nginx_server_block(),
429 + // What enabling the page cache would do to
430 + // wp-content/advanced-cache.php. The dashboard discloses the
431 + // replacement BEFORE the write when a leftover drop-in is
432 + // already there; see Page_Cache_Detector::dropin_disclosure().
433 + 'dropin' => Page_Cache_Detector::dropin_disclosure(),
434 + // Separate Mobile Cache visibility (FBS-83145). `blocking` is
435 + // true when mobile_separate is what's keeping the device-blind
436 + // static fast path from installing on a rewrite-capable server;
437 + // `needs_review` is true when a migration turned it on for us and
438 + // the user hasn't confirmed they actually need it. The dashboard
439 + // renders a callout (+ "Check now" equality probe) from these.
440 + 'mobile_separate' => array(
441 + 'enabled' => ! empty( Settings::get()['cache_enabled'] ) ? (bool) ( Settings_Manager::get( 'cache' )['mobile_separate'] ?? false ) : false,
442 + // Gated to servers that HAVE a static fast path — see the
443 + // matching comment in Admin::bootstrap_payload(): on IIS /
444 + // unknown, block_reason still falls through to
445 + // mobile_separate and reporting it as "blocking" would nag
446 + // about a rewrite that does not exist there (#108).
447 + // LiteSpeed joined the capable set with the opt-in (#509).
448 + 'blocking' => ( $rewrite_capable || Server::LITESPEED === $server_type )
449 + && 'mobile_separate' === Cache::static_rewrite_block_reason(),
450 + 'needs_review' => Cache::mobile_separate_needs_review(),
451 + ),
172 452 )
173 453 );
174 454 }
175 455
@@ -191,12 +471,89 @@
191 471 return rest_ensure_response( $updated );
192 472 }
193 473
194 474 public function purge() {
195 - Cache::purge_all();
196 - return rest_ensure_response( array( 'stats' => Cache::get_stats() ) );
475 + // The same core function `wp xspeed purge` runs, so the dashboard
476 + // button and the CLI cannot clear different sets of stores — and the
477 + // per-store report is available here for the UI to surface a store
478 + // that was skipped or refused rather than flashing "cache cleared".
479 + $report = Purge_Runner::run( array( 'all' ), __( 'dashboard', 'xspeed' ) );
480 + return rest_ensure_response(
481 + array(
482 + 'stats' => Cache::get_stats(),
483 + 'report' => $report,
484 + )
485 + );
197 486 }
198 487
488 + /**
489 + * The "Cached Pages" drill-down: which pages are cached and how old they
490 + * are. Paginated because a busy site's cache is thousands of entries and
491 + * the answer to "is my cache working" doesn't need all of them at once.
492 + */
493 + public function cache_inventory( \WP_REST_Request $request ) {
494 + return rest_ensure_response(
495 + Cache_Inventory::entries(
496 + (int) $request->get_param( 'limit' ),
497 + (int) $request->get_param( 'offset' ),
498 + (bool) $request->get_param( 'fresh' )
499 + )
500 + );
501 + }
502 +
503 + /** The "Cache Size" drill-down: where the bytes actually go. */
504 + public function cache_size() {
505 + return rest_ensure_response( Cache_Inventory::size_breakdown() );
506 + }
507 +
508 + /** The "Last Purge" drill-down: what cleared the cache, when, and why. */
509 + public function cache_purge_log( \WP_REST_Request $request ) {
510 + return rest_ensure_response( Cache_Inventory::purge_log( (int) $request->get_param( 'limit' ) ) );
511 + }
512 +
513 + /**
514 + * Resolved branding ({name, footer_credit, hide_help_links, logo_svg}).
515 + * Runs the `xspeed_branding` filter so Pro's white-label override is
516 + * reflected. Consumed by the dashboard's post-save branding refresh.
517 + */
518 + public function get_branding() {
519 + return rest_ensure_response( Admin::branding() );
520 + }
521 +
522 + /**
523 + * Re-run the static-rewrite probe, bypassing the cached result.
524 + *
525 + * Returns the same shape the dashboard bootstrap uses, so the caller can
526 + * swap it straight into state without a second round trip. (FBS-84012)
527 + */
528 + public function recheck_rewrite() {
529 + $raw = Cache::recheck_static_rewrite();
530 + $server_type = Server::detect();
531 +
532 + // Qualify the raw probe against known config refusals. The probe
533 + // fetches its own file from the static tree, which succeeds even when
534 + // no real page is served that way — so an unqualified `active` told
535 + // clients the static path was engaged on sites where it demonstrably
536 + // wasn't. `block_reason` is exposed so a client can act on the
537 + // specific cause rather than re-deriving it. See
538 + // Cache::qualify_rewrite_probe().
539 + $probe = Cache::qualify_rewrite_probe( $raw );
540 +
541 + return rest_ensure_response(
542 + array(
543 + 'active' => $probe['active'],
544 + 'pending' => (bool) ( $raw['pending'] ?? false ),
545 + 'inconclusive' => $probe['inconclusive'],
546 + 'reason' => $probe['reason'],
547 + 'block_reason' => $probe['block_reason'],
548 + 'server_type' => $server_type,
549 + 'snippet' => Cache::nginx_snippet(),
550 + 'topology' => Server::rewrite_topology(),
551 + 'behind_proxy' => Server::is_behind_proxy(),
552 + )
553 + );
554 + }
555 +
199 556 public function toggle_cache( \WP_REST_Request $request ) {
200 557 $params = $request->get_json_params();
201 558 $enabled = isset( $params['enabled'] ) ? (bool) $params['enabled'] : false;
202 559
@@ -204,15 +561,29 @@
204 561 // permission_callback above already enforced current_user_can(
205 562 // 'manage_options' ); the REST nonce is verified by core via the
206 563 // X-WP-Nonce header.
207 564 $state = Cache::toggle( $enabled );
208 - $updated = Settings::update( array( 'cache_enabled' => $state['enabled'] ) );
565 + $updated = Settings::get();
209 566
567 + // Recompute the unified nginx block AFTER cache_enabled is persisted.
568 + // Cache::toggle() computes it inline, but cache_enabled isn't written
569 + // until the Settings::update() above — so the block inside $state
570 + // reflects the PRE-toggle state (CacheModule::nginx_directives() gates
571 + // on cache_enabled). Regenerate here so the dashboard's optimistic
572 + // update shows the snippet for the state the user just selected.
573 + $state['nginx_server_block'] = Cache::full_nginx_server_block();
574 +
210 575 return rest_ensure_response(
211 576 array(
212 - 'enabled' => $updated['cache_enabled'],
213 - 'stats' => Cache::get_stats(),
214 - 'install_state' => $state,
577 + 'enabled' => $updated['cache_enabled'],
578 + // Surfaced at the top level so the dashboard can explain a
579 + // refusal rather than silently snapping the toggle back:
580 + // Cache::toggle() writes nothing when another plugin owns the
581 + // drop-in or WP_CACHE is in a shape we must not rewrite.
582 + 'blocked' => ! empty( $state['blocked'] ),
583 + 'blocked_reason' => $state['blocked_reason'] ?? null,
584 + 'stats' => Cache::get_stats(),
585 + 'install_state' => $state,
215 586 )
216 587 );
217 588 }
218 589 }