PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.4.0
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.4.0
1.4.1 1.4.0 1.3.7 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 All 35 releases
← All changes | includes/class-rest-api.php +498 -10 1.0.2 → 1.4.0 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,42 @@
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 + // The server-side mirror of "I pasted the block". Only meaningful on a
95 + // host where the probe cannot read the rules back — see
96 + // nginx_copied_hash().
97 + register_rest_route(
98 + self::NAMESPACE_V1,
99 + '/cache/nginx-copied-hash',
100 + array(
101 + 'methods' => 'POST',
102 + 'callback' => array( $this, 'nginx_copied_hash' ),
103 + 'permission_callback' => array( $this, 'permissions' ),
104 + 'args' => array(
105 + 'hash' => array(
106 + 'type' => 'string',
107 + 'required' => true,
108 + ),
109 + ),
110 + )
111 + );
112 +
113 + register_rest_route(
114 + self::NAMESPACE_V1,
70 115 '/cache/benchmark',
71 116 array(
72 117 'methods' => 'GET',
73 118 'callback' => array( $this, 'benchmark' ),
@@ -76,8 +121,110 @@
76 121 );
77 122
78 123 register_rest_route(
79 124 self::NAMESPACE_V1,
125 + '/cache/benchmark/history',
126 + array(
127 + 'methods' => 'GET',
128 + 'callback' => array( $this, 'benchmark_history' ),
129 + 'permission_callback' => array( $this, 'permissions' ),
130 + )
131 + );
132 +
133 + // Drill-downs behind the four dashboard stat cards. Each is a plain
134 + // GET so the same data reaches the CLI and MCP through
135 + // `wp xspeed cache inventory|size|purge-log`.
136 + register_rest_route(
137 + self::NAMESPACE_V1,
138 + '/cache/inventory',
139 + array(
140 + 'methods' => 'GET',
141 + 'callback' => array( $this, 'cache_inventory' ),
142 + 'permission_callback' => array( $this, 'permissions' ),
143 + 'args' => array(
144 + 'limit' => array(
145 + 'type' => 'integer',
146 + 'default' => 50,
147 + ),
148 + 'offset' => array(
149 + 'type' => 'integer',
150 + 'default' => 0,
151 + ),
152 + 'fresh' => array(
153 + 'type' => 'boolean',
154 + 'default' => false,
155 + ),
156 + ),
157 + )
158 + );
159 +
160 + register_rest_route(
161 + self::NAMESPACE_V1,
162 + '/cache/size',
163 + array(
164 + 'methods' => 'GET',
165 + 'callback' => array( $this, 'cache_size' ),
166 + 'permission_callback' => array( $this, 'permissions' ),
167 + )
168 + );
169 +
170 + register_rest_route(
171 + self::NAMESPACE_V1,
172 + '/cache/purge-log',
173 + array(
174 + 'methods' => 'GET',
175 + 'callback' => array( $this, 'cache_purge_log' ),
176 + 'permission_callback' => array( $this, 'permissions' ),
177 + 'args' => array(
178 + 'limit' => array(
179 + 'type' => 'integer',
180 + 'default' => 25,
181 + ),
182 + ),
183 + )
184 + );
185 +
186 + register_rest_route(
187 + self::NAMESPACE_V1,
188 + '/stats/hit-daily',
189 + array(
190 + 'methods' => 'GET',
191 + 'callback' => array( $this, 'hit_daily' ),
192 + 'permission_callback' => array( $this, 'permissions' ),
193 + )
194 + );
195 +
196 + register_rest_route(
197 + self::NAMESPACE_V1,
198 + '/recommendations',
199 + array(
200 + 'methods' => 'GET',
201 + 'callback' => array( $this, 'recommendations' ),
202 + 'permission_callback' => array( $this, 'permissions' ),
203 + 'args' => array(
204 + // `contributed` adds the entries other plugins hand in
205 + // through `xspeed_recommendations`. Only the Overview
206 + // card asks for them; see all_with_contributed().
207 + 'include' => array(
208 + 'type' => 'string',
209 + 'enum' => array( '', 'contributed' ),
210 + ),
211 + ),
212 + )
213 + );
214 +
215 + register_rest_route(
216 + self::NAMESPACE_V1,
217 + '/recommendations/apply',
218 + array(
219 + 'methods' => 'POST',
220 + 'callback' => array( $this, 'recommendations_apply' ),
221 + 'permission_callback' => array( $this, 'permissions' ),
222 + )
223 + );
224 +
225 + register_rest_route(
226 + self::NAMESPACE_V1,
80 227 '/audit/pro',
81 228 array(
82 229 'methods' => 'GET',
83 230 'callback' => array( $this, 'pro_audit' ),
@@ -83,11 +230,85 @@
83 230 'callback' => array( $this, 'pro_audit' ),
84 231 'permission_callback' => array( $this, 'permissions' ),
85 232 )
86 233 );
234 +
235 + register_rest_route(
236 + self::NAMESPACE_V1,
237 + '/activity',
238 + array(
239 + 'methods' => 'GET',
240 + 'callback' => array( $this, 'activity' ),
241 + 'permission_callback' => array( $this, 'permissions' ),
242 + )
243 + );
244 +
245 + register_rest_route(
246 + self::NAMESPACE_V1,
247 + '/modules',
248 + array(
249 + 'methods' => 'GET',
250 + 'callback' => array( $this, 'get_modules' ),
251 + 'permission_callback' => array( $this, 'permissions' ),
252 + )
253 + );
254 +
255 + // On-demand desktop-vs-mobile HTML equality probe (FBS-83145). POST so
256 + // it's never triggered by a prefetch/GET; runs only from the dashboard
257 + // "Check now" button behind manage_options.
258 + register_rest_route(
259 + self::NAMESPACE_V1,
260 + '/cache/mobile-probe',
261 + array(
262 + 'methods' => 'POST',
263 + 'callback' => array( $this, 'mobile_probe' ),
264 + 'permission_callback' => array( $this, 'permissions' ),
265 + )
266 + );
267 +
268 + // Dismiss the Separate-Mobile-Cache review prompt (FBS-83145).
269 + register_rest_route(
270 + self::NAMESPACE_V1,
271 + '/cache/mobile-review-dismiss',
272 + array(
273 + 'methods' => 'POST',
274 + 'callback' => array( $this, 'mobile_review_dismiss' ),
275 + 'permission_callback' => array( $this, 'permissions' ),
276 + )
277 + );
87 278 }
88 279
89 280 /**
281 + * Run the on-demand mobile-equality probe and return the fresh /status
282 + * mobile_separate block so the dashboard can update the callout in place.
283 + */
284 + public function mobile_probe( $request ) {
285 + unset( $request );
286 + return rest_ensure_response( Cache::probe_mobile_equality() );
287 + }
288 +
289 + /**
290 + * Clear the migration review flag so the callout stops nagging.
291 + */
292 + public function mobile_review_dismiss( $request ) {
293 + unset( $request );
294 + Cache::clear_mobile_separate_review();
295 + return rest_ensure_response( array( 'dismissed' => true ) );
296 + }
297 +
298 + /**
299 + * The registered-module descriptors — same payload baked into the
300 + * admin bootstrap (Admin::modules_payload), re-evaluated live. The
301 + * dashboard re-fetches this after a license activate/deactivate so a
302 + * Pro module's custom_panel flips between its real surface and
303 + * LicenseLockedPanel (decided server-side via the
304 + * xspeed_module_descriptor filter) WITHOUT a full page reload.
305 + */
306 + public function get_modules() {
307 + return rest_ensure_response( Admin::modules_payload() );
308 + }
309 +
310 + /**
90 311 * Run the Pro audit — scans current settings + cache stats,
91 312 * returns a personalized list of Pro features that would help
92 313 * THIS site. See Pro_Audit::run() for the rule set.
93 314 */
@@ -96,8 +317,20 @@
96 317 return rest_ensure_response( array( 'suggestions' => Pro_Audit::run() ) );
97 318 }
98 319
99 320 /**
321 + * Recent activity-log entries for the Overview activity strip —
322 + * newest-first (settings changes, purges, cache toggles, …).
323 + *
324 + * @param \WP_REST_Request $request Unused.
325 + * @return \WP_REST_Response
326 + */
327 + public function activity( $request ) {
328 + unset( $request );
329 + return rest_ensure_response( array( 'activity' => Activity_Log::entries() ) );
330 + }
331 +
332 + /**
100 333 * Cache before/after benchmark — fetches home_url() twice (with +
101 334 * without the bypass header) and returns side-by-side timings for
102 335 * the dashboard widget.
103 336 */
@@ -105,8 +338,72 @@
105 338 unset( $request );
106 339 return rest_ensure_response( Cache_Benchmark::run() );
107 340 }
108 341
342 + /**
343 + * Stored benchmark runs (oldest→newest) + the settings-change events
344 + * the trend chart overlays as annotations.
345 + */
346 + public function benchmark_history( $request ) {
347 + $limit = min( 100, max( 1, (int) ( $request['limit'] ?? 100 ) ) );
348 + return rest_ensure_response(
349 + array(
350 + 'runs' => Cache_Benchmark::history( $limit ),
351 + 'changes' => self::settings_change_events(),
352 + )
353 + );
354 + }
355 +
356 + /**
357 + * Daily hit/miss aggregates for the 7/30-day trend, plus change events
358 + * for annotation markers.
359 + */
360 + public function hit_daily( $request ) {
361 + $days = min( Hit_Counter::DAILY_MAX_DAYS, max( 1, (int) ( $request['days'] ?? 30 ) ) );
362 + return rest_ensure_response(
363 + array(
364 + 'days' => Hit_Counter::daily_series( $days ),
365 + 'changes' => self::settings_change_events(),
366 + )
367 + );
368 + }
369 +
370 + /** Ranked "next best action" recommendations (issue #48). */
371 + public function recommendations( $request ) {
372 + $recs = 'contributed' === (string) $request->get_param( 'include' )
373 + ? Recommendations::all_with_contributed()
374 + : Recommendations::all();
375 + return rest_ensure_response( array( 'recommendations' => $recs ) );
376 + }
377 +
378 + /** One-click apply of a recommendation's settings fix. */
379 + public function recommendations_apply( $request ) {
380 + $id = sanitize_key( (string) ( $request['id'] ?? '' ) );
381 + if ( '' === $id ) {
382 + return new \WP_Error( 'xspeed_rec_missing_id', __( 'The id argument is required.', 'xspeed' ), array( 'status' => 400 ) );
383 + }
384 + $result = Recommendations::apply( $id );
385 + return is_wp_error( $result ) ? $result : rest_ensure_response( $result );
386 + }
387 +
388 + /**
389 + * Recent settings_changed activity entries (the chart annotations).
390 + *
391 + * @return array<int,array{ts:int,message:string}>
392 + */
393 + private static function settings_change_events(): array {
394 + $out = array();
395 + foreach ( Activity_Log::entries() as $entry ) {
396 + if ( 'settings_changed' === ( $entry['type'] ?? '' ) ) {
397 + $out[] = array(
398 + 'ts' => (int) $entry['ts'],
399 + 'message' => (string) $entry['message'],
400 + );
401 + }
402 + }
403 + return $out;
404 + }
405 +
109 406 public function permissions() {
110 407 return current_user_can( 'manage_options' );
111 408 }
112 409
@@ -112,18 +409,83 @@
112 409
113 410 public function get_status() {
114 411 $opts = Settings::get();
115 412 $stats = Cache::get_stats();
413 +
414 + // rewrite_probe + nginx_server_block mirror the admin bootstrap
415 + // payload (Admin::bootstrap_data). The dashboard re-fetches /status
416 + // after every module save to refresh the consolidated nginx
417 + // server-block snippet without a full page reload — if these were
418 + // omitted here, the snippet would only ever update on reload (the
419 + // QA bug: "Server config snippet requires full page reload to
420 + // reflect toggle changes"). Keep this in sync with Admin.
421 + $server_type = Server::type();
422 + // LiteSpeed deliberately serves hits via the PHP drop-in (its
423 + // .htaccess can't add the HIT header or log a static hit), so the
424 + // static-rewrite probe is N/A there — surfacing it would pop the
425 + // "PHP fallback" nag for a setup that's working as designed. Only
426 + // nginx + Apache use a server-level rewrite worth probing.
427 + $rewrite_capable = ( $server_type === Server::NGINX || $server_type === Server::APACHE );
428 + $rewrite_probe = null;
429 + if ( $opts['cache_enabled'] && $rewrite_capable ) {
430 + $probe = Cache::probe_static_rewrite();
431 + $rewrite_probe = array(
432 + 'active' => (bool) ( $probe['active'] ?? false ),
433 + // Both flags were previously dropped here, so the dashboard
434 + // could not tell "proven inactive" from "no result yet" or
435 + // "probe failed" — and rendered the configure-your-server
436 + // banner for all three. (FBS-84012)
437 + 'pending' => (bool) ( $probe['pending'] ?? false ),
438 + 'inconclusive' => (bool) ( $probe['inconclusive'] ?? false ),
439 + 'reason' => (string) ( $probe['reason'] ?? '' ),
440 + 'server_type' => $server_type,
441 + 'snippet' => Cache::nginx_snippet(),
442 + 'topology' => Server::rewrite_topology(),
443 + 'behind_proxy' => Server::is_behind_proxy(),
444 + // Whether the rules the web server is running are the rules
445 + // these settings generate — `current`, `stale`, `absent` or
446 + // `unknown`, with the hashes both sides compared. Nothing
447 + // else can answer that: the nginx block lives in a server
448 + // config WordPress cannot read. See Cache::rules_state().
449 + 'rules' => Cache::rules_state( $probe ),
450 + );
451 + }
452 +
116 453 return rest_ensure_response(
117 454 array(
118 - 'enabled' => (bool) $opts['cache_enabled'],
119 - 'stats' => $stats,
120 - 'server' => array(
121 - 'type' => Server::type(),
455 + 'enabled' => (bool) $opts['cache_enabled'],
456 + 'stats' => $stats,
457 + 'server' => array(
458 + 'type' => $server_type,
122 459 'gzip_mode' => Server::gzip_mode(),
123 460 'gzip_active' => Gzip::probe_active(),
124 461 'nginx_snippet' => Gzip::nginx_snippet(),
125 462 ),
463 + 'rewrite_probe' => $rewrite_probe,
464 + 'nginx_server_block' => Cache::full_nginx_server_block(),
465 + // What enabling the page cache would do to
466 + // wp-content/advanced-cache.php. The dashboard discloses the
467 + // replacement BEFORE the write when a leftover drop-in is
468 + // already there; see Page_Cache_Detector::dropin_disclosure().
469 + 'dropin' => Page_Cache_Detector::dropin_disclosure(),
470 + // Separate Mobile Cache visibility (FBS-83145). `blocking` is
471 + // true when mobile_separate is what's keeping the device-blind
472 + // static fast path from installing on a rewrite-capable server;
473 + // `needs_review` is true when a migration turned it on for us and
474 + // the user hasn't confirmed they actually need it. The dashboard
475 + // renders a callout (+ "Check now" equality probe) from these.
476 + 'mobile_separate' => array(
477 + 'enabled' => ! empty( Settings::get()['cache_enabled'] ) ? (bool) ( Settings_Manager::get( 'cache' )['mobile_separate'] ?? false ) : false,
478 + // Gated to servers that HAVE a static fast path — see the
479 + // matching comment in Admin::bootstrap_payload(): on IIS /
480 + // unknown, block_reason still falls through to
481 + // mobile_separate and reporting it as "blocking" would nag
482 + // about a rewrite that does not exist there (#108).
483 + // LiteSpeed joined the capable set with the opt-in (#509).
484 + 'blocking' => ( $rewrite_capable || Server::LITESPEED === $server_type )
485 + && 'mobile_separate' === Cache::static_rewrite_block_reason(),
486 + 'needs_review' => Cache::mobile_separate_needs_review(),
487 + ),
126 488 )
127 489 );
128 490 }
129 491
@@ -145,12 +507,124 @@
145 507 return rest_ensure_response( $updated );
146 508 }
147 509
148 510 public function purge() {
149 - Cache::purge_all();
150 - return rest_ensure_response( array( 'stats' => Cache::get_stats() ) );
511 + // The same core function `wp xspeed purge` runs, so the dashboard
512 + // button and the CLI cannot clear different sets of stores — and the
513 + // per-store report is available here for the UI to surface a store
514 + // that was skipped or refused rather than flashing "cache cleared".
515 + $report = Purge_Runner::run( array( 'all' ), __( 'dashboard', 'xspeed' ) );
516 + return rest_ensure_response(
517 + array(
518 + 'stats' => Cache::get_stats(),
519 + 'report' => $report,
520 + )
521 + );
151 522 }
152 523
524 + /**
525 + * The "Cached Pages" drill-down: which pages are cached and how old they
526 + * are. Paginated because a busy site's cache is thousands of entries and
527 + * the answer to "is my cache working" doesn't need all of them at once.
528 + */
529 + public function cache_inventory( \WP_REST_Request $request ) {
530 + return rest_ensure_response(
531 + Cache_Inventory::entries(
532 + (int) $request->get_param( 'limit' ),
533 + (int) $request->get_param( 'offset' ),
534 + (bool) $request->get_param( 'fresh' )
535 + )
536 + );
537 + }
538 +
539 + /** The "Cache Size" drill-down: where the bytes actually go. */
540 + public function cache_size() {
541 + return rest_ensure_response( Cache_Inventory::size_breakdown() );
542 + }
543 +
544 + /** The "Last Purge" drill-down: what cleared the cache, when, and why. */
545 + public function cache_purge_log( \WP_REST_Request $request ) {
546 + return rest_ensure_response( Cache_Inventory::purge_log( (int) $request->get_param( 'limit' ) ) );
547 + }
548 +
549 + /**
550 + * Resolved branding ({name, footer_credit, hide_help_links, logo_svg}).
551 + * Runs the `xspeed_branding` filter so Pro's white-label override is
552 + * reflected. Consumed by the dashboard's post-save branding refresh.
553 + */
554 + public function get_branding() {
555 + return rest_ensure_response( Admin::branding() );
556 + }
557 +
558 + /**
559 + * Re-run the static-rewrite probe, bypassing the cached result.
560 + *
561 + * Returns the same shape the dashboard bootstrap uses, so the caller can
562 + * swap it straight into state without a second round trip. (FBS-84012)
563 + */
564 + public function recheck_rewrite() {
565 + $raw = Cache::recheck_static_rewrite();
566 + $server_type = Server::detect();
567 +
568 + // Qualify the raw probe against known config refusals. The probe
569 + // fetches its own file from the static tree, which succeeds even when
570 + // no real page is served that way — so an unqualified `active` told
571 + // clients the static path was engaged on sites where it demonstrably
572 + // wasn't. `block_reason` is exposed so a client can act on the
573 + // specific cause rather than re-deriving it. See
574 + // Cache::qualify_rewrite_probe().
575 + $probe = Cache::qualify_rewrite_probe( $raw );
576 +
577 + return rest_ensure_response(
578 + array(
579 + 'active' => $probe['active'],
580 + 'pending' => (bool) ( $raw['pending'] ?? false ),
581 + 'inconclusive' => $probe['inconclusive'],
582 + 'reason' => $probe['reason'],
583 + 'block_reason' => $probe['block_reason'],
584 + 'server_type' => $server_type,
585 + 'snippet' => Cache::nginx_snippet(),
586 + 'topology' => Server::rewrite_topology(),
587 + 'behind_proxy' => Server::is_behind_proxy(),
588 + // The whole reason to re-run the probe is usually that the
589 + // user just pasted the block, so this is where they most need
590 + // to be told whether the installed rules are the current ones.
591 + // It was only ever on /status before, which the CLI and MCP
592 + // recheck paths never call. See Cache::rules_state().
593 + 'rules' => $probe['rules'],
594 + )
595 + );
596 + }
597 +
598 + /**
599 + * Remember that this admin copied the current rules block.
600 + *
601 + * The mirror of the panel's own localStorage note, for the one case that
602 + * note cannot cover: a host where the probe returns `unknown` — blocked
603 + * loopback, or a CDN answering it — and a second admin, or the same admin
604 + * on another machine, is otherwise told to paste a block that is already
605 + * installed. Stored per user because it is a claim a person made.
606 + *
607 + * The hash is the rules marker, and a value that is not one is refused
608 + * rather than stored: the mirror is only useful while it holds something
609 + * rules_marker_expected() could also produce.
610 + */
611 + public function nginx_copied_hash( \WP_REST_Request $request ) {
612 + $params = (array) $request->get_json_params();
613 + $hash = isset( $params['hash'] ) ? (string) $params['hash'] : (string) $request->get_param( 'hash' );
614 +
615 + $stored = Cache::remember_rules_copied( $hash );
616 + if ( null === $stored ) {
617 + return new \WP_Error(
618 + 'xspeed_invalid_rules_hash',
619 + __( 'That is not a rules marker this site could have generated.', 'xspeed' ),
620 + array( 'status' => 400 )
621 + );
622 + }
623 +
624 + return rest_ensure_response( array( 'copied' => $stored ) );
625 + }
626 +
153 627 public function toggle_cache( \WP_REST_Request $request ) {
154 628 $params = $request->get_json_params();
155 629 $enabled = isset( $params['enabled'] ) ? (bool) $params['enabled'] : false;
156 630
@@ -158,15 +632,29 @@
158 632 // permission_callback above already enforced current_user_can(
159 633 // 'manage_options' ); the REST nonce is verified by core via the
160 634 // X-WP-Nonce header.
161 635 $state = Cache::toggle( $enabled );
162 - $updated = Settings::update( array( 'cache_enabled' => $state['enabled'] ) );
636 + $updated = Settings::get();
163 637
638 + // Recompute the unified nginx block AFTER cache_enabled is persisted.
639 + // Cache::toggle() computes it inline, but cache_enabled isn't written
640 + // until the Settings::update() above — so the block inside $state
641 + // reflects the PRE-toggle state (CacheModule::nginx_directives() gates
642 + // on cache_enabled). Regenerate here so the dashboard's optimistic
643 + // update shows the snippet for the state the user just selected.
644 + $state['nginx_server_block'] = Cache::full_nginx_server_block();
645 +
164 646 return rest_ensure_response(
165 647 array(
166 - 'enabled' => $updated['cache_enabled'],
167 - 'stats' => Cache::get_stats(),
168 - 'install_state' => $state,
648 + 'enabled' => $updated['cache_enabled'],
649 + // Surfaced at the top level so the dashboard can explain a
650 + // refusal rather than silently snapping the toggle back:
651 + // Cache::toggle() writes nothing when another plugin owns the
652 + // drop-in or WP_CACHE is in a shape we must not rewrite.
653 + 'blocked' => ! empty( $state['blocked'] ),
654 + 'blocked_reason' => $state['blocked_reason'] ?? null,
655 + 'stats' => Cache::get_stats(),
656 + 'install_state' => $state,
169 657 )
170 658 );
171 659 }
172 660 }