403 ) ); } check_admin_referer( self::NONCE ); self::check_now(); wp_safe_redirect( admin_url( 'admin.php?page=hatch#connection' ) ); exit; } /** * Probe the saved frontend address right now and return what was found. * * Used by the Connection tab's Check now button (through REST, so the page * stays put) and by the admin-post fallback. * * @return array{ran:bool,status:int,rtt_ms:int,error:string,checked_at:int} "ran" is false when no frontend address is saved. */ public static function check_now(): array { $url = trim( (string) get_option( 'hatch_frontend_url', '' ) ); if ( '' === $url ) { return array( 'ran' => false, 'status' => 0, 'rtt_ms' => 0, 'error' => '', 'checked_at' => 0, ); } return self::instance()->probe_and_store( self::provider_for_model( (string) get_option( 'hatch_hosting_model', '' ) ), $url ); } /** * Add the fifteen-minute cron interval. * * @param array> $schedules Existing schedules. * @return array> */ public function register_schedule( $schedules ) { if ( ! isset( $schedules[ self::CRON_RECUR ] ) ) { $schedules[ self::CRON_RECUR ] = array( 'interval' => 15 * MINUTE_IN_SECONDS, 'display' => __( 'Every 15 minutes (Hatch)', 'hatch-connect' ), ); } return $schedules; } /** * Schedule the probe once a frontend URL exists; unschedule when it is removed. * * @return void */ public function ensure_scheduled(): void { $has_url = '' !== trim( (string) get_option( 'hatch_frontend_url', '' ) ); if ( ! $has_url ) { self::clear_schedule(); return; } if ( ! wp_next_scheduled( self::CRON_HOOK ) ) { wp_schedule_event( time() + 60, self::CRON_RECUR, self::CRON_HOOK ); } } /** * Remove the scheduled probe. * * @return void */ public static function clear_schedule(): void { wp_clear_scheduled_hook( self::CRON_HOOK ); } /** * Cron callback: probe the configured frontend. * * @return void */ public function run(): void { $frontend = trim( (string) get_option( 'hatch_frontend_url', '' ) ); if ( '' === $frontend ) { return; } $this->probe_and_store( self::provider_for_model( (string) get_option( 'hatch_hosting_model', '' ) ), $frontend ); } /** * Send a HEAD request and store the outcome. * * @param string $provider Storage key. * @param string $url Frontend URL. * @return array{ran:bool,status:int,rtt_ms:int,error:string,checked_at:int} The outcome, with the transport error text when no answer came. */ private function probe_and_store( string $provider, string $url ): array { $start = microtime( true ); // SSRF-safe: https only, and private or loopback targets are refused (local dev hosts excepted). $res = Hatch_Revalidate::safe_request( 'HEAD', $url, array( 'timeout' => 8, 'redirection' => 3, 'sslverify' => true, 'user-agent' => 'Hatch-Heartbeat/1.0', ) ); $rtt_ms = (int) round( ( microtime( true ) - $start ) * 1000 ); $status = is_wp_error( $res ) ? 0 : (int) wp_remote_retrieve_response_code( $res ); $error = is_wp_error( $res ) ? wp_strip_all_tags( $res->get_error_message() ) : ''; if ( 0 === $status ) { $rtt_ms = 0; } $now = time(); $opt_key = self::OPT_PREFIX . $provider; $prev = (array) get_option( $opt_key, array() ); $history = isset( $prev['history'] ) && is_array( $prev['history'] ) ? $prev['history'] : array(); $history[] = array( 'ts' => $now, 'status' => $status, 'rtt_ms' => $rtt_ms, ); if ( count( $history ) > self::HISTORY_MAX ) { $history = array_slice( $history, -self::HISTORY_MAX ); } update_option( $opt_key, array( 'ts' => $now, 'status' => $status, 'rtt_ms' => $rtt_ms, 'history' => $history, ), false ); return array( 'ran' => true, 'status' => $status, 'rtt_ms' => $rtt_ms, 'error' => $error, 'checked_at' => $now, ); } /** * Read the heartbeat record. Any provider name is accepted; a record * stored under the Cloudflare or generic key is used when none exists for * the requested one, so callers written for older hosting models keep working. * * @param string $provider Provider key, for example "cloudflare". * @return array|null Null when no probe has run yet. */ public static function get( string $provider ): ?array { $keys = array_unique( array( sanitize_key( $provider ), 'cloudflare', 'generic' ) ); foreach ( $keys as $key ) { $opt = get_option( self::OPT_PREFIX . $key, array() ); if ( is_array( $opt ) && ! empty( $opt['ts'] ) ) { $opt['ttfb_ms'] = isset( $opt['rtt_ms'] ) ? (int) $opt['rtt_ms'] : 0; $opt['source'] = 'probe'; return $opt; } } return null; } /** * Is the record too old to trust? The probe runs every 15 minutes, so three missed * runs means the cron is not firing. * * @param array $record Record from get(). * @return bool */ private static function is_stale( array $record ): bool { return ( time() - (int) ( $record['ts'] ?? 0 ) ) > 3 * 15 * MINUTE_IN_SECONDS; } /** * Health label for a record, used for the status dot colour. * * @param array|null $record Record from get(). * @return string "good", "warn", "bad", "stale" or "muted". */ public static function health( ?array $record ): string { if ( ! $record || empty( $record['ts'] ) ) { return 'muted'; } if ( self::is_stale( $record ) ) { // WP-Cron only runs when the site gets traffic, so an old record // means "not checked lately", not "the frontend is down". return 'stale'; } $status = (int) ( $record['status'] ?? 0 ); if ( $status >= 200 && $status < 400 ) { return 'good'; } if ( 0 === $status || $status >= 500 ) { return 'bad'; } return 'warn'; } /** * Sentence describing a record, always consistent with health() for the same record. * * A response time is only quoted when the frontend answered with a success or * redirect status; an error status or no answer says so instead. * * @param array|null $record Record from get(). * @return string Plain text, translated. */ public static function label( ?array $record ): string { if ( ! $record || empty( $record['ts'] ) ) { return __( 'No check yet. The first one runs within a few minutes of a deploy.', 'hatch-connect' ); } $age = human_time_diff( (int) $record['ts'] ); if ( self::is_stale( $record ) ) { /* translators: %s: how long ago the last check ran, for example "3 hours". */ return sprintf( __( 'Not checked recently. The last check ran %s ago. Press Check now for a fresh result.', 'hatch-connect' ), $age ); } $status = (int) ( $record['status'] ?? 0 ); $rtt = (int) ( $record['rtt_ms'] ?? 0 ); if ( 0 === $status ) { /* translators: %s: how long ago the check ran, for example "3 mins". */ return sprintf( __( 'No response from the frontend. Last checked %s ago.', 'hatch-connect' ), $age ); } if ( $status >= 200 && $status < 400 ) { /* translators: 1: response time in milliseconds, for example "180 ms". 2: how long ago the check ran, for example "3 mins". */ return sprintf( __( 'Responded in %1$s. Last checked %2$s ago.', 'hatch-connect' ), $rtt . ' ms', $age ); } /* translators: 1: HTTP status code, for example "503". 2: how long ago the check ran, for example "3 mins". */ return sprintf( __( 'The frontend answered with HTTP %1$d. Last checked %2$s ago.', 'hatch-connect' ), $status, $age ); } } Hatch_Cloud_Heartbeat::instance();