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

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

738 lines 30.9 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 global $submenu;
207 $deep_links = self::deep_link_items();
208 foreach ( $deep_links as $hash => $label ) {
209 $submenu[ self::PAGE_SLUG ][] = array(
210 $label,
211 'manage_options',
212 'admin.php?page=' . self::PAGE_SLUG . '#' . $hash,
213 );
214 }
215 }
216
217 /**
218 * Section deep-links rendered under the xSpeed menu.
219 *
220 * This is deliberately a SHORT LIST, not a mirror of React's
221 * SIDEBAR_GROUPS. It used to mirror all eight groups 1:1, which made the
222 * WP admin rail a second, competing copy of the in-app sidebar — the same
223 * map rendered twice, one of them permanently expanded and pushing every
224 * other plugin's menu down the page.
225 *
226 * What stays are the entry points a user navigates to from OUTSIDE the
227 * app: the dashboard itself, the two areas people arrive at with a
228 * specific errand (AI & agents to connect an assistant, Settings), and the
229 * wizard. Everything else — Cache, Optimization, Network, Health &
230 * insights, Tools — is one click away in the app's own sidebar, which is
231 * where in-app navigation belongs.
232 *
233 * So adding a group to React's SIDEBAR_GROUPS no longer means adding it
234 * here. The two lists are intentionally different lengths now.
235 *
236 * Setup Wizard is NOT in this array — it's a real submenu page registered
237 * by Onboarding::register_menu() on `admin_menu` at priority 20, so it
238 * lands after these entries.
239 *
240 * @return array<string,string> map of route hash → menu label.
241 */
242 private static function deep_link_items() {
243 // Keys are route hashes (`/<group-id>`); the submenu loop prepends '#'.
244 // React (nav.ts parseRoute) resolves `#/<group-id>` to that landing;
245 // legacy `#slug` links still redirect, so old bookmarks keep working —
246 // including bookmarks to the groups no longer listed here.
247 return array(
248 '/overview' => __( 'Overview', 'xspeed' ),
249 '/ai-agents' => __( 'AI & agents', 'xspeed' ),
250 '/settings' => __( 'Settings', 'xspeed' ),
251 );
252 }
253
254 /**
255 * Pluggable branding for the dashboard chrome. xspeed-pro's
256 * White-Label module hooks `xspeed_branding` to override these
257 * values from saved settings.
258 *
259 * @return array{name:string,footer_credit:?string,hide_help_links:bool,logo_svg:?string}
260 * @since 1.5.0
261 */
262 public static function branding() {
263 $defaults = array(
264 'name' => 'xSpeed Cache',
265 'footer_credit' => null, // null = show the default WPDeveloper credit.
266 'hide_help_links' => false,
267 'logo_svg' => null, // null = use the built-in brand mark.
268 );
269 $out = apply_filters( 'xspeed_branding', $defaults );
270 if ( ! is_array( $out ) ) {
271 return $defaults;
272 }
273 return array_merge( $defaults, $out );
274 }
275
276 /**
277 * Label for the WordPress admin sidebar menu entry — the resolved brand
278 * name verbatim ("xSpeed Cache" by default, or the white-label name).
279 *
280 * @param array{name:string} $brand Resolved branding array.
281 * @return string
282 */
283 private static function menu_label( array $brand ) {
284 return isset( $brand['name'] ) ? (string) $brand['name'] : 'xSpeed Cache';
285 }
286
287 /**
288 * URL of the SVG menu icon — the official xSpeed brand mark. Uses
289 * fill="currentColor" which renders black in <img> context; the inline
290 * style below recolors it via CSS filter for the WP admin menu states.
291 */
292 private static function menu_icon() {
293 return XSPEED_URL . 'assets/icon.svg';
294 }
295
296 public function render() {
297 $dark = 'dark' === self::user_theme() ? ' dark' : '';
298 // Pre-mount skeleton: the React bundle executes a beat after the page
299 // paints, so without this the mount div is an empty dark void while it
300 // loads. We render the xSpeed brand mark (pulsing) straight into the
301 // mount node in PHP; createRoot().render() REPLACES these children the
302 // instant React boots, so the skeleton disappears with no JS wiring.
303 // The mark is the bundled icon.svg; `dark:invert` (scoped in
304 // styles.css) keeps the currentColor mark visible on the dark shell.
305 printf(
306 '<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>',
307 esc_attr( $dark ),
308 esc_attr__( 'Loading', 'xspeed' ),
309 esc_url( XSPEED_URL . 'assets/icon.svg' ),
310 esc_attr__( 'xSpeed', 'xspeed' ),
311 esc_html__( 'Loading…', 'xspeed' )
312 );
313 }
314
315 /**
316 * Enqueue the stylesheet that recolors the menu icon to match the WP
317 * admin color scheme. Loads on every admin page (not just the plugin's
318 * page) because the menu icon is visible site-wide.
319 */
320 public function enqueue_menu_styles() {
321 wp_enqueue_style(
322 'xspeed-menu-icon',
323 XSPEED_URL . 'assets/menu-icon.css',
324 array(),
325 XSPEED_VERSION
326 );
327
328 // menu-icon.css recolors the built-in mark (fill=currentColor) to white
329 // via a brightness/invert filter. A white-label logo is a real image
330 // (often colored), so cancel the filter for it — otherwise the agency
331 // logo renders as a white silhouette. (FBS-82222)
332 $brand = self::branding();
333 if ( ! empty( $brand['logo_svg'] ) ) {
334 wp_add_inline_style(
335 'xspeed-menu-icon',
336 '#toplevel_page_' . self::PAGE_SLUG . ' .wp-menu-image img{filter:none;opacity:1}'
337 );
338 }
339 }
340
341 public function enqueue( $hook ) {
342 if ( 'toplevel_page_' . self::PAGE_SLUG !== $hook ) {
343 return;
344 }
345
346 $asset_js = XSPEED_DIR . 'assets/admin.js';
347 $asset_css = XSPEED_DIR . 'assets/admin.css';
348
349 // Needed for the media-library picker used by schema 'media' fields
350 // (e.g. the white-label brand logo). Loads window.wp.media.
351 wp_enqueue_media();
352
353 if ( file_exists( $asset_js ) ) {
354 // filemtime() cache-busts on every rebuild so a stable
355 // VERSION constant never serves stale JS through browser
356 // caches.
357 wp_enqueue_script(
358 'xspeed-admin',
359 XSPEED_URL . 'assets/admin.js',
360 array( 'wp-api-fetch', 'wp-i18n' ),
361 XSPEED_VERSION . '.' . filemtime( $asset_js ),
362 true
363 );
364 // Loads .mo files for the 'xspeed' text-domain into
365 // window.wp.i18n so the React `__()` helper resolves.
366 if ( function_exists( 'wp_set_script_translations' ) ) {
367 wp_set_script_translations(
368 'xspeed-admin',
369 'xspeed',
370 XSPEED_DIR . 'languages'
371 );
372 }
373 }
374
375 // Redesign v2 design tokens + self-hosted fonts. Hand-written (not
376 // Vite-bundled) so the @font-face url('./fonts/…') resolve relative to
377 // assets/. admin.css depends on it so the CSS vars are defined first.
378 $theme_css = XSPEED_DIR . 'assets/theme.css';
379 if ( file_exists( $theme_css ) ) {
380 wp_enqueue_style(
381 'xspeed-theme',
382 XSPEED_URL . 'assets/theme.css',
383 array(),
384 XSPEED_VERSION . '.' . filemtime( $theme_css )
385 );
386 }
387 if ( file_exists( $asset_css ) ) {
388 wp_enqueue_style(
389 'xspeed-admin',
390 XSPEED_URL . 'assets/admin.css',
391 array( 'xspeed-theme' ),
392 XSPEED_VERSION . '.' . filemtime( $asset_css )
393 );
394 }
395
396 /**
397 * Fires after the Free dashboard bundle is enqueued, before its
398 * config is localized. Pro hooks this to enqueue its own bundle
399 * with `xspeed-admin` as a dependency, so its panel
400 * registrations run after `window.XSpeedPro` is installed by
401 * Free's main.tsx.
402 *
403 * @since 1.5.0
404 */
405 do_action( 'xspeed_admin_enqueue', $hook );
406
407 // NOT wp_localize_script(). WP_Scripts::localize() casts every scalar
408 // to a string (wp-includes/class-wp-scripts.php: `(string) $value`),
409 // so an int arrives in JS as "21600" and a bool as "1" or "".
410 //
411 // That silently broke the Pro prewarm scheduler: it guards with
412 // `typeof gmtOffset === 'number'`, which a string fails, so the site's
413 // UTC offset was treated as 0 and every one-off warm was scheduled
414 // against UTC instead of site time — hours late on any non-UTC site,
415 // under a label that confidently read "Site time (UTC)".
416 //
417 // wp_add_inline_script() with wp_json_encode() preserves types, so
418 // numbers stay numbers and booleans stay booleans. Worth doing beyond
419 // the one field: every future numeric or boolean config value would
420 // hit the same trap. (#105)
421 self::print_config(
422 'XSpeedConfig',
423 array(
424 'restUrl' => esc_url_raw( rest_url( Rest_Api::NAMESPACE_V1 ) ),
425 'nonce' => wp_create_nonce( 'wp_rest' ),
426 'version' => XSPEED_VERSION,
427 'branding' => self::branding(),
428 // Site's UTC offset in seconds — so datetime pickers (e.g. the
429 // Pro prewarm scheduler) align with WP's own post-scheduling,
430 // which is site-time, not the admin's browser-local time.
431 'gmtOffset' => (int) round( (float) get_option( 'gmt_offset', 0 ) * HOUR_IN_SECONDS ),
432 // 'pro' when xspeed-pro is active + speaks our API
433 // version (see Tier_Registry); 'free' otherwise.
434 // 'trial' reserved for future license-server work.
435 'tier' => class_exists( '\\XSpeed\\Tier_Registry' ) && Tier_Registry::pro_active() ? 'pro' : 'free',
436 // Three-state Pro status so gated UI can show the RIGHT message:
437 // 'not_installed' — Pro plugin not active → "Upgrade to Pro"
438 // 'unlicensed' — Pro active, no valid license → "Activate license"
439 // 'active' — Pro active + licensed → (modules unlocked)
440 // Free can't read Pro's license directly (Free never references
441 // Pro), so Pro filters this via `xspeed_pro_state`. Default:
442 // not_installed when Pro is absent; 'active' when Pro is present
443 // (Pro downgrades to 'unlicensed' when its license isn't valid).
444 'proState' => self::pro_state(),
445 // The xCloud purge plugin is here: the site already has
446 // Cloudflare Enterprise from its host, so the dashboard does
447 // not offer the add-on.
448 'xcloudPurgePlugin' => Managed_Edge_Purge::installed(),
449 // Setup Wizard URL, surfaced in the sidebar profile popover.
450 'wizardUrl' => admin_url( 'admin.php?page=' . Onboarding::PAGE_SLUG ),
451 'bootstrap' => self::bootstrap_payload(),
452 )
453 );
454 }
455
456 /**
457 * Emit a JS global for the admin bundle with types intact.
458 *
459 * The type-preserving replacement for wp_localize_script(), which
460 * stringifies every scalar. Attached to the `xspeed-admin` handle as a
461 * `before` script so it is defined by the time the bundle executes —
462 * exactly the ordering guarantee localize gave us.
463 *
464 * Shared by the dashboard and the onboarding wizard so neither can drift
465 * back to the stringifying path.
466 *
467 * @param string $var_name JS global to define.
468 * @param array $data Payload; encoded with wp_json_encode().
469 */
470 public static function print_config( string $var_name, array $data ): void {
471 $json = wp_json_encode( $data );
472 if ( false === $json ) {
473 // Never emit a broken assignment — the bundle reads this global
474 // on mount and a syntax error here blanks the whole screen.
475 $json = '{}';
476 }
477 wp_add_inline_script(
478 'xspeed-admin',
479 'var ' . $var_name . ' = ' . $json . ';',
480 'before'
481 );
482 }
483
484 /**
485 * Pre-rendered settings + status payload, baked into the page so the
486 * React app can mount with real values instead of showing a loading state
487 * while it waits for /settings and /status REST calls.
488 */
489 /**
490 * Three-state Pro status for the gated UI ('not_installed' | 'unlicensed'
491 * | 'active'). Free cannot read Pro's license (it never references Pro),
492 * so the authoritative value comes from the `xspeed_pro_state` filter that
493 * xspeed-pro hooks. The default here only distinguishes installed vs not —
494 * when Pro is active, Pro itself downgrades the value to 'unlicensed' if
495 * its license isn't valid.
496 *
497 * @return string
498 */
499 private static function pro_state(): string {
500 $pro_present = class_exists( '\\XSpeed\\Tier_Registry' ) && Tier_Registry::pro_active();
501 $default = $pro_present ? 'active' : 'not_installed';
502
503 /**
504 * Filter: xspeed_pro_state
505 *
506 * Lets the Pro plugin report its real license state so Free's gated
507 * panels can show "Activate license" (Pro installed, unlicensed) vs
508 * "Upgrade to Pro" (Pro not installed).
509 *
510 * @param string $state One of 'not_installed' | 'unlicensed' | 'active'.
511 */
512 $state = (string) apply_filters( 'xspeed_pro_state', $default );
513
514 return in_array( $state, array( 'not_installed', 'unlicensed', 'active' ), true ) ? $state : $default;
515 }
516
517 private static function bootstrap_payload() {
518 $opts = Settings::get();
519 $stats = Cache::get_stats();
520 // Static-rewrite probe state shipped to the React side so the
521 // dashboard can show a persistent banner when nginx/Apache
522 // hasn't been wired to bypass PHP yet. Cache-ONLY here (no $allow_probe
523 // arg) so the dashboard bootstrap never makes the loopback HTTP probe
524 // — that could add seconds to every admin page load on hosts that
525 // stall self-requests. The Health tab runs the live probe on demand;
526 // here we just surface whatever it last cached. (FBS-82142)
527 $server_type = Server::type();
528 // LiteSpeed serves hits via the PHP drop-in by design (its .htaccess
529 // can't add the HIT header or log a static hit), so the static-rewrite
530 // probe is N/A there — surfacing it would pop the "PHP fallback" nag
531 // for a setup working as designed. Only nginx + Apache probe.
532 $rewrite_capable = ( $server_type === Server::NGINX || $server_type === Server::APACHE );
533 $rewrite_probe = null;
534 if ( $opts['cache_enabled'] && $rewrite_capable ) {
535 $probe = Cache::probe_static_rewrite();
536 $rewrite_probe = array(
537 'active' => (bool) ( $probe['active'] ?? false ),
538 'server_type' => $server_type,
539 'snippet' => Cache::nginx_snippet(), // null on non-nginx hosts
540 'topology' => Server::rewrite_topology(),
541 // A reverse proxy / CDN in front (X-Forwarded-* present) usually
542 // means the request-terminating nginx isn't user-editable on this
543 // host — the banner uses this to switch to honest messaging
544 // instead of dangling a snippet the user can't apply.
545 'behind_proxy' => Server::is_behind_proxy(),
546 // Mirrors /status (see the note there). On first paint the
547 // probe is whatever was last cached, so this is usually
548 // `unknown` until the Health tab runs a live probe — the
549 // dashboard needs the key present either way.
550 'rules' => Cache::rules_state( $probe ),
551 );
552 }
553
554 return array(
555 'settings' => $opts,
556 'status' => array(
557 'enabled' => (bool) $opts['cache_enabled'],
558 'stats' => $stats,
559 'server' => array(
560 'type' => $server_type,
561 'gzip_mode' => Server::gzip_mode(),
562 'gzip_active' => Gzip::probe_active(),
563 'nginx_snippet' => Gzip::nginx_snippet(),
564 ),
565 'rewrite_probe' => $rewrite_probe,
566 // Separate Mobile Cache visibility (FBS-83145) — mirrors the
567 // /status block so the dashboard callout renders on first paint
568 // without waiting for a status re-fetch.
569 'mobile_separate' => array(
570 'enabled' => (bool) ( $opts['cache_enabled'] ? ( Settings_Manager::get( 'cache' )['mobile_separate'] ?? false ) : false ),
571 // Gated to servers that HAVE a static fast path — on IIS or
572 // an undetected server block_reason still falls through to
573 // mobile_separate, and reporting that as "blocking" would
574 // nag about a rewrite that does not exist there (#108).
575 // LiteSpeed joined the capable set with the Static Fast
576 // Path opt-in (#509); its own refusal (litespeed_dropin)
577 // outranks mobile_separate, so this stays false there
578 // until the opt-in is on.
579 'blocking' => ( $rewrite_capable || Server::LITESPEED === $server_type )
580 && 'mobile_separate' === Cache::static_rewrite_block_reason(),
581 'needs_review' => Cache::mobile_separate_needs_review(),
582 ),
583 // One consolidated nginx server-block snippet aggregating
584 // every enabled module's directives (Cache static-rewrite,
585 // BrowserCache headers, GZIP, …). Null on non-nginx hosts
586 // or when no module contributes directives. Replaces the
587 // per-module "paste this snippet" notices.
588 'nginx_server_block' => Cache::full_nginx_server_block(),
589 // Mirrors the /status block so the enable-time disclosure is
590 // correct on FIRST PAINT. Without it the top-bar switch can be
591 // clicked before a status fetch lands, and the one moment the
592 // warning exists for — a leftover drop-in about to be
593 // replaced — is exactly when it would be missing.
594 'dropin' => Page_Cache_Detector::dropin_disclosure(),
595 ),
596 // Registered Modules (Free + Pro). The React app discovers them
597 // here and renders one sidebar item + one panel per module that
598 // declares a settings schema. Hidden modules are filtered.
599 'modules' => self::modules_payload(),
600 // xSpeed Hub connection snapshot so the Account panel + header chip
601 // render the correct connected/not-connected state on FIRST PAINT —
602 // no fetch, no "not connected → connected" flash. Null when the MCP
603 // module is unavailable (the panel then falls back to /mcp/hub).
604 'hub' => self::hub_payload(),
605 );
606 }
607
608 /**
609 * xSpeed Hub connection snapshot for the dashboard bootstrap. Guarded so the
610 * dashboard never hard-depends on the MCP module. Same shape as GET /mcp/hub.
611 *
612 * @return array<string,mixed>|null
613 */
614 private static function hub_payload() {
615 if ( ! class_exists( '\XSpeed\Modules\Mcp\Mcp_Hub' ) ) {
616 return null;
617 }
618 return \XSpeed\Modules\Mcp\Mcp_Hub::public_status();
619 }
620
621 /**
622 * Serialize every available Module for the React dashboard. Each entry
623 * carries enough to render: identity (slug + tier), UI metadata (label,
624 * icon, optional description), current settings, and the typed schema
625 * the panel uses to render controls.
626 *
627 * Modules that declare `hidden => true` in ui_metadata (e.g., engine
628 * modules with no user-facing settings) are skipped.
629 */
630 public static function modules_payload() {
631 if ( ! class_exists( 'XSpeed\\Module_Registry' ) ) {
632 return array();
633 }
634 $out = array();
635 foreach ( Module_Registry::available() as $slug => $module ) {
636 $meta = $module->ui_metadata();
637 if ( ! empty( $meta['hidden'] ) ) {
638 continue;
639 }
640 $schema = $module->settings_schema();
641 $custom_panel = $meta['custom_panel'] ?? null;
642 // Skip only when the module has neither a schema nor a custom
643 // panel — i.e., truly nothing to render in the dashboard.
644 if ( empty( $schema ) && empty( $custom_panel ) ) {
645 continue;
646 }
647 $settings = Settings_Manager::get_public( $slug );
648 // Something else on the site has taken over this module's job. It
649 // stands down whatever its own switch says, so it is reported off,
650 // with the reason in place of its usual one.
651 $blocked = $module->blocked_by();
652 $active = $module->is_active();
653
654 $entry = array(
655 'slug' => $slug,
656 'tier' => $module->tier(),
657 'version' => $module->version(),
658 // Promoted from inside `settings` so the payload is
659 // self-describing. Consumers kept tripping on this — the Hub
660 // rendered every module "Inactive" until it learned to look
661 // inside the settings bag. `settings.enabled` is kept below
662 // for back-compat; this is the same value, not a second
663 // source of truth. Modules with no `enabled` key (status
664 // panels like Health) report null rather than a misleading
665 // false. (#146)
666 'enabled' => array_key_exists( 'enabled', $settings )
667 ? (bool) $settings['enabled']
668 : null,
669 // Whether the module is actually DOING something, which is not
670 // the same question as `enabled` above. "On" has several shapes
671 // -- page caching lives in the global option, Minify and Lazy
672 // are on when any flag is set, MCP when it is connected -- so
673 // each module answers for itself via is_active(). Consumers
674 // that want "what is switched on?" (the sidebar's "N on" badge)
675 // must read THIS, not `enabled`, which only ever described the
676 // modules that happen to store that one key. null means the
677 // module has no meaningful on/off and should be excluded from
678 // any count rather than treated as off. (#363)
679 'active' => ( null !== $blocked && true === $active ) ? false : $active,
680 // One sentence explaining the line above, computed next to it
681 // so the two cannot disagree. The UI shows it behind an (i)
682 // beside the status pill: "On" is a bare assertion otherwise,
683 // and least obvious exactly where it matters -- Media
684 // Optimization reads On while its two most prominent switches
685 // are off, because three other flags are on. (#363)
686 'active_reason' => null !== $blocked ? $blocked : $module->active_reason(),
687 // A purchase or licence the module still waits on. Shown in the
688 // page header in place of the On/Off pill. Not while another
689 // module has taken over: that reason is the one to show.
690 'status_label' => null !== $blocked ? null : $module->status_label(),
691 'label' => $meta['label'] ?? ucfirst( $slug ),
692 'icon' => $meta['icon'] ?? 'Square',
693 'description' => $meta['description'] ?? '',
694 // The dashboard group the module belongs to (cache, performance,
695 // network, insights, tools, ai-agents, settings), declared by the
696 // module itself. The Hub kept its own copy of this map, so every
697 // new module landed in its "Other" bucket until the Hub shipped.
698 // Must agree with src/components/sidebarGroups.ts; a unit test
699 // holds the two together.
700 'group' => $meta['group'] ?? null,
701 // Short label for the module's own tab when it hosts a tabbed
702 // page (FBS-83633). Only set on host modules.
703 'tab_label' => $meta['tab_label'] ?? null,
704 // Public view: real values except secret fields, which are masked.
705 // The dashboard bundle localizes this into page HTML, so a raw
706 // credential here would be readable from view-source. (#115)
707 'settings' => $settings,
708 // Where each value actually came from: a wp-config.php constant,
709 // the option row, or the schema default. The panel renders a
710 // constant-sourced field read-only and names the constant, so it
711 // can never present an editable box over a value the site is not
712 // using. Every module gets this, not just the ones that declare
713 // constants today. (#398)
714 'setting_origins' => Settings_Manager::origins( $slug ),
715 'schema' => $schema,
716 'notices' => $module->ui_notices(),
717 'custom_panel' => $meta['custom_panel'] ?? null,
718 // Why this module may not be switched on, when something else
719 // on the site has taken over from it. Null for almost every
720 // module almost always — Free never blocks anything itself.
721 'blocked_by' => $blocked,
722 );
723
724 /**
725 * Last-mile descriptor filter. Lets Pro (or third-party
726 * extensions) override any field before the module is
727 * shipped to React. Primary use: xspeed-pro hooks this to
728 * swap `custom_panel` to LicenseLockedPanel for Pro modules
729 * when the license is invalid, so unlocked modules stay
730 * visible in the sidebar (good upsell UX) but the panel
731 * shows an activation prompt instead of the real surface.
732 */
733 $out[] = apply_filters( 'xspeed_module_descriptor', $entry, $module );
734 }
735 return $out;
736 }
737 }
738