PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.2
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.2
1.3.3 1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 1.1.8 All 29 releases
← All changes | includes/class-admin.php +399 -41 1.0.21.3.2 View file →
@@ -19,11 +19,62 @@
19 19 add_action( 'admin_menu', array( $this, 'register_menu' ) );
20 20 add_action( 'admin_enqueue_scripts', array( $this, 'enqueue' ) );
21 21 add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_menu_styles' ) );
22 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 );
23 30 }
24 31
25 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 + /**
26 77 * Server-side theme detection from the cookie written by useTheme. Used
27 78 * to emit the `.dark` class on the React mount node and the
28 79 * `xspeed-dark` class on `<body>` during the initial render — kills the
29 80 * light→dark flash that happens when JS adds those classes after the
@@ -37,17 +88,30 @@
37 88 return 'dark' === sanitize_key( $raw ) ? 'dark' : 'light';
38 89 }
39 90
40 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.
41 107 $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
42 108 if ( ! $screen ) {
43 109 return false;
44 110 }
45 - // Toplevel page and any submenu (e.g., the onboarding wizard) share
46 - // the `xspeed` prefix in their screen base.
47 - return 0 === strpos( (string) $screen->base, 'toplevel_page_' . self::PAGE_SLUG )
48 - || 0 === strpos( (string) $screen->base, self::PAGE_SLUG . '_page_' )
49 - || 0 === strpos( (string) $screen->base, 'admin_page_' . Onboarding::PAGE_SLUG );
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 );
50 114 }
51 115
52 116 public static function admin_body_class( $classes ) {
53 117 if ( ! self::is_plugin_page() ) {
@@ -53,8 +117,18 @@
53 117 if ( ! self::is_plugin_page() ) {
54 118 return $classes;
55 119 }
56 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 + }
57 131 if ( 'dark' === self::user_theme() ) {
58 132 $classes .= ' xspeed-dark';
59 133 }
60 134 return $classes;
@@ -59,32 +133,63 @@
59 133 }
60 134 return $classes;
61 135 }
62 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 +
63 161 public function register_menu() {
64 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 );
65 173 add_menu_page(
66 174 $brand['name'],
67 - $brand['name'],
175 + $menu_label,
68 176 'manage_options',
69 177 self::PAGE_SLUG,
70 178 array( $this, 'render' ),
71 - self::menu_icon(),
72 - 80
179 + $menu_icon,
180 + 61
73 181 );
74 182
75 - // Rename the auto-generated first submenu (which inherits the
76 - // toplevel title "xSpeed") to "Dashboard" — keeps the same slug so
77 - // it stays the default landing page.
78 - add_submenu_page(
79 - self::PAGE_SLUG,
80 - /* translators: %s = brand name (xSpeed by default; agencies may white-label). */
81 - sprintf( __( '%s Dashboard', 'xspeed' ), $brand['name'] ),
82 - __( 'Dashboard', 'xspeed' ),
83 - 'manage_options',
84 - self::PAGE_SLUG,
85 - array( $this, 'render' )
86 - );
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 );
87 192
88 193 // Deep-link submenus — each points to the same dashboard page
89 194 // with a section hash so React's App.tsx routing lands the
90 195 // user inside the right module. WordPress's add_submenu_page
@@ -102,20 +207,41 @@
102 207 }
103 208 }
104 209
105 210 /**
106 - * Section deep-links rendered under the xSpeed menu. Keep the list
107 - * short — one entry per top-level concern, not per module. Anything
108 - * over ~5 entries clutters the WP admin menu rail.
211 + * Section deep-links rendered under the xSpeed menu.
109 212 *
110 - * @return array<string,string> map of hash → label.
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.
111 234 */
112 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.
113 240 return array(
114 - 'cache' => __( 'Cache', 'xspeed' ),
115 - 'health' => __( 'Health', 'xspeed' ),
116 - 'minify' => __( 'Performance', 'xspeed' ),
117 - 'database' => __( 'Tools', 'xspeed' ),
241 + '/overview' => __( 'Overview', 'xspeed' ),
242 + '/ai-agents' => __( 'AI & agents', 'xspeed' ),
243 + '/settings' => __( 'Settings', 'xspeed' ),
118 244 );
119 245 }
120 246
121 247 /**
@@ -127,9 +253,9 @@
127 253 * @since 1.5.0
128 254 */
129 255 public static function branding() {
130 256 $defaults = array(
131 - 'name' => 'xSpeed',
257 + 'name' => 'xSpeed Cache',
132 258 'footer_credit' => null, // null = show the default WPDeveloper credit.
133 259 'hide_help_links' => false,
134 260 'logo_svg' => null, // null = use the built-in brand mark.
135 261 );
@@ -140,8 +266,19 @@
140 266 return array_merge( $defaults, $out );
141 267 }
142 268
143 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 + /**
144 281 * URL of the SVG menu icon — the official xSpeed brand mark. Uses
145 282 * fill="currentColor" which renders black in <img> context; the inline
146 283 * style below recolors it via CSS filter for the WP admin menu states.
147 284 */
@@ -150,9 +287,23 @@
150 287 }
151 288
152 289 public function render() {
153 290 $dark = 'dark' === self::user_theme() ? ' dark' : '';
154 - printf( '<div id="xspeed-app" class="xspeed-root%s"></div>', esc_attr( $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 + );
155 306 }
156 307
157 308 /**
158 309 * Enqueue the stylesheet that recolors the menu icon to match the WP
@@ -165,8 +316,20 @@
165 316 XSPEED_URL . 'assets/menu-icon.css',
166 317 array(),
167 318 XSPEED_VERSION
168 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 + }
169 332 }
170 333
171 334 public function enqueue( $hook ) {
172 335 if ( 'toplevel_page_' . self::PAGE_SLUG !== $hook ) {
@@ -175,8 +338,12 @@
175 338
176 339 $asset_js = XSPEED_DIR . 'assets/admin.js';
177 340 $asset_css = XSPEED_DIR . 'assets/admin.css';
178 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 +
179 346 if ( file_exists( $asset_js ) ) {
180 347 // filemtime() cache-busts on every rebuild so a stable
181 348 // VERSION constant never serves stale JS through browser
182 349 // caches.
@@ -197,13 +364,25 @@
197 364 );
198 365 }
199 366 }
200 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 + }
201 380 if ( file_exists( $asset_css ) ) {
202 381 wp_enqueue_style(
203 382 'xspeed-admin',
204 383 XSPEED_URL . 'assets/admin.css',
205 - array(),
384 + array( 'xspeed-theme' ),
206 385 XSPEED_VERSION . '.' . filemtime( $asset_css )
207 386 );
208 387 }
209 388
@@ -217,10 +396,23 @@
217 396 * @since 1.5.0
218 397 */
219 398 do_action( 'xspeed_admin_enqueue', $hook );
220 399
221 - wp_localize_script(
222 - 'xspeed-admin',
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(
223 415 'XSpeedConfig',
224 416 array(
225 417 'restUrl' => esc_url_raw( rest_url( Rest_Api::NAMESPACE_V1 ) ),
226 418 'nonce' => wp_create_nonce( 'wp_rest' ),
@@ -225,12 +417,27 @@
225 417 'restUrl' => esc_url_raw( rest_url( Rest_Api::NAMESPACE_V1 ) ),
226 418 'nonce' => wp_create_nonce( 'wp_rest' ),
227 419 'version' => XSPEED_VERSION,
228 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 ),
229 425 // 'pro' when xspeed-pro is active + speaks our API
230 426 // version (see Tier_Registry); 'free' otherwise.
231 427 // 'trial' reserved for future license-server work.
232 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 ),
233 440 'bootstrap' => self::bootstrap_payload(),
234 441 )
235 442 );
236 443 }
@@ -235,28 +442,97 @@
235 442 );
236 443 }
237 444
238 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 + /**
239 474 * Pre-rendered settings + status payload, baked into the page so the
240 475 * React app can mount with real values instead of showing a loading state
241 476 * while it waits for /settings and /status REST calls.
242 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 +
243 506 private static function bootstrap_payload() {
244 507 $opts = Settings::get();
245 508 $stats = Cache::get_stats();
246 509 // Static-rewrite probe state shipped to the React side so the
247 510 // dashboard can show a persistent banner when nginx/Apache
248 - // hasn't been wired to bypass PHP yet. Cheap — Cache::probe…
249 - // is transient-throttled to one HTTP round-trip per 5 min.
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)
250 516 $server_type = Server::type();
251 - $rewrite_capable = ( $server_type === Server::NGINX || $server_type === Server::APACHE || $server_type === Server::LITESPEED );
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 );
252 522 $rewrite_probe = null;
253 523 if ( $opts['cache_enabled'] && $rewrite_capable ) {
254 524 $probe = Cache::probe_static_rewrite();
255 525 $rewrite_probe = array(
256 - 'active' => (bool) ( $probe['active'] ?? false ),
257 - 'server_type' => $server_type,
258 - 'snippet' => Cache::nginx_snippet(), // null on non-nginx hosts
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(),
259 535 );
260 536 }
261 537
262 538 return array(
@@ -270,17 +546,55 @@
270 546 'gzip_active' => Gzip::probe_active(),
271 547 'nginx_snippet' => Gzip::nginx_snippet(),
272 548 ),
273 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(),
274 570 ),
275 571 // Registered Modules (Free + Pro). The React app discovers them
276 572 // here and renders one sidebar item + one panel per module that
277 573 // declares a settings schema. Hidden modules are filtered.
278 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(),
279 580 );
280 581 }
281 582
282 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 + /**
283 597 * Serialize every available Module for the React dashboard. Each entry
284 598 * carries enough to render: identity (slug + tier), UI metadata (label,
285 599 * icon, optional description), current settings, and the typed schema
286 600 * the panel uses to render controls.
@@ -287,9 +601,9 @@
287 601 *
288 602 * Modules that declare `hidden => true` in ui_metadata (e.g., engine
289 603 * modules with no user-facing settings) are skipped.
290 604 */
291 - private static function modules_payload() {
605 + public static function modules_payload() {
292 606 if ( ! class_exists( 'XSpeed\\Module_Registry' ) ) {
293 607 return array();
294 608 }
295 609 $out = array();
@@ -304,16 +618,60 @@
304 618 // panel — i.e., truly nothing to render in the dashboard.
305 619 if ( empty( $schema ) && empty( $custom_panel ) ) {
306 620 continue;
307 621 }
622 + $settings = Settings_Manager::get_public( $slug );
623 +
308 624 $entry = array(
309 625 'slug' => $slug,
310 626 'tier' => $module->tier(),
311 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(),
312 657 'label' => $meta['label'] ?? ucfirst( $slug ),
313 658 'icon' => $meta['icon'] ?? 'Square',
314 659 'description' => $meta['description'] ?? '',
315 - 'settings' => Settings_Manager::get( $slug ),
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 ),
316 674 'schema' => $schema,
317 675 'notices' => $module->ui_notices(),
318 676 'custom_panel' => $meta['custom_panel'] ?? null,
319 677 );