PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.4.2
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.4.2
1.4.2 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 All 36 releases
xspeed / includes / class-admin.php

class-admin.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.4.2, at includes/class-admin.php

746 lines 31.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Admin menu + asset enqueue.
4 *
5 * @package XSpeed
6 */
7
8 namespace XSpeed;
9
10 defined( 'ABSPATH' ) || exit;
11
12 class Admin {
13
14 const PAGE_SLUG = 'xspeed';
15
16 const THEME_COOKIE = 'xspeed_theme';
17
18 /**
19 * The dashboard's sidebar offer reads a module's `purchase_needed` before
20 * its `status_label`. An add-on checks for this constant before it labels
21 * a setup step, which an older dashboard would read as "still for sale".
22 */
23 const OFFER_READS_PURCHASE_NEEDED = true;
24
25 public function __construct() {
26 add_action( 'admin_menu', array( $this, 'register_menu' ) );
27 add_action( 'admin_enqueue_scripts', array( $this, 'enqueue' ) );
28 add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_menu_styles' ) );
29 add_filter( 'admin_body_class', array( __CLASS__, 'admin_body_class' ) );
30 // Add a "Settings" shortcut to the plugin's row on the Plugins screen,
31 // deep-linking straight to the xSpeed dashboard. (FBS-83234)
32 add_filter( 'plugin_action_links_' . plugin_basename( XSPEED_FILE ), array( __CLASS__, 'plugin_action_links' ) );
33 // Strip third-party admin notices on our screens only. Fires in
34 // `in_admin_header` (after `get_current_screen()` is populated
35 // but before notices render) so the screen check is reliable.
36 add_action( 'in_admin_header', array( __CLASS__, 'suppress_foreign_notices' ), 0 );
37 }
38
39 /**
40 * Remove every third-party admin notice on xSpeed admin screens so the
41 * dashboard stays visually clean. Scoped via `is_plugin_page()` — runs
42 * nowhere else. Our own notices stay rendered: register them on the
43 * dedicated `xspeed_admin_notices` action below, which fires after
44 * this suppression and is wired to all three core notice hooks.
45 *
46 * Hooks cleared: `admin_notices`, `all_admin_notices`,
47 * `user_admin_notices`, `network_admin_notices`. WordPress's own
48 * settings-saved / updated messages are emitted via `settings_errors()`
49 * and printed inline by `options.php` — they are NOT on these hooks
50 * and are unaffected.
51 *
52 * @return void
53 */
54 public static function suppress_foreign_notices() {
55 if ( ! self::is_plugin_page() ) {
56 return;
57 }
58 remove_all_actions( 'admin_notices' );
59 remove_all_actions( 'all_admin_notices' );
60 remove_all_actions( 'user_admin_notices' );
61 remove_all_actions( 'network_admin_notices' );
62
63 // Re-route the four standard notice hooks to a single namespaced
64 // action so xSpeed (and any deliberate extender that opts in)
65 // keeps a place to emit notices after the strip.
66 $relay = static function () {
67 /**
68 * Fires in place of WP's `admin_notices` family on xSpeed
69 * admin screens. Use this instead of `admin_notices` when
70 * you want a notice to survive xSpeed's third-party
71 * suppression.
72 *
73 * @since 1.0.3
74 */
75 do_action( 'xspeed_admin_notices' );
76 };
77 add_action( 'admin_notices', $relay );
78 add_action( 'all_admin_notices', $relay );
79 add_action( 'user_admin_notices', $relay );
80 add_action( 'network_admin_notices', $relay );
81 }
82
83 /**
84 * Server-side theme detection from the cookie written by useTheme. Used
85 * to emit the `.dark` class on the React mount node and the
86 * `xspeed-dark` class on `<body>` during the initial render — kills the
87 * light→dark flash that happens when JS adds those classes after the
88 * page has already painted.
89 *
90 * @return string 'dark' | 'light'
91 */
92 public static function user_theme() {
93 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotValidated, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- read-only string compare; value is sanitized via sanitize_key() on the next line before any use.
94 $raw = isset( $_COOKIE[ self::THEME_COOKIE ] ) ? wp_unslash( $_COOKIE[ self::THEME_COOKIE ] ) : '';
95 return 'dark' === sanitize_key( $raw ) ? 'dark' : 'light';
96 }
97
98 public static function is_plugin_page() {
99 // Prefer the ?page= slug: it's brand-independent. WordPress derives
100 // the submenu screen base from the *sanitized parent menu title*, so
101 // once White-Label renames the menu the base becomes e.g.
102 // "acmespeed_page_xspeed-onboarding" and any check anchored on
103 // self::PAGE_SLUG ("xspeed_page_…") silently stops matching — which
104 // dropped the xspeed-page / xspeed-dark body classes on the wizard and
105 // left it unstyled. Match the slug instead. (FBS-82222)
106 $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only screen gate.
107 if ( self::PAGE_SLUG === $page || Onboarding::PAGE_SLUG === $page ) {
108 return true;
109 }
110
111 // Fallback for contexts where $_GET['page'] isn't set but the screen is
112 // available. The toplevel base is slug-based (stable); the submenu base
113 // is title-derived, so match on its slug SUFFIX rather than the prefix.
114 $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
115 if ( ! $screen ) {
116 return false;
117 }
118 $base = (string) $screen->base;
119 return 0 === strpos( $base, 'toplevel_page_' . self::PAGE_SLUG )
120 || (bool) preg_match( '/_page_' . preg_quote( self::PAGE_SLUG, '/' ) . '($|-)/', $base );
121 }
122
123 public static function admin_body_class( $classes ) {
124 if ( ! self::is_plugin_page() ) {
125 return $classes;
126 }
127 $classes .= ' xspeed-page';
128 // Dashboard-only marker (NOT the onboarding wizard, which shares
129 // `xspeed-page` + the same admin.css but must scroll as a normal
130 // centered card). Layout rules that reshape the WP admin chrome — the
131 // sticky content column that pins the fixed-height dashboard while a
132 // tall admin menu scrolls — are scoped to `.xspeed-dashboard` so they
133 // never touch the wizard. Keyed on the ?page= slug (brand-independent).
134 $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only screen gate.
135 if ( self::PAGE_SLUG === $page ) {
136 $classes .= ' xspeed-dashboard';
137 }
138 if ( 'dark' === self::user_theme() ) {
139 $classes .= ' xspeed-dark';
140 }
141 return $classes;
142 }
143
144 /**
145 * Prepend a "Settings" link to the plugin's action links on the Plugins
146 * screen, deep-linking to the xSpeed dashboard. Only users who can reach
147 * the settings page (`manage_options`, the same cap the menu uses) see it.
148 *
149 * @param array $links Existing action links (Deactivate, etc.).
150 * @return array
151 */
152 public static function plugin_action_links( $links ) {
153 if ( ! current_user_can( 'manage_options' ) ) {
154 return $links;
155 }
156
157 $settings_link = sprintf(
158 '<a href="%1$s">%2$s</a>',
159 esc_url( admin_url( 'admin.php?page=' . self::PAGE_SLUG ) ),
160 esc_html__( 'Settings', 'xspeed' )
161 );
162
163 array_unshift( $links, $settings_link );
164
165 return $links;
166 }
167
168 public function register_menu() {
169 $brand = self::branding();
170 // Use the white-label logo for the admin menu icon when set, so the
171 // rebrand carries to the menu mark too — not just the title. Falls
172 // back to the built-in xSpeed SVG. (FBS-82222)
173 $menu_icon = ! empty( $brand['logo_svg'] ) ? $brand['logo_svg'] : self::menu_icon();
174 // Menu label + page <title> both read the resolved brand name
175 // ("xSpeed Cache" by default; a white-label brand is used verbatim).
176 // Positioned just after Appearance
177 // (WP core slot 60) — up with the site-management group, not buried
178 // down by Settings and not pinned to the very top.
179 $menu_label = self::menu_label( $brand );
180 add_menu_page(
181 $brand['name'],
182 $menu_label,
183 'manage_options',
184 self::PAGE_SLUG,
185 array( $this, 'render' ),
186 $menu_icon,
187 61
188 );
189
190 // Drop WP's auto-generated duplicate first submenu (which
191 // inherits the toplevel "xSpeed" title). The group deep-links
192 // below replace it — keeping it would render a redundant
193 // "xSpeed" / "Dashboard" row that just re-links to the same
194 // page as the toplevel entry.
195 global $submenu;
196 // $submenu may not yet be populated for this slug; the
197 // `remove_submenu_page` call covers either case.
198 remove_submenu_page( self::PAGE_SLUG, self::PAGE_SLUG );
199
200 // Deep-link submenus — each points to the same dashboard page
201 // with a section hash so React's App.tsx routing lands the
202 // user inside the right module. WordPress's add_submenu_page
203 // strips the hash, so we inject directly into $submenu where
204 // it survives intact (the same trick Yoast / WooCommerce use
205 // for their per-area shortcuts).
206 //
207 // Writing to $submenu skips the capability check add_submenu_page()
208 // does, and core keeps a top-level item the user can't open while its
209 // submenu has entries. Without this guard an Editor saw an empty
210 // "xSpeed Cache" flyout that led to a permissions error. (#720)
211 if ( ! current_user_can( 'manage_options' ) ) {
212 return;
213 }
214 global $submenu;
215 $deep_links = self::deep_link_items();
216 foreach ( $deep_links as $hash => $label ) {
217 $submenu[ self::PAGE_SLUG ][] = array(
218 $label,
219 'manage_options',
220 'admin.php?page=' . self::PAGE_SLUG . '#' . $hash,
221 );
222 }
223 }
224
225 /**
226 * Section deep-links rendered under the xSpeed menu.
227 *
228 * This is deliberately a SHORT LIST, not a mirror of React's
229 * SIDEBAR_GROUPS. It used to mirror all eight groups 1:1, which made the
230 * WP admin rail a second, competing copy of the in-app sidebar — the same
231 * map rendered twice, one of them permanently expanded and pushing every
232 * other plugin's menu down the page.
233 *
234 * What stays are the entry points a user navigates to from OUTSIDE the
235 * app: the dashboard itself, the two areas people arrive at with a
236 * specific errand (AI & agents to connect an assistant, Settings), and the
237 * wizard. Everything else — Cache, Optimization, Network, Health &
238 * insights, Tools — is one click away in the app's own sidebar, which is
239 * where in-app navigation belongs.
240 *
241 * So adding a group to React's SIDEBAR_GROUPS no longer means adding it
242 * here. The two lists are intentionally different lengths now.
243 *
244 * Setup Wizard is NOT in this array — it's a real submenu page registered
245 * by Onboarding::register_menu() on `admin_menu` at priority 20, so it
246 * lands after these entries.
247 *
248 * @return array<string,string> map of route hash → menu label.
249 */
250 private static function deep_link_items() {
251 // Keys are route hashes (`/<group-id>`); the submenu loop prepends '#'.
252 // React (nav.ts parseRoute) resolves `#/<group-id>` to that landing;
253 // legacy `#slug` links still redirect, so old bookmarks keep working —
254 // including bookmarks to the groups no longer listed here.
255 return array(
256 '/overview' => __( 'Overview', 'xspeed' ),
257 '/ai-agents' => __( 'AI & agents', 'xspeed' ),
258 '/settings' => __( 'Settings', 'xspeed' ),
259 );
260 }
261
262 /**
263 * Pluggable branding for the dashboard chrome. xspeed-pro's
264 * White-Label module hooks `xspeed_branding` to override these
265 * values from saved settings.
266 *
267 * @return array{name:string,footer_credit:?string,hide_help_links:bool,logo_svg:?string}
268 * @since 1.5.0
269 */
270 public static function branding() {
271 $defaults = array(
272 'name' => 'xSpeed Cache',
273 'footer_credit' => null, // null = show the default WPDeveloper credit.
274 'hide_help_links' => false,
275 'logo_svg' => null, // null = use the built-in brand mark.
276 );
277 $out = apply_filters( 'xspeed_branding', $defaults );
278 if ( ! is_array( $out ) ) {
279 return $defaults;
280 }
281 return array_merge( $defaults, $out );
282 }
283
284 /**
285 * Label for the WordPress admin sidebar menu entry — the resolved brand
286 * name verbatim ("xSpeed Cache" by default, or the white-label name).
287 *
288 * @param array{name:string} $brand Resolved branding array.
289 * @return string
290 */
291 private static function menu_label( array $brand ) {
292 return isset( $brand['name'] ) ? (string) $brand['name'] : 'xSpeed Cache';
293 }
294
295 /**
296 * URL of the SVG menu icon — the official xSpeed brand mark. Uses
297 * fill="currentColor" which renders black in <img> context; the inline
298 * style below recolors it via CSS filter for the WP admin menu states.
299 */
300 private static function menu_icon() {
301 return XSPEED_URL . 'assets/icon.svg';
302 }
303
304 public function render() {
305 $dark = 'dark' === self::user_theme() ? ' dark' : '';
306 // Pre-mount skeleton: the React bundle executes a beat after the page
307 // paints, so without this the mount div is an empty dark void while it
308 // loads. We render the xSpeed brand mark (pulsing) straight into the
309 // mount node in PHP; createRoot().render() REPLACES these children the
310 // instant React boots, so the skeleton disappears with no JS wiring.
311 // The mark is the bundled icon.svg; `dark:invert` (scoped in
312 // styles.css) keeps the currentColor mark visible on the dark shell.
313 printf(
314 '<div id="xspeed-app" class="xspeed-root%1$s"><div class="xspeed-boot" role="status" aria-label="%2$s"><img class="xspeed-boot-mark" src="%3$s" alt="%4$s" width="48" height="48" /><span class="screen-reader-text">%5$s</span></div></div>',
315 esc_attr( $dark ),
316 esc_attr__( 'Loading', 'xspeed' ),
317 esc_url( XSPEED_URL . 'assets/icon.svg' ),
318 esc_attr__( 'xSpeed', 'xspeed' ),
319 esc_html__( 'Loading…', 'xspeed' )
320 );
321 }
322
323 /**
324 * Enqueue the stylesheet that recolors the menu icon to match the WP
325 * admin color scheme. Loads on every admin page (not just the plugin's
326 * page) because the menu icon is visible site-wide.
327 */
328 public function enqueue_menu_styles() {
329 wp_enqueue_style(
330 'xspeed-menu-icon',
331 XSPEED_URL . 'assets/menu-icon.css',
332 array(),
333 XSPEED_VERSION
334 );
335
336 // menu-icon.css recolors the built-in mark (fill=currentColor) to white
337 // via a brightness/invert filter. A white-label logo is a real image
338 // (often colored), so cancel the filter for it — otherwise the agency
339 // logo renders as a white silhouette. (FBS-82222)
340 $brand = self::branding();
341 if ( ! empty( $brand['logo_svg'] ) ) {
342 wp_add_inline_style(
343 'xspeed-menu-icon',
344 '#toplevel_page_' . self::PAGE_SLUG . ' .wp-menu-image img{filter:none;opacity:1}'
345 );
346 }
347 }
348
349 public function enqueue( $hook ) {
350 if ( 'toplevel_page_' . self::PAGE_SLUG !== $hook ) {
351 return;
352 }
353
354 $asset_js = XSPEED_DIR . 'assets/admin.js';
355 $asset_css = XSPEED_DIR . 'assets/admin.css';
356
357 // Needed for the media-library picker used by schema 'media' fields
358 // (e.g. the white-label brand logo). Loads window.wp.media.
359 wp_enqueue_media();
360
361 if ( file_exists( $asset_js ) ) {
362 // filemtime() cache-busts on every rebuild so a stable
363 // VERSION constant never serves stale JS through browser
364 // caches.
365 wp_enqueue_script(
366 'xspeed-admin',
367 XSPEED_URL . 'assets/admin.js',
368 array( 'wp-api-fetch', 'wp-i18n' ),
369 XSPEED_VERSION . '.' . filemtime( $asset_js ),
370 true
371 );
372 // Loads .mo files for the 'xspeed' text-domain into
373 // window.wp.i18n so the React `__()` helper resolves.
374 if ( function_exists( 'wp_set_script_translations' ) ) {
375 wp_set_script_translations(
376 'xspeed-admin',
377 'xspeed',
378 XSPEED_DIR . 'languages'
379 );
380 }
381 }
382
383 // Redesign v2 design tokens + self-hosted fonts. Hand-written (not
384 // Vite-bundled) so the @font-face url('./fonts/…') resolve relative to
385 // assets/. admin.css depends on it so the CSS vars are defined first.
386 $theme_css = XSPEED_DIR . 'assets/theme.css';
387 if ( file_exists( $theme_css ) ) {
388 wp_enqueue_style(
389 'xspeed-theme',
390 XSPEED_URL . 'assets/theme.css',
391 array(),
392 XSPEED_VERSION . '.' . filemtime( $theme_css )
393 );
394 }
395 if ( file_exists( $asset_css ) ) {
396 wp_enqueue_style(
397 'xspeed-admin',
398 XSPEED_URL . 'assets/admin.css',
399 array( 'xspeed-theme' ),
400 XSPEED_VERSION . '.' . filemtime( $asset_css )
401 );
402 }
403
404 /**
405 * Fires after the Free dashboard bundle is enqueued, before its
406 * config is localized. Pro hooks this to enqueue its own bundle
407 * with `xspeed-admin` as a dependency, so its panel
408 * registrations run after `window.XSpeedPro` is installed by
409 * Free's main.tsx.
410 *
411 * @since 1.5.0
412 */
413 do_action( 'xspeed_admin_enqueue', $hook );
414
415 // NOT wp_localize_script(). WP_Scripts::localize() casts every scalar
416 // to a string (wp-includes/class-wp-scripts.php: `(string) $value`),
417 // so an int arrives in JS as "21600" and a bool as "1" or "".
418 //
419 // That silently broke the Pro prewarm scheduler: it guards with
420 // `typeof gmtOffset === 'number'`, which a string fails, so the site's
421 // UTC offset was treated as 0 and every one-off warm was scheduled
422 // against UTC instead of site time — hours late on any non-UTC site,
423 // under a label that confidently read "Site time (UTC)".
424 //
425 // wp_add_inline_script() with wp_json_encode() preserves types, so
426 // numbers stay numbers and booleans stay booleans. Worth doing beyond
427 // the one field: every future numeric or boolean config value would
428 // hit the same trap. (#105)
429 self::print_config(
430 'XSpeedConfig',
431 array(
432 'restUrl' => esc_url_raw( rest_url( Rest_Api::NAMESPACE_V1 ) ),
433 'nonce' => wp_create_nonce( 'wp_rest' ),
434 'version' => XSPEED_VERSION,
435 'branding' => self::branding(),
436 // Site's UTC offset in seconds — so datetime pickers (e.g. the
437 // Pro prewarm scheduler) align with WP's own post-scheduling,
438 // which is site-time, not the admin's browser-local time.
439 'gmtOffset' => (int) round( (float) get_option( 'gmt_offset', 0 ) * HOUR_IN_SECONDS ),
440 // 'pro' when xspeed-pro is active + speaks our API
441 // version (see Tier_Registry); 'free' otherwise.
442 // 'trial' reserved for future license-server work.
443 'tier' => class_exists( '\\XSpeed\\Tier_Registry' ) && Tier_Registry::pro_active() ? 'pro' : 'free',
444 // Three-state Pro status so gated UI can show the RIGHT message:
445 // 'not_installed' — Pro plugin not active → "Upgrade to Pro"
446 // 'unlicensed' — Pro active, no valid license → "Activate license"
447 // 'active' — Pro active + licensed → (modules unlocked)
448 // Free can't read Pro's license directly (Free never references
449 // Pro), so Pro filters this via `xspeed_pro_state`. Default:
450 // not_installed when Pro is absent; 'active' when Pro is present
451 // (Pro downgrades to 'unlicensed' when its license isn't valid).
452 'proState' => self::pro_state(),
453 // The xCloud purge plugin is here: the site already has
454 // Cloudflare Enterprise from its host, so the dashboard does
455 // not offer the add-on.
456 'xcloudPurgePlugin' => Managed_Edge_Purge::installed(),
457 // Setup Wizard URL, surfaced in the sidebar profile popover.
458 'wizardUrl' => admin_url( 'admin.php?page=' . Onboarding::PAGE_SLUG ),
459 'bootstrap' => self::bootstrap_payload(),
460 )
461 );
462 }
463
464 /**
465 * Emit a JS global for the admin bundle with types intact.
466 *
467 * The type-preserving replacement for wp_localize_script(), which
468 * stringifies every scalar. Attached to the `xspeed-admin` handle as a
469 * `before` script so it is defined by the time the bundle executes —
470 * exactly the ordering guarantee localize gave us.
471 *
472 * Shared by the dashboard and the onboarding wizard so neither can drift
473 * back to the stringifying path.
474 *
475 * @param string $var_name JS global to define.
476 * @param array $data Payload; encoded with wp_json_encode().
477 */
478 public static function print_config( string $var_name, array $data ): void {
479 $json = wp_json_encode( $data );
480 if ( false === $json ) {
481 // Never emit a broken assignment — the bundle reads this global
482 // on mount and a syntax error here blanks the whole screen.
483 $json = '{}';
484 }
485 wp_add_inline_script(
486 'xspeed-admin',
487 'var ' . $var_name . ' = ' . $json . ';',
488 'before'
489 );
490 }
491
492 /**
493 * Pre-rendered settings + status payload, baked into the page so the
494 * React app can mount with real values instead of showing a loading state
495 * while it waits for /settings and /status REST calls.
496 */
497 /**
498 * Three-state Pro status for the gated UI ('not_installed' | 'unlicensed'
499 * | 'active'). Free cannot read Pro's license (it never references Pro),
500 * so the authoritative value comes from the `xspeed_pro_state` filter that
501 * xspeed-pro hooks. The default here only distinguishes installed vs not —
502 * when Pro is active, Pro itself downgrades the value to 'unlicensed' if
503 * its license isn't valid.
504 *
505 * @return string
506 */
507 private static function pro_state(): string {
508 $pro_present = class_exists( '\\XSpeed\\Tier_Registry' ) && Tier_Registry::pro_active();
509 $default = $pro_present ? 'active' : 'not_installed';
510
511 /**
512 * Filter: xspeed_pro_state
513 *
514 * Lets the Pro plugin report its real license state so Free's gated
515 * panels can show "Activate license" (Pro installed, unlicensed) vs
516 * "Upgrade to Pro" (Pro not installed).
517 *
518 * @param string $state One of 'not_installed' | 'unlicensed' | 'active'.
519 */
520 $state = (string) apply_filters( 'xspeed_pro_state', $default );
521
522 return in_array( $state, array( 'not_installed', 'unlicensed', 'active' ), true ) ? $state : $default;
523 }
524
525 private static function bootstrap_payload() {
526 $opts = Settings::get();
527 $stats = Cache::get_stats();
528 // Static-rewrite probe state shipped to the React side so the
529 // dashboard can show a persistent banner when nginx/Apache
530 // hasn't been wired to bypass PHP yet. Cache-ONLY here (no $allow_probe
531 // arg) so the dashboard bootstrap never makes the loopback HTTP probe
532 // — that could add seconds to every admin page load on hosts that
533 // stall self-requests. The Health tab runs the live probe on demand;
534 // here we just surface whatever it last cached. (FBS-82142)
535 $server_type = Server::type();
536 // LiteSpeed serves hits via the PHP drop-in by design (its .htaccess
537 // can't add the HIT header or log a static hit), so the static-rewrite
538 // probe is N/A there — surfacing it would pop the "PHP fallback" nag
539 // for a setup working as designed. Only nginx + Apache probe.
540 $rewrite_capable = ( $server_type === Server::NGINX || $server_type === Server::APACHE );
541 $rewrite_probe = null;
542 if ( $opts['cache_enabled'] && $rewrite_capable ) {
543 $probe = Cache::probe_static_rewrite();
544 $rewrite_probe = array(
545 'active' => (bool) ( $probe['active'] ?? false ),
546 'server_type' => $server_type,
547 'snippet' => Cache::nginx_snippet(), // null on non-nginx hosts
548 'topology' => Server::rewrite_topology(),
549 // A reverse proxy / CDN in front (X-Forwarded-* present) usually
550 // means the request-terminating nginx isn't user-editable on this
551 // host — the banner uses this to switch to honest messaging
552 // instead of dangling a snippet the user can't apply.
553 'behind_proxy' => Server::is_behind_proxy(),
554 // Mirrors /status (see the note there). On first paint the
555 // probe is whatever was last cached, so this is usually
556 // `unknown` until the Health tab runs a live probe — the
557 // dashboard needs the key present either way.
558 'rules' => Cache::rules_state( $probe ),
559 );
560 }
561
562 return array(
563 'settings' => $opts,
564 'status' => array(
565 'enabled' => (bool) $opts['cache_enabled'],
566 'stats' => $stats,
567 'server' => array(
568 'type' => $server_type,
569 'gzip_mode' => Server::gzip_mode(),
570 'gzip_active' => Gzip::probe_active(),
571 'nginx_snippet' => Gzip::nginx_snippet(),
572 ),
573 'rewrite_probe' => $rewrite_probe,
574 // Separate Mobile Cache visibility (FBS-83145) — mirrors the
575 // /status block so the dashboard callout renders on first paint
576 // without waiting for a status re-fetch.
577 'mobile_separate' => array(
578 'enabled' => (bool) ( $opts['cache_enabled'] ? ( Settings_Manager::get( 'cache' )['mobile_separate'] ?? false ) : false ),
579 // Gated to servers that HAVE a static fast path — on IIS or
580 // an undetected server block_reason still falls through to
581 // mobile_separate, and reporting that as "blocking" would
582 // nag about a rewrite that does not exist there (#108).
583 // LiteSpeed joined the capable set with the Static Fast
584 // Path opt-in (#509); its own refusal (litespeed_dropin)
585 // outranks mobile_separate, so this stays false there
586 // until the opt-in is on.
587 'blocking' => ( $rewrite_capable || Server::LITESPEED === $server_type )
588 && 'mobile_separate' === Cache::static_rewrite_block_reason(),
589 'needs_review' => Cache::mobile_separate_needs_review(),
590 ),
591 // One consolidated nginx server-block snippet aggregating
592 // every enabled module's directives (Cache static-rewrite,
593 // BrowserCache headers, GZIP, …). Null on non-nginx hosts
594 // or when no module contributes directives. Replaces the
595 // per-module "paste this snippet" notices.
596 'nginx_server_block' => Cache::full_nginx_server_block(),
597 // Mirrors the /status block so the enable-time disclosure is
598 // correct on FIRST PAINT. Without it the top-bar switch can be
599 // clicked before a status fetch lands, and the one moment the
600 // warning exists for — a leftover drop-in about to be
601 // replaced — is exactly when it would be missing.
602 'dropin' => Page_Cache_Detector::dropin_disclosure(),
603 ),
604 // Registered Modules (Free + Pro). The React app discovers them
605 // here and renders one sidebar item + one panel per module that
606 // declares a settings schema. Hidden modules are filtered.
607 'modules' => self::modules_payload(),
608 // xSpeed Hub connection snapshot so the Account panel + header chip
609 // render the correct connected/not-connected state on FIRST PAINT —
610 // no fetch, no "not connected → connected" flash. Null when the MCP
611 // module is unavailable (the panel then falls back to /mcp/hub).
612 'hub' => self::hub_payload(),
613 );
614 }
615
616 /**
617 * xSpeed Hub connection snapshot for the dashboard bootstrap. Guarded so the
618 * dashboard never hard-depends on the MCP module. Same shape as GET /mcp/hub.
619 *
620 * @return array<string,mixed>|null
621 */
622 private static function hub_payload() {
623 if ( ! class_exists( '\XSpeed\Modules\Mcp\Mcp_Hub' ) ) {
624 return null;
625 }
626 return \XSpeed\Modules\Mcp\Mcp_Hub::public_status();
627 }
628
629 /**
630 * Serialize every available Module for the React dashboard. Each entry
631 * carries enough to render: identity (slug + tier), UI metadata (label,
632 * icon, optional description), current settings, and the typed schema
633 * the panel uses to render controls.
634 *
635 * Modules that declare `hidden => true` in ui_metadata (e.g., engine
636 * modules with no user-facing settings) are skipped.
637 */
638 public static function modules_payload() {
639 if ( ! class_exists( 'XSpeed\\Module_Registry' ) ) {
640 return array();
641 }
642 $out = array();
643 foreach ( Module_Registry::available() as $slug => $module ) {
644 $meta = $module->ui_metadata();
645 if ( ! empty( $meta['hidden'] ) ) {
646 continue;
647 }
648 $schema = $module->settings_schema();
649 $custom_panel = $meta['custom_panel'] ?? null;
650 // Skip only when the module has neither a schema nor a custom
651 // panel — i.e., truly nothing to render in the dashboard.
652 if ( empty( $schema ) && empty( $custom_panel ) ) {
653 continue;
654 }
655 $settings = Settings_Manager::get_public( $slug );
656 // Something else on the site has taken over this module's job. It
657 // stands down whatever its own switch says, so it is reported off,
658 // with the reason in place of its usual one.
659 $blocked = $module->blocked_by();
660 $active = $module->is_active();
661
662 $entry = array(
663 'slug' => $slug,
664 'tier' => $module->tier(),
665 'version' => $module->version(),
666 // Promoted from inside `settings` so the payload is
667 // self-describing. Consumers kept tripping on this — the Hub
668 // rendered every module "Inactive" until it learned to look
669 // inside the settings bag. `settings.enabled` is kept below
670 // for back-compat; this is the same value, not a second
671 // source of truth. Modules with no `enabled` key (status
672 // panels like Health) report null rather than a misleading
673 // false. (#146)
674 'enabled' => array_key_exists( 'enabled', $settings )
675 ? (bool) $settings['enabled']
676 : null,
677 // Whether the module is actually DOING something, which is not
678 // the same question as `enabled` above. "On" has several shapes
679 // -- page caching lives in the global option, Minify and Lazy
680 // are on when any flag is set, MCP when it is connected -- so
681 // each module answers for itself via is_active(). Consumers
682 // that want "what is switched on?" (the sidebar's "N on" badge)
683 // must read THIS, not `enabled`, which only ever described the
684 // modules that happen to store that one key. null means the
685 // module has no meaningful on/off and should be excluded from
686 // any count rather than treated as off. (#363)
687 'active' => ( null !== $blocked && true === $active ) ? false : $active,
688 // One sentence explaining the line above, computed next to it
689 // so the two cannot disagree. The UI shows it behind an (i)
690 // beside the status pill: "On" is a bare assertion otherwise,
691 // and least obvious exactly where it matters -- Media
692 // Optimization reads On while its two most prominent switches
693 // are off, because three other flags are on. (#363)
694 'active_reason' => null !== $blocked ? $blocked : $module->active_reason(),
695 // A purchase or licence the module still waits on. Shown in the
696 // page header in place of the On/Off pill. Not while another
697 // module has taken over: that reason is the one to show.
698 'status_label' => null !== $blocked ? null : $module->status_label(),
699 'label' => $meta['label'] ?? ucfirst( $slug ),
700 'icon' => $meta['icon'] ?? 'Square',
701 'description' => $meta['description'] ?? '',
702 // The dashboard group the module belongs to (cache, performance,
703 // network, insights, tools, ai-agents, settings), declared by the
704 // module itself. The Hub kept its own copy of this map, so every
705 // new module landed in its "Other" bucket until the Hub shipped.
706 // Must agree with src/components/sidebarGroups.ts; a unit test
707 // holds the two together.
708 'group' => $meta['group'] ?? null,
709 // Short label for the module's own tab when it hosts a tabbed
710 // page (FBS-83633). Only set on host modules.
711 'tab_label' => $meta['tab_label'] ?? null,
712 // Public view: real values except secret fields, which are masked.
713 // The dashboard bundle localizes this into page HTML, so a raw
714 // credential here would be readable from view-source. (#115)
715 'settings' => $settings,
716 // Where each value actually came from: a wp-config.php constant,
717 // the option row, or the schema default. The panel renders a
718 // constant-sourced field read-only and names the constant, so it
719 // can never present an editable box over a value the site is not
720 // using. Every module gets this, not just the ones that declare
721 // constants today. (#398)
722 'setting_origins' => Settings_Manager::origins( $slug ),
723 'schema' => $schema,
724 'notices' => $module->ui_notices(),
725 'custom_panel' => $meta['custom_panel'] ?? null,
726 // Why this module may not be switched on, when something else
727 // on the site has taken over from it. Null for almost every
728 // module almost always — Free never blocks anything itself.
729 'blocked_by' => $blocked,
730 );
731
732 /**
733 * Last-mile descriptor filter. Lets Pro (or third-party
734 * extensions) override any field before the module is
735 * shipped to React. Primary use: xspeed-pro hooks this to
736 * swap `custom_panel` to LicenseLockedPanel for Pro modules
737 * when the license is invalid, so unlocked modules stay
738 * visible in the sidebar (good upsell UX) but the panel
739 * shows an activation prompt instead of the real surface.
740 */
741 $out[] = apply_filters( 'xspeed_module_descriptor', $entry, $module );
742 }
743 return $out;
744 }
745 }
746