PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.0
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.0
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 1.1.6 1.1.7 1.1.8 1.2.0 All 28 releases
xspeed / includes / class-admin.php

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

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