backend_client = $backend_client; } /** * Fetch the summary, preferring a fresh answer and falling back to the last * good one. * * @return array{state: string, data: array|null, stale: bool} * state is 'ok' when the figures are current, 'stale' when the service was * unreachable but we still hold a recent answer, and 'unavailable' when we * have nothing to show. Callers must render all three differently. */ public function get(): array { $cached = get_transient( self::CACHE_KEY ); if ( is_array( $cached ) && isset( $cached['data'] ) ) { return array( 'state' => 'ok', 'data' => $cached['data'], 'stale' => false, ); } $data = $this->backend_client->get_site_summary(); if ( is_wp_error( $data ) ) { // "We could not reach WPPilot" and "WPPilot no longer recognises // this site" look identical from here unless we ask. They need // opposite responses — wait, versus reconnect — so an owner whose // pairing has actually broken is never left watching a spinner. $rejected = self::is_rejection( $data ); // Keep the last good answer alive separately from the freshness // cache: a service blip should not make a working site look idle. $last = get_option( 'wppilot_last_site_summary', null ); if ( ! $rejected && is_array( $last ) && isset( $last['data'] ) ) { return array( 'state' => 'stale', 'data' => $last['data'], 'stale' => true, ); } return array( 'state' => $rejected ? 'rejected' : 'unavailable', 'data' => null, 'stale' => false, ); } set_transient( self::CACHE_KEY, array( 'data' => $data ), self::CACHE_TTL ); update_option( 'wppilot_last_site_summary', array( 'data' => $data, 'fetched_at' => time(), ), false ); return array( 'state' => 'ok', 'data' => $data, 'stale' => false, ); } /** * Whether this failure means WPPilot no longer accepts our credentials, as * opposed to not answering at all. * * A rejection is a pairing that needs re-establishing; anything else is a * network or service condition that will pass on its own. Only the former * should put a "Reconnect" button in front of an owner. * * @param \WP_Error $error The failure returned by the backend client. */ private static function is_rejection( \WP_Error $error ): bool { $code = $error->get_error_code(); if ( 'wppilot_token_failed' === $code || 'wppilot_no_secret' === $code ) { return true; } // A GET that still came back 401 after the client already retried with a // freshly exchanged token: the credentials themselves are the problem. if ( 'wppilot_get_failed' === $code ) { $data = $error->get_error_data(); return is_array( $data ) && isset( $data['http_code'] ) && 401 === (int) $data['http_code']; } return false; } /** Forget everything cached about the summary (disconnect, reconnect). */ public static function forget(): void { delete_transient( self::CACHE_KEY ); delete_option( 'wppilot_last_site_summary' ); } /** * "3 minutes ago", or null when there is no timestamp. * * Null means we genuinely do not know, and every caller renders that as * absence rather than as "never" or "just now". * * @param mixed $iso An ISO-8601 timestamp, or anything else. */ public static function ago( $iso ): ?string { if ( ! is_string( $iso ) || '' === $iso ) { return null; } $ts = strtotime( $iso ); if ( false === $ts ) { return null; } $now = time(); if ( $ts > $now ) { $ts = $now; } /* translators: %s: human-readable time difference, e.g. "3 minutes". */ return sprintf( __( '%s ago', 'wingpilot' ), human_time_diff( $ts, $now ) ); } /** * The handful of recent events worth showing an owner, in their language. * * Only event types whose meaning we can state plainly are surfaced. An * event we cannot describe honestly is skipped rather than shown with a * generic label, because a vague line is what makes a status area feel * decorative rather than real. * * @param array $recent Raw recent events from the service. * @return array */ public static function readable_events( array $recent ): array { $out = array(); foreach ( $recent as $event ) { if ( ! is_array( $event ) ) { continue; } $type = isset( $event['type'] ) ? (string) $event['type'] : ''; $when = self::ago( $event['created_at'] ?? null ); if ( null === $when ) { continue; } $icon = 'check'; switch ( $type ) { case 'scan_completed': $text = __( 'Security check completed', 'wingpilot' ); break; case 'action_execute': $text = __( 'Change applied and verified', 'wingpilot' ); break; case 'action_rollback': $text = __( 'Change rolled back', 'wingpilot' ); $icon = 'undo'; break; case 'malware_detected': case 'suspicious_file': case 'vulnerability_found': case 'integrity_violation': $text = __( 'Security finding to review', 'wingpilot' ); $icon = 'alert'; break; case 'connection': $text = __( 'Connected to WPPilot', 'wingpilot' ); break; default: // Everything else — logins, plugin activations, post edits — // is the site's own activity, not WPPilot's work. It belongs // in the cockpit's feed, not in a status line that claims // WPPilot did something. continue 2; } $out[] = array( 'icon' => $icon, 'text' => $text, 'when' => $when, ); if ( count( $out ) >= 3 ) { break; } } return $out; } }