hooks(); } return self::$instance; } /** * Register admin hooks. */ private function hooks() { add_action( 'admin_menu', array( $this, 'menu' ) ); add_action( 'admin_enqueue_scripts', array( $this, 'assets' ) ); add_action( 'admin_notices', array( $this, 'maybe_unwritable_notice' ) ); // Plugins-screen affordances (Code Snippets-style): quick links under // the plugin name + Docs/Support in the row meta. add_filter( 'plugin_action_links_' . CULPRIT_PLUGIN_BASENAME, array( $this, 'action_links' ) ); add_filter( 'plugin_row_meta', array( $this, 'row_meta' ), 10, 2 ); } /** * Action links shown under the plugin name on the Plugins screen. * Free build additionally gets a highlighted "Upgrade to Pro". * * @param array $links Existing links (Deactivate, etc.). * @return array */ public function action_links( $links ) { $ours = array( 'culprit-incidents' => '' . esc_html__( 'Incidents', 'culprit' ) . '', 'culprit-environment' => '' . esc_html__( 'Environment', 'culprit' ) . '', ); if ( class_exists( 'Culprit_License_Admin' ) ) { // Paid build: link straight to the License tab. $ours['culprit-license'] = '' . esc_html__( 'License', 'culprit' ) . ''; } else { // Free build: highlighted upgrade link (rendered after Deactivate, // like Code Snippets' "Upgrade to Pro"). $links['culprit-upgrade'] = '' . esc_html__( 'Upgrade to Pro', 'culprit' ) . ''; } return array_merge( $ours, $links ); } /** * Row meta (the line with Version / By): add Docs and Support links. * * @param array $meta Existing meta links. * @param string $file Plugin basename being rendered. * @return array */ public function row_meta( $meta, $file ) { if ( CULPRIT_PLUGIN_BASENAME !== $file ) { return $meta; } /** Filter the documentation URL shown on the Plugins screen. */ $docs_url = (string) apply_filters( 'culprit_docs_url', 'https://manicsoup.com/docs/culprit/' ); /** Filter the support URL shown on the Plugins screen. */ $support_url = (string) apply_filters( 'culprit_support_url', 'https://manicsoup.com/support/' ); $meta[] = '' . esc_html__( 'Docs', 'culprit' ) . ''; $meta[] = '' . esc_html__( 'Support', 'culprit' ) . ''; return $meta; } /** * Register the top-level menu. */ public function menu() { $cap = Culprit_Settings::capability(); add_menu_page( __( 'Culprit', 'culprit' ), __( 'Culprit', 'culprit' ), $cap, 'culprit', array( $this, 'render' ), 'dashicons-search', 81 ); } /** * Enqueue admin assets on our page only. * * @param string $hook Current admin page hook. */ public function assets( $hook ) { if ( 'toplevel_page_culprit' !== $hook ) { return; } wp_enqueue_style( 'culprit-admin', CULPRIT_PLUGIN_URL . 'assets/css/admin.css', array(), CULPRIT_VERSION ); } /** * Show a notice if the mu-plugins directory isn't writable. */ public function maybe_unwritable_notice() { if ( ! Culprit_Settings::current_user_can_manage() ) { return; } if ( ! get_option( 'culprit_watcher_unwritable' ) ) { return; } echo '

'; echo esc_html__( 'Culprit could not install its early-boot watcher because the mu-plugins directory is not writable. Culprit still catches later crashes, but the earliest boot fatals may be missed.', 'culprit' ); echo '

'; } /** * Render the current tab. */ public function render() { if ( ! Culprit_Settings::current_user_can_manage() ) { wp_die( esc_html__( 'You do not have permission to view this page.', 'culprit' ) ); } // Read-only tab switch; no state change, so nonce isn't required here. $tab = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : 'incidents'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended echo '
'; Culprit_UI::header( $tab ); switch ( $tab ) { case 'status': $this->render_status(); break; case 'history': $this->render_history(); break; case 'timemachine': $this->render_time_machine(); break; case 'hooks': $this->render_hooks(); break; case 'environment': $this->render_environment(); break; case 'license': $this->render_license(); break; case 'incidents': default: $this->render_incidents(); break; } echo '
'; } /** * Render the incident list — the core free-tier value. */ private function render_incidents() { // Show a deep-scan result banner if one just ran (Elite). if ( class_exists( 'Culprit_Deep_Scan_Admin' ) ) { Culprit_Deep_Scan_Admin::maybe_render_result(); } // Recovery Assistant (Elite): one-time result banner, then the offer // panel when a fresh plugin-blamed fatal has the plugin still active. if ( class_exists( 'Culprit_Recovery' ) ) { Culprit_Recovery::maybe_render_result(); Culprit_Recovery::maybe_render_panel(); } $incidents = Culprit_Database::query( array( 'orderby' => 'last_seen', 'order' => 'DESC', 'per_page' => 50, ) ); echo '
'; if ( empty( $incidents ) ) { echo '
'; echo '

' . esc_html__( 'No crashes detected. ', 'culprit' ) . '

'; echo '

' . esc_html__( 'When a plugin or theme breaks your site with a fatal error or white screen, Culprit will catch it and name the culprit here.', 'culprit' ) . '

'; echo '
'; } else { $this->render_incident_table( $incidents ); } echo '
'; // Premium teasers — shown when the premium code is absent (free build) // or the license doesn't cover it (gated build on a Lite key). if ( ! class_exists( 'Culprit_Correlation' ) || ! Culprit_License::can( 'correlation' ) ) { echo '
'; Culprit_UI::teaser( __( 'Correlation & confidence scoring', 'culprit' ), __( 'See exactly what changed right before the crash and how confident Culprit is in the culprit.', 'culprit' ), __( 'Premium', 'culprit' ) ); Culprit_UI::teaser( __( 'Time Machine debugging', 'culprit' ), __( 'Compare the last known good state with today and see exactly what changed before the problem started.', 'culprit' ), __( 'Premium', 'culprit' ) ); Culprit_UI::teaser( __( 'Hook Inspector & timeline', 'culprit' ), __( 'See which plugins attach to any hook, in execution order, with a lifecycle timeline of real page loads.', 'culprit' ), __( 'Premium', 'culprit' ) ); Culprit_UI::teaser( __( 'Active deep scan & Recovery Assistant', 'culprit' ), __( 'Prove the culprit by safely reproducing the conflict, and recover with one click — disable only the plugin that broke your site.', 'culprit' ), __( 'Elite', 'culprit' ) ); echo '
'; Culprit_UI::upgrade_box(); } } /** * Render the Time Machine tab (Premium): the incident diagnosis ("what * changed before the problem started") plus the state-change timeline. */ private function render_time_machine() { if ( ! class_exists( 'Culprit_Time_Machine' ) || ! Culprit_License::can( 'time_machine' ) ) { echo '
'; Culprit_UI::teaser( __( 'Time Machine debugging', 'culprit' ), __( 'Compare the last known good state with today and see exactly what changed before the problem started.', 'culprit' ), __( 'Premium', 'culprit' ) ); echo '
'; return; } // Diagnose the most recent unresolved incident, if any. $incidents = Culprit_Database::query( array( 'status' => 'new', 'orderby' => 'last_seen', 'order' => 'DESC', 'per_page' => 1, ) ); if ( ! empty( $incidents ) ) { $inc = $incidents[0]; $diag = Culprit_Time_Machine::diagnose( $inc ); $name = ( '' !== $inc->blamed_name ) ? $inc->blamed_name : __( 'Unknown', 'culprit' ); echo '
'; echo '

' . esc_html__( 'What changed before the problem started', 'culprit' ) . '

'; /* translators: 1: culprit name, 2: date/time. */ echo '

' . esc_html( sprintf( __( 'Most recent incident: %1$s, first seen %2$s.', 'culprit' ), $name, wp_strip_all_tags( Culprit_Settings::format_datetime( $inc->first_seen ) ) ) ) . '

'; if ( '' !== $diag['good_time'] ) { /* translators: %s: date/time. */ echo '

' . esc_html( sprintf( __( 'Last known good state: %s', 'culprit' ), wp_strip_all_tags( Culprit_Settings::format_datetime( $diag['good_time'] ) ) ) ) . '

'; } if ( empty( $diag['events'] ) ) { echo '

' . esc_html__( 'No recorded state change leads up to this incident. The cause may be environmental (server, PHP, or specific data) rather than an update.', 'culprit' ) . '

'; } else { echo '
    '; foreach ( $diag['events'] as $event ) { $class = ! empty( $event['matches_culprit'] ) ? ' class="is-match"' : ''; echo ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- static attribute. echo '' . Culprit_Settings::format_datetime( $event['time'] ) . ' '; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- format_datetime escapes. echo esc_html( $event['text'] ); if ( ! empty( $event['matches_culprit'] ) ) { echo ' ' . esc_html__( 'Blamed culprit', 'culprit' ) . ''; } echo ''; } echo '
'; } if ( '' !== $diag['likely_cause'] ) { echo '

' . esc_html__( 'Likely cause:', 'culprit' ) . ' ' . esc_html( $diag['likely_cause'] ) . '

'; } if ( '' !== $diag['confidence_note'] ) { echo '

' . esc_html( $diag['confidence_note'] ) . '

'; } echo '
'; } // The full state-change timeline. $timeline = Culprit_Time_Machine::timeline( 50 ); echo '
'; echo '

' . esc_html__( 'Site state timeline', 'culprit' ) . '

'; if ( empty( $timeline ) ) { echo '

' . esc_html__( 'No state changes recorded yet. From now on, every plugin update, activation, theme switch, and WordPress or PHP upgrade will be recorded here.', 'culprit' ) . '

'; } else { echo ''; echo ''; echo ''; echo ''; echo ''; foreach ( $timeline as $event ) { echo ''; echo ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- format_datetime escapes. echo ''; echo ''; } echo '
' . esc_html__( 'When', 'culprit' ) . '' . esc_html__( 'What changed', 'culprit' ) . '
' . Culprit_Settings::format_datetime( $event['time'] ) . '' . esc_html( $event['text'] ) . '
'; } echo '
'; } /** * Render the Hooks tab (Premium): inspector + lifecycle timeline. */ private function render_hooks() { if ( ! class_exists( 'Culprit_Hook_Inspector' ) || ! Culprit_License::can( 'hook_inspector' ) ) { echo '
'; Culprit_UI::teaser( __( 'Hook Inspector & timeline', 'culprit' ), __( 'See which plugins attach to any hook, in execution order, with a lifecycle timeline of real page loads.', 'culprit' ), __( 'Premium', 'culprit' ) ); echo '
'; return; } // Read-only view selection; no state change, so no nonce needed. $hook = isset( $_GET['hook'] ) ? sanitize_key( wp_unslash( $_GET['hook'] ) ) : 'init'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended echo '
'; echo '

' . esc_html__( 'Hook Inspector', 'culprit' ) . '

'; echo '

' . esc_html__( 'Every callback attached to a hook, in the order WordPress will run them — and which plugin or theme owns each one.', 'culprit' ) . '

'; echo '
'; echo ''; echo ''; echo ' '; echo ' '; submit_button( __( 'Inspect', 'culprit' ), 'secondary', '', false ); echo '
'; $rows = Culprit_Hook_Inspector::inspect( $hook ); if ( empty( $rows ) ) { /* translators: %s: hook name. */ echo '

' . esc_html( sprintf( __( 'Nothing is attached to "%s" on this request.', 'culprit' ), $hook ) ) . '

'; } else { echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; foreach ( $rows as $row ) { echo ''; echo ''; echo ''; echo ''; echo ''; } echo '
' . esc_html__( 'Priority', 'culprit' ) . '' . esc_html__( 'Callback', 'culprit' ) . '' . esc_html__( 'Owner', 'culprit' ) . '
' . esc_html( (string) $row['priority'] ) . '' . esc_html( $row['callback'] ) . '' . esc_html( $row['owner_name'] ); $type_label = self::type_label( $row['owner_type'] ); if ( '' !== $type_label ) { echo ' ' . esc_html( $type_label ) . ''; } echo '
'; } echo '
'; // Lifecycle timeline of the last recorded front-end page load. $timeline = Culprit_Hook_Inspector::last_timeline(); echo '
'; echo '

' . esc_html__( 'Page-load hook timeline', 'culprit' ) . '

'; if ( null === $timeline ) { echo '

' . esc_html__( 'No front-end page load recorded yet. Visit your site (while logged in or out) and reload this tab.', 'culprit' ) . '

'; } else { /* translators: 1: URL path, 2: date/time. */ echo '

' . esc_html( sprintf( __( 'Last recorded load: %1$s at %2$s.', 'culprit' ), $timeline['url'], wp_strip_all_tags( Culprit_Settings::format_datetime( gmdate( 'Y-m-d H:i:s', $timeline['recorded'] ) ) ) ) ) . '

'; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; foreach ( $timeline['rows'] as $row ) { echo ''; echo ''; echo ''; echo ''; echo ''; } echo '
' . esc_html__( 'Hook', 'culprit' ) . '' . esc_html__( 'Fired at (ms)', 'culprit' ) . '' . esc_html__( 'Stage took (ms)', 'culprit' ) . '
' . esc_html( $row['hook'] ) . '' . esc_html( number_format_i18n( $row['at_ms'], 1 ) ) . '' . esc_html( number_format_i18n( $row['delta_ms'], 1 ) ) . '
'; } echo '
'; } /** * Render the Environment tab (free): the snapshot tables + report export. */ private function render_environment() { $sections = Culprit_Environment::snapshot( true ); echo '
'; echo '

' . esc_html__( 'Environment', 'culprit' ) . '

'; echo '

' . esc_html__( 'Everything a support thread asks for, in one place. Export it as a text report and paste it anywhere.', 'culprit' ) . '

'; $export_url = wp_nonce_url( admin_url( 'admin-post.php?action=culprit_export_environment' ), 'culprit_export_environment' ); echo '

' . esc_html__( 'Download support report', 'culprit' ) . '

'; foreach ( $sections as $section => $rows ) { echo '

' . esc_html( Culprit_Environment::section_label( $section ) ) . '

'; echo ''; foreach ( $rows as $label => $value ) { echo ''; } echo '
' . esc_html( $label ) . '' . esc_html( $value ) . '
'; } echo '
'; } /** * Render the incident table. * * @param array $incidents Row objects. */ private function render_incident_table( $incidents ) { echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; $show_actions = class_exists( 'Culprit_Deep_Scan_Admin' ) && Culprit_License::can( 'deep_scan' ); if ( $show_actions ) { echo ''; } echo ''; foreach ( $incidents as $inc ) { $name = ( '' !== $inc->blamed_name ) ? $inc->blamed_name : __( 'Unknown', 'culprit' ); $type_label = self::type_label( $inc->blamed_type ); echo ''; echo ''; echo ''; echo ''; echo ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- format_datetime escapes. echo ''; if ( $show_actions ) { echo ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- button markup escaped internally. } echo ''; } echo '
' . esc_html__( 'Culprit', 'culprit' ) . '' . esc_html__( 'What happened', 'culprit' ) . '' . esc_html__( 'Confidence', 'culprit' ) . '' . esc_html__( 'Last seen', 'culprit' ) . '' . esc_html__( 'Count', 'culprit' ) . '' . esc_html__( 'Prove', 'culprit' ) . '
' . esc_html( $name ) . ''; if ( '' !== $type_label ) { echo ' ' . esc_html( $type_label ) . ''; } echo ''; echo esc_html( self::summarize( $inc ) ); // When premium correlation has written an analysis reason, show it. $reason = self::incident_reason( $inc ); if ( '' !== $reason ) { echo '
' . esc_html( $reason ) . '
'; } echo '
' . esc_html( (int) $inc->confidence ) . '%' . Culprit_Settings::format_datetime( $inc->last_seen ) . '' . esc_html( (int) $inc->occurrences ) . '' . Culprit_Deep_Scan_Admin::scan_button( $inc ) . '
'; } /** * Build a plain-English one-liner for an incident. * * @param object $inc Incident row. * @return string */ private static function summarize( $inc ) { $file = $inc->failing_file; $short = ( '' !== $file ) ? basename( $file ) : ''; switch ( $inc->blamed_type ) { case 'plugin': /* translators: %s: file name. */ return $short ? sprintf( __( 'A fatal error occurred in this plugin (%s).', 'culprit' ), $short ) : __( 'A fatal error occurred in this plugin.', 'culprit' ); case 'theme': /* translators: %s: file name. */ return $short ? sprintf( __( 'A fatal error occurred in this theme (%s).', 'culprit' ), $short ) : __( 'A fatal error occurred in this theme.', 'culprit' ); case 'core': return __( 'A fatal error surfaced in WordPress core, often triggered by data from another plugin.', 'culprit' ); default: return __( 'A fatal error occurred but could not be pinned to a single plugin or theme.', 'culprit' ); } } /** * Human label for a blamed type. * * @param string $type Blamed type. * @return string */ private static function type_label( $type ) { switch ( $type ) { case 'plugin': return __( 'Plugin', 'culprit' ); case 'theme': return __( 'Theme', 'culprit' ); case 'core': return __( 'Core', 'culprit' ); default: return ''; } } /** * Decode the premium analysis reason from an incident's context, if any. * * @param object $inc Incident row. * @return string */ private static function incident_reason( $inc ) { if ( empty( $inc->context ) ) { return ''; } $context = json_decode( (string) $inc->context, true ); if ( is_array( $context ) && ! empty( $context['reason'] ) ) { return (string) $context['reason']; } return ''; } /** * Render the History tab — recurring culprits (Premium). */ private function render_history() { // Defensive: this tab is only linked when available, but guard anyway. if ( ! class_exists( 'Culprit_History' ) || ! Culprit_License::can( 'conflict_history' ) ) { echo '
'; Culprit_UI::teaser( __( 'Conflict history', 'culprit' ), __( 'Track recurring culprits over time so chronic offenders surface.', 'culprit' ), __( 'Premium', 'culprit' ) ); echo '
'; return; } $culprits = Culprit_History::recurring_culprits( 20 ); echo '
'; echo '

' . esc_html__( 'Recurring culprits', 'culprit' ) . '

'; if ( empty( $culprits ) ) { echo '

' . esc_html__( 'No recurring culprits yet. When the same plugin or theme breaks your site more than once, it will appear here.', 'culprit' ) . '

'; } else { echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; foreach ( $culprits as $c ) { $name = ( '' !== $c['name'] ) ? $c['name'] : __( 'Unknown', 'culprit' ); echo ''; echo ''; echo ''; echo ''; echo ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- format_datetime escapes. echo ''; } echo '
' . esc_html__( 'Culprit', 'culprit' ) . '' . esc_html__( 'Incidents', 'culprit' ) . '' . esc_html__( 'Total occurrences', 'culprit' ) . '' . esc_html__( 'Last seen', 'culprit' ) . '
' . esc_html( $name ) . ''; $type_label = self::type_label( $c['type'] ); if ( '' !== $type_label ) { echo ' ' . esc_html( $type_label ) . ''; } echo '' . esc_html( (int) $c['incidents'] ) . '' . esc_html( (int) $c['occurrences'] ) . '' . Culprit_Settings::format_datetime( $c['last_seen'] ) . '
'; } echo '
'; } /** * Render the License tab. Paid build delegates to Culprit_License_Admin; * free build shows an upgrade prompt (the class is stripped there). */ private function render_license() { if ( class_exists( 'Culprit_License_Admin' ) ) { Culprit_License_Admin::render(); return; } // Free build: no licensing code present. Show the full registry-driven // upgrade pitch. echo '
'; echo '

' . esc_html__( 'Upgrade Culprit', 'culprit' ) . '

'; echo '

' . esc_html__( 'You are running the free version of Culprit. Everything below is already built and waiting in Premium and Elite.', 'culprit' ) . '

'; Culprit_UI::upgrade_box(); echo '
'; } /** * Render the status tab — watcher health. */ private function render_status() { $health = Culprit_Watcher_Installer::health(); echo '
'; echo '

' . esc_html__( 'Early-boot watcher', 'culprit' ) . '

'; $state_class = $health['active'] ? 'is-good' : ( $health['enabled'] ? 'is-warn' : 'is-warn' ); echo '

'; echo esc_html( $health['message'] ); echo '

'; echo '

' . esc_html__( 'Culprit always catches fatal errors that happen after plugins load, with no extra setup. Optionally, it can also install a tiny must-use loader (wp-content/mu-plugins/culprit-watcher.php) that runs before normal plugins, so crashes during earliest boot are captured too. Nothing is installed without your explicit choice here, and disabling removes the file immediately. It is also removed when Culprit is deactivated or uninstalled.', 'culprit' ) . '

'; if ( $health['enabled'] ) { $disable_url = wp_nonce_url( admin_url( 'admin-post.php?action=culprit_watcher_disable' ), 'culprit_watcher_disable' ); echo '

' . esc_html__( 'Disable early-boot watcher', 'culprit' ) . '

'; } else { $enable_url = wp_nonce_url( admin_url( 'admin-post.php?action=culprit_watcher_enable' ), 'culprit_watcher_enable' ); echo '

' . esc_html__( 'Enable early-boot watcher', 'culprit' ) . '

'; } echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo '
' . esc_html__( 'Early-boot watcher enabled', 'culprit' ) . '' . ( $health['enabled'] ? esc_html__( 'Yes', 'culprit' ) : esc_html__( 'No', 'culprit' ) ) . '
' . esc_html__( 'Watcher drop-in installed', 'culprit' ) . '' . ( $health['active'] ? esc_html__( 'Yes', 'culprit' ) : esc_html__( 'No', 'culprit' ) ) . '
' . esc_html__( 'Standard crash handler', 'culprit' ) . '' . ( $health['active'] ? esc_html__( 'Standby (watcher covers it)', 'culprit' ) : esc_html__( 'Active', 'culprit' ) ) . '
' . esc_html__( 'mu-plugins writable', 'culprit' ) . '' . ( $health['writable'] ? esc_html__( 'Yes', 'culprit' ) : esc_html__( 'No', 'culprit' ) ) . '
' . esc_html__( 'PHP version', 'culprit' ) . '' . esc_html( PHP_VERSION ) . '
'; echo '
'; } }