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

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

602 lines 24.0 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. Mirrors the
212 * React sidebar's group manifest (see src/components/sidebarGroups.ts)
213 * so the WP admin rail reads as the same map as the in-app sidebar.
214 * Each entry points at the first module slug in that group; the
215 * React app's hash router lands the user on that module, which is
216 * the first row of that group's sidebar section — no manual scrolling.
217 *
218 * If you add a group to React's SIDEBAR_GROUPS, add it here too. The
219 * two arrays are intentionally co-located in PR review (same change
220 * touches both) rather than DRY'd through a generated config file —
221 * this is the only PHP↔TS coupling and it's tiny.
222 *
223 * @return array<string,string> map of first-module-hash → group label.
224 */
225 private static function deep_link_items() {
226 // Mirrors SIDEBAR_GROUPS (sidebarGroups.ts) 1:1, in the same order —
227 // the only PHP↔TS coupling. Redesign v2: groups are LANDING pages, so
228 // each key is the group's route hash (`/<group-id>`), not a module
229 // slug; the submenu loop prepends '#'. Overview is pinned first. React
230 // (nav.ts parseRoute) resolves `#/<group-id>` to that landing; legacy
231 // `#slug` links still redirect, so old bookmarks keep working.
232 return array(
233 '/overview' => __( 'Overview', 'xspeed' ),
234 '/cache' => __( 'Cache', 'xspeed' ),
235 '/performance' => __( 'Optimization', 'xspeed' ),
236 '/network' => __( 'Network', 'xspeed' ),
237 '/insights' => __( 'Health & insights', 'xspeed' ),
238 '/tools' => __( 'Tools', 'xspeed' ),
239 '/ai-agents' => __( 'AI & agents', 'xspeed' ),
240 '/settings' => __( 'Settings', 'xspeed' ),
241 );
242 }
243
244 /**
245 * Pluggable branding for the dashboard chrome. xspeed-pro's
246 * White-Label module hooks `xspeed_branding` to override these
247 * values from saved settings.
248 *
249 * @return array{name:string,footer_credit:?string,hide_help_links:bool,logo_svg:?string}
250 * @since 1.5.0
251 */
252 public static function branding() {
253 $defaults = array(
254 'name' => 'xSpeed Cache',
255 'footer_credit' => null, // null = show the default WPDeveloper credit.
256 'hide_help_links' => false,
257 'logo_svg' => null, // null = use the built-in brand mark.
258 );
259 $out = apply_filters( 'xspeed_branding', $defaults );
260 if ( ! is_array( $out ) ) {
261 return $defaults;
262 }
263 return array_merge( $defaults, $out );
264 }
265
266 /**
267 * Label for the WordPress admin sidebar menu entry — the resolved brand
268 * name verbatim ("xSpeed Cache" by default, or the white-label name).
269 *
270 * @param array{name:string} $brand Resolved branding array.
271 * @return string
272 */
273 private static function menu_label( array $brand ) {
274 return isset( $brand['name'] ) ? (string) $brand['name'] : 'xSpeed Cache';
275 }
276
277 /**
278 * URL of the SVG menu icon — the official xSpeed brand mark. Uses
279 * fill="currentColor" which renders black in <img> context; the inline
280 * style below recolors it via CSS filter for the WP admin menu states.
281 */
282 private static function menu_icon() {
283 return XSPEED_URL . 'assets/icon.svg';
284 }
285
286 public function render() {
287 $dark = 'dark' === self::user_theme() ? ' dark' : '';
288 // Pre-mount skeleton: the React bundle executes a beat after the page
289 // paints, so without this the mount div is an empty dark void while it
290 // loads. We render the xSpeed brand mark (pulsing) straight into the
291 // mount node in PHP; createRoot().render() REPLACES these children the
292 // instant React boots, so the skeleton disappears with no JS wiring.
293 // The mark is the bundled icon.svg; `dark:invert` (scoped in
294 // styles.css) keeps the currentColor mark visible on the dark shell.
295 printf(
296 '<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>',
297 esc_attr( $dark ),
298 esc_attr__( 'Loading', 'xspeed' ),
299 esc_url( XSPEED_URL . 'assets/icon.svg' ),
300 esc_attr__( 'xSpeed', 'xspeed' ),
301 esc_html__( 'Loading…', 'xspeed' )
302 );
303 }
304
305 /**
306 * Enqueue the stylesheet that recolors the menu icon to match the WP
307 * admin color scheme. Loads on every admin page (not just the plugin's
308 * page) because the menu icon is visible site-wide.
309 */
310 public function enqueue_menu_styles() {
311 wp_enqueue_style(
312 'xspeed-menu-icon',
313 XSPEED_URL . 'assets/menu-icon.css',
314 array(),
315 XSPEED_VERSION
316 );
317
318 // menu-icon.css recolors the built-in mark (fill=currentColor) to white
319 // via a brightness/invert filter. A white-label logo is a real image
320 // (often colored), so cancel the filter for it — otherwise the agency
321 // logo renders as a white silhouette. (FBS-82222)
322 $brand = self::branding();
323 if ( ! empty( $brand['logo_svg'] ) ) {
324 wp_add_inline_style(
325 'xspeed-menu-icon',
326 '#toplevel_page_' . self::PAGE_SLUG . ' .wp-menu-image img{filter:none;opacity:1}'
327 );
328 }
329 }
330
331 public function enqueue( $hook ) {
332 if ( 'toplevel_page_' . self::PAGE_SLUG !== $hook ) {
333 return;
334 }
335
336 $asset_js = XSPEED_DIR . 'assets/admin.js';
337 $asset_css = XSPEED_DIR . 'assets/admin.css';
338
339 // Needed for the media-library picker used by schema 'media' fields
340 // (e.g. the white-label brand logo). Loads window.wp.media.
341 wp_enqueue_media();
342
343 if ( file_exists( $asset_js ) ) {
344 // filemtime() cache-busts on every rebuild so a stable
345 // VERSION constant never serves stale JS through browser
346 // caches.
347 wp_enqueue_script(
348 'xspeed-admin',
349 XSPEED_URL . 'assets/admin.js',
350 array( 'wp-api-fetch', 'wp-i18n' ),
351 XSPEED_VERSION . '.' . filemtime( $asset_js ),
352 true
353 );
354 // Loads .mo files for the 'xspeed' text-domain into
355 // window.wp.i18n so the React `__()` helper resolves.
356 if ( function_exists( 'wp_set_script_translations' ) ) {
357 wp_set_script_translations(
358 'xspeed-admin',
359 'xspeed',
360 XSPEED_DIR . 'languages'
361 );
362 }
363 }
364
365 // Redesign v2 design tokens + self-hosted fonts. Hand-written (not
366 // Vite-bundled) so the @font-face url('./fonts/…') resolve relative to
367 // assets/. admin.css depends on it so the CSS vars are defined first.
368 $theme_css = XSPEED_DIR . 'assets/theme.css';
369 if ( file_exists( $theme_css ) ) {
370 wp_enqueue_style(
371 'xspeed-theme',
372 XSPEED_URL . 'assets/theme.css',
373 array(),
374 XSPEED_VERSION . '.' . filemtime( $theme_css )
375 );
376 }
377 if ( file_exists( $asset_css ) ) {
378 wp_enqueue_style(
379 'xspeed-admin',
380 XSPEED_URL . 'assets/admin.css',
381 array( 'xspeed-theme' ),
382 XSPEED_VERSION . '.' . filemtime( $asset_css )
383 );
384 }
385
386 /**
387 * Fires after the Free dashboard bundle is enqueued, before its
388 * config is localized. Pro hooks this to enqueue its own bundle
389 * with `xspeed-admin` as a dependency, so its panel
390 * registrations run after `window.XSpeedPro` is installed by
391 * Free's main.tsx.
392 *
393 * @since 1.5.0
394 */
395 do_action( 'xspeed_admin_enqueue', $hook );
396
397 wp_localize_script(
398 'xspeed-admin',
399 'XSpeedConfig',
400 array(
401 'restUrl' => esc_url_raw( rest_url( Rest_Api::NAMESPACE_V1 ) ),
402 'nonce' => wp_create_nonce( 'wp_rest' ),
403 'version' => XSPEED_VERSION,
404 'branding' => self::branding(),
405 // Site's UTC offset in seconds — so datetime pickers (e.g. the
406 // Pro prewarm scheduler) align with WP's own post-scheduling,
407 // which is site-time, not the admin's browser-local time.
408 'gmtOffset' => (int) round( (float) get_option( 'gmt_offset', 0 ) * HOUR_IN_SECONDS ),
409 // 'pro' when xspeed-pro is active + speaks our API
410 // version (see Tier_Registry); 'free' otherwise.
411 // 'trial' reserved for future license-server work.
412 'tier' => class_exists( '\\XSpeed\\Tier_Registry' ) && Tier_Registry::pro_active() ? 'pro' : 'free',
413 // Three-state Pro status so gated UI can show the RIGHT message:
414 // 'not_installed' — Pro plugin not active → "Upgrade to Pro"
415 // 'unlicensed' — Pro active, no valid license → "Activate license"
416 // 'active' — Pro active + licensed → (modules unlocked)
417 // Free can't read Pro's license directly (Free never references
418 // Pro), so Pro filters this via `xspeed_pro_state`. Default:
419 // not_installed when Pro is absent; 'active' when Pro is present
420 // (Pro downgrades to 'unlicensed' when its license isn't valid).
421 'proState' => self::pro_state(),
422 // Setup Wizard URL, surfaced in the sidebar profile popover.
423 'wizardUrl' => admin_url( 'admin.php?page=' . Onboarding::PAGE_SLUG ),
424 'bootstrap' => self::bootstrap_payload(),
425 )
426 );
427 }
428
429 /**
430 * Pre-rendered settings + status payload, baked into the page so the
431 * React app can mount with real values instead of showing a loading state
432 * while it waits for /settings and /status REST calls.
433 */
434 /**
435 * Three-state Pro status for the gated UI ('not_installed' | 'unlicensed'
436 * | 'active'). Free cannot read Pro's license (it never references Pro),
437 * so the authoritative value comes from the `xspeed_pro_state` filter that
438 * xspeed-pro hooks. The default here only distinguishes installed vs not —
439 * when Pro is active, Pro itself downgrades the value to 'unlicensed' if
440 * its license isn't valid.
441 *
442 * @return string
443 */
444 private static function pro_state(): string {
445 $pro_present = class_exists( '\\XSpeed\\Tier_Registry' ) && Tier_Registry::pro_active();
446 $default = $pro_present ? 'active' : 'not_installed';
447
448 /**
449 * Filter: xspeed_pro_state
450 *
451 * Lets the Pro plugin report its real license state so Free's gated
452 * panels can show "Activate license" (Pro installed, unlicensed) vs
453 * "Upgrade to Pro" (Pro not installed).
454 *
455 * @param string $state One of 'not_installed' | 'unlicensed' | 'active'.
456 */
457 $state = (string) apply_filters( 'xspeed_pro_state', $default );
458
459 return in_array( $state, array( 'not_installed', 'unlicensed', 'active' ), true ) ? $state : $default;
460 }
461
462 private static function bootstrap_payload() {
463 $opts = Settings::get();
464 $stats = Cache::get_stats();
465 // Static-rewrite probe state shipped to the React side so the
466 // dashboard can show a persistent banner when nginx/Apache
467 // hasn't been wired to bypass PHP yet. Cache-ONLY here (no $allow_probe
468 // arg) so the dashboard bootstrap never makes the loopback HTTP probe
469 // — that could add seconds to every admin page load on hosts that
470 // stall self-requests. The Health tab runs the live probe on demand;
471 // here we just surface whatever it last cached. (FBS-82142)
472 $server_type = Server::type();
473 // LiteSpeed serves hits via the PHP drop-in by design (its .htaccess
474 // can't add the HIT header or log a static hit), so the static-rewrite
475 // probe is N/A there — surfacing it would pop the "PHP fallback" nag
476 // for a setup working as designed. Only nginx + Apache probe.
477 $rewrite_capable = ( $server_type === Server::NGINX || $server_type === Server::APACHE );
478 $rewrite_probe = null;
479 if ( $opts['cache_enabled'] && $rewrite_capable ) {
480 $probe = Cache::probe_static_rewrite();
481 $rewrite_probe = array(
482 'active' => (bool) ( $probe['active'] ?? false ),
483 'server_type' => $server_type,
484 'snippet' => Cache::nginx_snippet(), // null on non-nginx hosts
485 'topology' => Server::rewrite_topology(),
486 // A reverse proxy / CDN in front (X-Forwarded-* present) usually
487 // means the request-terminating nginx isn't user-editable on this
488 // host — the banner uses this to switch to honest messaging
489 // instead of dangling a snippet the user can't apply.
490 'behind_proxy' => Server::is_behind_proxy(),
491 );
492 }
493
494 return array(
495 'settings' => $opts,
496 'status' => array(
497 'enabled' => (bool) $opts['cache_enabled'],
498 'stats' => $stats,
499 'server' => array(
500 'type' => $server_type,
501 'gzip_mode' => Server::gzip_mode(),
502 'gzip_active' => Gzip::probe_active(),
503 'nginx_snippet' => Gzip::nginx_snippet(),
504 ),
505 'rewrite_probe' => $rewrite_probe,
506 // Separate Mobile Cache visibility (FBS-83145) — mirrors the
507 // /status block so the dashboard callout renders on first paint
508 // without waiting for a status re-fetch.
509 'mobile_separate' => array(
510 'enabled' => (bool) ( $opts['cache_enabled'] ? ( Settings_Manager::get( 'cache' )['mobile_separate'] ?? false ) : false ),
511 'blocking' => $rewrite_capable && 'mobile_separate' === Cache::static_rewrite_block_reason(),
512 'needs_review' => Cache::mobile_separate_needs_review(),
513 ),
514 // One consolidated nginx server-block snippet aggregating
515 // every enabled module's directives (Cache static-rewrite,
516 // BrowserCache headers, GZIP, …). Null on non-nginx hosts
517 // or when no module contributes directives. Replaces the
518 // per-module "paste this snippet" notices.
519 'nginx_server_block' => Cache::full_nginx_server_block(),
520 ),
521 // Registered Modules (Free + Pro). The React app discovers them
522 // here and renders one sidebar item + one panel per module that
523 // declares a settings schema. Hidden modules are filtered.
524 'modules' => self::modules_payload(),
525 // xSpeed Hub connection snapshot so the Account panel + header chip
526 // render the correct connected/not-connected state on FIRST PAINT —
527 // no fetch, no "not connected → connected" flash. Null when the MCP
528 // module is unavailable (the panel then falls back to /mcp/hub).
529 'hub' => self::hub_payload(),
530 );
531 }
532
533 /**
534 * xSpeed Hub connection snapshot for the dashboard bootstrap. Guarded so the
535 * dashboard never hard-depends on the MCP module. Same shape as GET /mcp/hub.
536 *
537 * @return array<string,mixed>|null
538 */
539 private static function hub_payload() {
540 if ( ! class_exists( '\XSpeed\Modules\Mcp\Mcp_Hub' ) ) {
541 return null;
542 }
543 return \XSpeed\Modules\Mcp\Mcp_Hub::public_status();
544 }
545
546 /**
547 * Serialize every available Module for the React dashboard. Each entry
548 * carries enough to render: identity (slug + tier), UI metadata (label,
549 * icon, optional description), current settings, and the typed schema
550 * the panel uses to render controls.
551 *
552 * Modules that declare `hidden => true` in ui_metadata (e.g., engine
553 * modules with no user-facing settings) are skipped.
554 */
555 public static function modules_payload() {
556 if ( ! class_exists( 'XSpeed\\Module_Registry' ) ) {
557 return array();
558 }
559 $out = array();
560 foreach ( Module_Registry::available() as $slug => $module ) {
561 $meta = $module->ui_metadata();
562 if ( ! empty( $meta['hidden'] ) ) {
563 continue;
564 }
565 $schema = $module->settings_schema();
566 $custom_panel = $meta['custom_panel'] ?? null;
567 // Skip only when the module has neither a schema nor a custom
568 // panel — i.e., truly nothing to render in the dashboard.
569 if ( empty( $schema ) && empty( $custom_panel ) ) {
570 continue;
571 }
572 $entry = array(
573 'slug' => $slug,
574 'tier' => $module->tier(),
575 'version' => $module->version(),
576 'label' => $meta['label'] ?? ucfirst( $slug ),
577 'icon' => $meta['icon'] ?? 'Square',
578 'description' => $meta['description'] ?? '',
579 // Short label for the module's own tab when it hosts a tabbed
580 // page (FBS-83633). Only set on host modules.
581 'tab_label' => $meta['tab_label'] ?? null,
582 'settings' => Settings_Manager::get( $slug ),
583 'schema' => $schema,
584 'notices' => $module->ui_notices(),
585 'custom_panel' => $meta['custom_panel'] ?? null,
586 );
587
588 /**
589 * Last-mile descriptor filter. Lets Pro (or third-party
590 * extensions) override any field before the module is
591 * shipped to React. Primary use: xspeed-pro hooks this to
592 * swap `custom_panel` to LicenseLockedPanel for Pro modules
593 * when the license is invalid, so unlocked modules stay
594 * visible in the sidebar (good upsell UX) but the panel
595 * shows an activation prompt instead of the real surface.
596 */
597 $out[] = apply_filters( 'xspeed_module_descriptor', $entry, $module );
598 }
599 return $out;
600 }
601 }
602