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 / modules / Health / HealthModule.php

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

268 lines 11.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Health module — read-only diagnostic surface for the dashboard.
4 *
5 * No settings_schema (this is a status panel, not a configuration
6 * surface). The React side renders a custom panel (HealthCard, declared
7 * via `ui_metadata.custom_panel`) instead of going through ModulePanel's
8 * schema-driven path.
9 *
10 * Data sources are all existing services:
11 * - Health::checks() — diagnostic rows
12 * - Cache::get_stats() — cached_pages / size / last_purge /
13 * hits_24h / misses_24h / hit_ratio
14 * - Hit_Counter::buckets() — 24 hourly buckets for the sparkline
15 * - Activity_Log::entries() — newest-first event log
16 *
17 * Tier: Free (per FEATURES.md "Cache Insights" — Cache Performance +
18 * Last 24h chart are Free; Recommendations + Frequently-missed-URLs
19 * stay Pro).
20 *
21 * @package XSpeed
22 */
23
24 declare(strict_types=1);
25
26 namespace XSpeed\Modules\Health;
27
28 defined( 'ABSPATH' ) || exit;
29
30 use XSpeed\Activity_Log;
31 use XSpeed\Cache;
32 use XSpeed\Health;
33 use XSpeed\Hit_Counter;
34 use XSpeed\Module;
35
36 final class HealthModule extends Module {
37
38 public const SLUG = 'health';
39 public const TIER = self::TIER_FREE;
40 public const VERSION = '1.0.0';
41
42 /**
43 * Surface the most important diagnostics in WordPress's built-in
44 * Site Health screen (Tools → Site Health → Status). Admins who
45 * never open the xSpeed dashboard still get a heads-up when
46 * static-rewrite is missing on Apache/LiteSpeed or when the nginx
47 * snippet hasn't been pasted yet — both lead to a 5-10× slowdown
48 * vs the optimal cache hit path.
49 */
50 public function boot(): void {
51 add_filter( 'site_status_tests', array( $this, 'register_site_status_tests' ) );
52
53 // Out-of-band refresh of the Set-Cookie probe. Health::checks()
54 // only ever reads the cached verdict, so the HTTP round-trip
55 // happens here instead of inside a request the user waits on.
56 add_action( \XSpeed\Cookie_Inspector::CRON_HOOK, array( $this, 'refresh_cookie_probe' ) );
57 }
58
59 /** Cron callback: perform the real (blocking) probe off-request. */
60 public function refresh_cookie_probe(): void {
61 \XSpeed\Cookie_Inspector::probe( true );
62 }
63
64 public function register_site_status_tests( array $tests ): array {
65 $tests['direct']['xspeed_static_rewrite'] = array(
66 'label' => __( 'xSpeed static-rewrite cache', 'xspeed' ),
67 'test' => array( $this, 'site_status_static_rewrite' ),
68 );
69 return $tests;
70 }
71
72 /**
73 * Site Health test row. Reports green when the .htaccess block is
74 * present (Apache/LiteSpeed) or yellow with the nginx snippet
75 * embedded when nginx is detected. Skipped entirely when cache is
76 * disabled — no point telling the user to install a rewrite they
77 * haven't opted into.
78 */
79 public function site_status_static_rewrite(): array {
80 $result = array(
81 'label' => __( 'xSpeed static-rewrite cache is active', 'xspeed' ),
82 'status' => 'good',
83 'badge' => array(
84 'label' => __( 'Performance', 'xspeed' ),
85 'color' => 'blue',
86 ),
87 'description' => '<p>' . esc_html__( 'Cache hits bypass PHP for ~5-15ms TTFB.', 'xspeed' ) . '</p>',
88 'test' => 'xspeed_static_rewrite',
89 );
90
91 $cache_enabled = (bool) ( \XSpeed\Settings::get()['cache_enabled'] ?? false );
92 if ( ! $cache_enabled ) {
93 $result['label'] = __( 'xSpeed cache is disabled', 'xspeed' );
94 $result['status'] = 'recommended';
95 $result['description'] = '<p>' . esc_html__( 'Enable the page cache in the xSpeed dashboard to start serving cached HTML for non-logged-in visitors.', 'xspeed' ) . '</p>';
96 return $result;
97 }
98
99 $server_type = \XSpeed\Server::type();
100 if ( \XSpeed\Server::APACHE === $server_type || \XSpeed\Server::LITESPEED === $server_type ) {
101 // Only call the block "missing" when it is genuinely absent by
102 // accident. When static_rewrite_allowed() deliberately refused it,
103 // "toggle Enable Cache off and on" cannot reinstall anything —
104 // the same condition suppresses the write and auto_heal() strips
105 // the block again on the next admin load. Explain the real cause.
106 $block_reason = \XSpeed\Cache::static_rewrite_block_reason();
107 if ( 'no_mod_headers' === $block_reason ) {
108 $result['label'] = __( 'xSpeed is serving cache hits through PHP', 'xspeed' );
109 $result['status'] = 'recommended';
110 $result['description'] = '<p>' . esc_html__( 'Caching is working — hits are served by the xSpeed drop-in and tagged X-XSpeed-Cache: HIT (php). The faster .htaccess fast path is off because Apache\'s mod_headers module is not loaded, without which a static hit could not be tagged or counted. Enable mod_headers (a2enmod headers on Debian/Ubuntu, then restart Apache) to shave roughly 20-30ms off each cache hit.', 'xspeed' ) . '</p>';
111 } elseif ( 'mobile_separate' === $block_reason ) {
112 $result['label'] = __( 'xSpeed static rewrite is off (Separate Mobile Cache)', 'xspeed' );
113 $result['status'] = 'recommended';
114 $result['description'] = '<p>' . esc_html__( 'Separate Mobile Cache is on, so cache hits are served by the PHP drop-in to keep per-device HTML correct. Turn Separate Mobile Cache off if your site serves the same HTML to every device to regain the faster static path.', 'xspeed' ) . '</p>';
115 } elseif ( \XSpeed\Server::APACHE === $server_type && ! \XSpeed\Cache::rewrite_installed() ) {
116 $result['label'] = __( 'xSpeed .htaccess rewrite block is missing', 'xspeed' );
117 $result['status'] = 'recommended';
118 $result['description'] = '<p>' . esc_html__( 'Without the static-rewrite block, cache hits go through the PHP drop-in (~85ms TTFB) instead of the web server (~5-15ms). Toggle Enable Cache off and on in xSpeed to reinstall the block.', 'xspeed' ) . '</p>';
119 }
120 return $result;
121 }
122
123 if ( \XSpeed\Server::NGINX === $server_type ) {
124 $snippet = \XSpeed\Cache::nginx_snippet();
125 $result['label'] = __( 'xSpeed nginx server config required', 'xspeed' );
126 $result['status'] = 'recommended';
127 $result['description'] = '<p>' . esc_html__( 'xSpeed can\'t write nginx config from PHP. Paste this snippet into your site\'s server { } block, then reload nginx so cache hits serve without booting PHP:', 'xspeed' ) . '</p>'
128 . '<pre style="white-space:pre;overflow-x:auto;background:#f6f7f7;border:1px solid #c3c4c7;border-radius:4px;padding:12px;font-size:12px;line-height:1.4;">'
129 . esc_html( (string) $snippet )
130 . '</pre>';
131 return $result;
132 }
133
134 // Unknown / IIS — no server-level rewrite path available; PHP
135 // drop-in is the best we can offer. Don't flag as broken.
136 $result['label'] = __( 'xSpeed PHP drop-in cache active', 'xspeed' );
137 $result['status'] = 'recommended';
138 $result['description'] = '<p>' . esc_html__( 'Static-rewrite caching needs Apache, LiteSpeed, or nginx. The PHP drop-in is still serving cache hits at ~85ms TTFB on this server.', 'xspeed' ) . '</p>';
139 return $result;
140 }
141
142 public function ui_metadata(): array {
143 return array(
144 'label' => __( 'Health', 'xspeed' ),
145 'icon' => 'HeartPulse',
146 'description' => __( 'Diagnostics, hit ratio, and recent cache activity.', 'xspeed' ),
147 // Health is the single host page for all Insights (FBS-83633):
148 // a Recommendations action card + Cache / Visitors / PageSpeed
149 // tabs. HealthPanel renders the Free cache diagnostics (the old
150 // HealthCard) as the Cache tab and hosts the Pro insight panels
151 // as the other tabs via ProSlot.
152 'custom_panel' => 'HealthPanel',
153 );
154 }
155
156 // No settings — explicit empty so Module::rest_routes() doesn't
157 // auto-wire the schema-driven GET+POST.
158 public function settings_schema(): array {
159 return array();
160 }
161
162 public function rest_routes(): array {
163 return array(
164 array(
165 'path' => '/',
166 'methods' => 'GET',
167 'callback' => array( $this, 'rest_get_payload' ),
168 ),
169 );
170 }
171
172 public function cli_commands(): array {
173 return array(
174 array(
175 'name' => 'xspeed health',
176 'callback' => array( $this, 'cli_handler' ),
177 'shortdesc' => 'Print diagnostic checks + cache stats + recent activity.',
178 'ai_hint' => 'Full diagnostic sweep: what is misconfigured or degraded on this site right now, plus cache stats and recent activity. The best FIRST call for open-ended "why is my site slow" or "is anything wrong" questions.',
179 'synopsis' => array(),
180 ),
181 array(
182 'name' => 'xspeed recommend',
183 'callback' => array( $this, 'cli_recommend' ),
184 'shortdesc' => 'List ranked next-best-action recommendations, or apply one by id.',
185 'ai_hint' => 'The ranked list of what to do next to make this site faster, and the way to apply one. Use when asked "what should I improve" or "what\'s the biggest win" — each item is actionable and ordered by impact.',
186 'synopsis' => array(
187 array(
188 'type' => 'positional',
189 'name' => 'action',
190 'options' => array( 'list', 'apply' ),
191 'optional' => true,
192 ),
193 array(
194 'type' => 'positional',
195 'name' => 'id',
196 'optional' => true,
197 ),
198 ),
199 ),
200 );
201 }
202
203 /** CLI: `wp xspeed recommend [list|apply <id>]` — MCP-reachable via run_command. */
204 public function cli_recommend( array $args, array $assoc ): void {
205 $action = isset( $args[0] ) ? (string) $args[0] : 'list';
206
207 if ( 'apply' === $action ) {
208 $id = isset( $args[1] ) ? (string) $args[1] : '';
209 if ( '' === $id ) {
210 \WP_CLI::error( 'Usage: wp xspeed recommend apply <id>' );
211 return;
212 }
213 $result = \XSpeed\Recommendations::apply( $id );
214 if ( is_wp_error( $result ) ) {
215 \WP_CLI::error( $result->get_error_message() );
216 return;
217 }
218 \WP_CLI::success( sprintf( 'Applied "%s". %d recommendation(s) remain.', $id, count( $result['recommendations'] ) ) );
219 return;
220 }
221
222 $recs = \XSpeed\Recommendations::all();
223 if ( empty( $recs ) ) {
224 \WP_CLI::success( 'No recommendations — configuration looks healthy.' );
225 return;
226 }
227 foreach ( $recs as $i => $rec ) {
228 $fixable = 'apply' === ( $rec['action']['type'] ?? '' ) ? ' (one-click: wp xspeed recommend apply ' . $rec['id'] . ')' : '';
229 \WP_CLI::log( sprintf( '%d. [%s] %s — %s%s', $i + 1, $rec['id'], $rec['title'], $rec['detail'], $fixable ) );
230 }
231 }
232
233 /**
234 * Single endpoint that backs the dashboard panel. Refreshed lazily by
235 * the React side; cheap enough that aggregating into one response is
236 * the right call (the buckets array is at most 24 entries; activity
237 * is capped at 50).
238 */
239 public function rest_get_payload( \WP_REST_Request $request ) {
240 return rest_ensure_response( $this->payload() );
241 }
242
243 public function payload(): array {
244 return array(
245 'checks' => Health::checks(),
246 'stats' => Cache::get_stats(),
247 'buckets' => Hit_Counter::buckets(),
248 'activity' => Activity_Log::entries(),
249 );
250 }
251
252 public function cli_handler( array $args, array $assoc ): void {
253 $payload = $this->payload();
254 \WP_CLI::log( '== Checks ==' );
255 foreach ( $payload['checks'] as $c ) {
256 \WP_CLI::log( sprintf( '[%s] %s — %s', strtoupper( $c['tone'] ), $c['label'], $c['detail'] ) );
257 }
258 \WP_CLI::log( '' );
259 \WP_CLI::log( '== Stats (24h) ==' );
260 \WP_CLI::log( sprintf( 'Hits %d · Misses %d · Hit ratio %.2f%%', $payload['stats']['hits_24h'], $payload['stats']['misses_24h'], $payload['stats']['hit_ratio'] * 100 ) );
261 \WP_CLI::log( '' );
262 \WP_CLI::log( '== Recent activity ==' );
263 foreach ( array_slice( $payload['activity'], 0, 10 ) as $e ) {
264 \WP_CLI::log( sprintf( '%s [%s] %s', gmdate( 'Y-m-d H:i:s', $e['ts'] ), $e['severity'], $e['message'] ) );
265 }
266 }
267 }
268