prepare(): values and ID lists as placeholders, table and // column names as %i. Fragments added to a query are code (Scope::sql()) or are prepared // where they are built (Scope::type_sql(), Lang::sql(), SeoMeta joins). /** * SupportPanel * * Admin-only section at the bottom of the Help page: live system status, * access/roles overview, developer hooks and troubleshooting. The same * collected data renders both as HTML and as a plain-text report that * can be copied or downloaded for support. * * Never includes secrets: the AI key is reported only as configured / * decrypts OK, never its value. * * Only rendered for manage_clusterlane — render() checks this itself, so * callers can't accidentally expose it to editors. */ class SupportPanel { const STATUS_OK = 'ok'; const STATUS_WARN = 'warn'; const STATUS_INFO = 'info'; public function __construct( private \Clusterlane\Core\Plugin $plugin ) {} /* ------------------------------------------------------------------ Data collection ------------------------------------------------------------------ */ /** * @return array}> */ public function collect(): array { return [ 'clusterlane' => [ 'title' => __( 'Clusterlane', 'clusterlane' ), 'icon' => 'bolt', 'rows' => $this->rows_plugin() ], 'data' => [ 'title' => __( 'Data & scan status', 'clusterlane' ), 'icon' => 'database', 'rows' => $this->rows_data() ], 'ai' => [ 'title' => __( 'AI', 'clusterlane' ), 'icon' => 'sparkle', 'rows' => $this->rows_ai() ], 'access' => [ 'title' => __( 'Access & roles', 'clusterlane' ), 'icon' => 'lock', 'rows' => $this->rows_access() ], 'wordpress' => [ 'title' => __( 'WordPress', 'clusterlane' ), 'icon' => 'layers', 'rows' => $this->rows_wordpress() ], 'server' => [ 'title' => __( 'Server & PHP', 'clusterlane' ), 'icon' => 'server', 'rows' => $this->rows_server() ], 'structure' => [ 'title' => __( 'Site structure', 'clusterlane' ), 'icon' => 'map', 'rows' => $this->rows_structure() ], 'plugins' => [ 'title' => __( 'Active plugins', 'clusterlane' ), 'icon' => 'nodes', 'rows' => $this->rows_plugins() ], ]; } private function rows_plugin(): array { $schema_installed = (string) get_option( \Clusterlane\Core\Installer::VERSION_KEY, '' ); $schema_expected = \Clusterlane\Core\Installer::SCHEMA_VERSION; $caps_version = (string) get_option( Capabilities::VERSION_KEY, '' ); return [ [ __( 'Plugin version', 'clusterlane' ), CLUSTERLANE_VERSION, self::STATUS_INFO ], [ __( 'Edition', 'clusterlane' ), \Clusterlane\Core\Edition::label() . ( defined( 'CLUSTERLANE_EDITION' ) && \Clusterlane\Core\Edition::premium_code() ? ' (CLUSTERLANE_EDITION)' : '' ) . ( \Clusterlane\Core\Edition::premium_code() ? '' : ' — ' . __( 'free build', 'clusterlane' ) ), self::STATUS_INFO ], ...$this->rows_license(), [ __( 'DB schema', 'clusterlane' ), $schema_installed . ( $schema_installed === $schema_expected ? '' : ' (expected ' . $schema_expected . ')' ), $schema_installed === $schema_expected ? self::STATUS_OK : self::STATUS_WARN ], [ __( 'Role mapping version', 'clusterlane' ), $caps_version !== '' ? $caps_version : __( 'not applied', 'clusterlane' ), $caps_version === Capabilities::VERSION ? self::STATUS_OK : self::STATUS_WARN ], ]; } /** Pro build only (1.2.0): the Freemius license behind the edition. */ private function rows_license(): array { if ( ! function_exists( 'clusterlane_fs' ) ) return []; $fs = clusterlane_fs(); if ( ! is_object( $fs ) ) return []; if ( $fs->can_use_premium_code() ) { $plan = method_exists( $fs, 'get_plan_title' ) ? (string) $fs->get_plan_title() : (string) $fs->get_plan_name(); return [ [ __( 'License', 'clusterlane' ), $plan . ( $fs->is_trial() ? ' ' . __( '(trial)', 'clusterlane' ) : '' ), self::STATUS_OK ] ]; } if ( \Clusterlane\Core\Edition::tier() !== 'free' ) { // No license, but a clusterlane_edition filter sets the edition (e.g. the owner's own sites). return [ [ __( 'License', 'clusterlane' ), __( 'none — the edition is set by the clusterlane_edition filter', 'clusterlane' ), self::STATUS_INFO ] ]; } return [ [ __( 'License', 'clusterlane' ), $fs->is_registered() ? __( 'no active license — running with the free features', 'clusterlane' ) : __( 'not activated — running with the free features', 'clusterlane' ), self::STATUS_WARN ] ]; } private function rows_data(): array { global $wpdb; $rows = []; $tables = [ 'clusterlane_clusters', 'clusterlane_post_metrics', 'clusterlane_cluster_post_class', 'clusterlane_links', 'clusterlane_ai_cache', 'clusterlane_custom_members', 'clusterlane_tms_skipped', 'clusterlane_snapshots', 'clusterlane_activity' ]; $missing = []; $counts = []; foreach ( $tables as $bare ) { $table = $wpdb->prefix . $bare; if ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ) !== $table ) { $missing[] = $bare; continue; } $counts[ $bare ] = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i', $table ) ); } $rows[] = [ __( 'Tables', 'clusterlane' ), $missing ? sprintf( /* translators: %s: text */ __( 'missing: %s', 'clusterlane' ), implode( ', ', $missing ) ) : sprintf( /* translators: %d: number of tables */ __( 'all %d present', 'clusterlane' ), count( $tables ) ), $missing ? self::STATUS_WARN : self::STATUS_OK ]; $scanner = $this->plugin->get( 'scanner' ); $summary = $scanner ? $scanner->get_summary() : []; $total = (int) ( $summary['total_posts'] ?? 0 ); $scanned = (int) ( $summary['total_scanned'] ?? 0 ); $rows[] = [ __( 'Posts scanned', 'clusterlane' ), sprintf( '%d / %d', $scanned, $total ), ( $total > 0 && $scanned >= $total ) ? self::STATUS_OK : self::STATUS_WARN ]; $last = $summary['last_scan'] ?? null; $rows[] = [ __( 'Last scan', 'clusterlane' ), $last ? $last . ' UTC (' . human_time_diff( strtotime( $last . ' UTC' ) ) . ' ' . __( 'ago', 'clusterlane' ) . ')' : __( 'never', 'clusterlane' ), $last ? self::STATUS_INFO : self::STATUS_WARN ]; $rows[] = [ __( 'Orphan articles', 'clusterlane' ), (string) (int) ( $summary['orphan_count'] ?? 0 ), self::STATUS_INFO ]; if ( isset( $counts['clusterlane_clusters'] ) ) { // Same definition as the dashboard hero: is_active = 1. $active = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$wpdb->prefix}clusterlane_clusters` WHERE is_active = 1" ); $inactive = $counts['clusterlane_clusters'] - $active; $rows[] = [ __( 'Clusters', 'clusterlane' ), $inactive > 0 ? sprintf( /* translators: %1$d: number, %2$d: number, %3$d: number */ __( '%1$d (%2$d active, %3$d inactive)', 'clusterlane' ), $counts['clusterlane_clusters'], $active, $inactive ) : sprintf( /* translators: %d: number */ __( '%d (all active)', 'clusterlane' ), $active ), self::STATUS_INFO ]; } foreach ( [ 'clusterlane_links' => __( 'Links indexed', 'clusterlane' ), 'clusterlane_ai_cache' => __( 'AI cache entries', 'clusterlane' ), 'clusterlane_custom_members' => __( 'Custom cluster members', 'clusterlane' ), 'clusterlane_tms_skipped' => __( 'Skipped text matches', 'clusterlane' ) ] as $bare => $label ) { if ( isset( $counts[ $bare ] ) ) { $value = (string) $counts[ $bare ]; if ( $bare === 'clusterlane_ai_cache' && $counts[ $bare ] > 0 ) { $bytes = (int) $wpdb->get_var( "SELECT SUM(LENGTH(response_data)) FROM `{$wpdb->prefix}clusterlane_ai_cache`" ); $value .= ' (' . size_format( $bytes, 1 ) . ')'; } $rows[] = [ $label, $value, self::STATUS_INFO ]; } } // History (0.98.124): daily snapshots and the activity log. if ( isset( $counts['clusterlane_snapshots'] ) ) { $h = \Clusterlane\Core\History::stats(); $next = wp_next_scheduled( \Clusterlane\Core\History::CRON_HOOK ); $rows[] = [ __( 'Snapshots', 'clusterlane' ), $h['count'] ? sprintf( /* translators: %1$d: number, %2$s: date, %3$s: date */ __( '%1$d (%2$s – %3$s)', 'clusterlane' ), $h['count'], $h['first'], $h['last'] ) : __( 'none yet — taken after each scan and once a day', 'clusterlane' ), self::STATUS_INFO ]; $rows[] = [ __( 'Daily snapshot', 'clusterlane' ), $next ? sprintf( /* translators: %s: date and time */ __( 'next %s', 'clusterlane' ), wp_date( 'Y-m-d H:i', $next ) ) : __( 'not scheduled', 'clusterlane' ), $next ? self::STATUS_OK : self::STATUS_WARN ]; } if ( isset( $counts['clusterlane_activity'] ) ) { $rows[] = [ __( 'Activity log', 'clusterlane' ), sprintf( /* translators: %d: number */ _n( '%d entry', '%d entries', $counts['clusterlane_activity'], 'clusterlane' ), $counts['clusterlane_activity'] ), self::STATUS_INFO ]; } $settings = $this->plugin->get( 'settings' ); if ( $settings ) { $configured = $settings->cluster_terms_configured(); $rows[] = [ __( 'Cluster setup completed', 'clusterlane' ), $configured ? __( 'yes', 'clusterlane' ) : __( 'no', 'clusterlane' ), $configured ? self::STATUS_OK : self::STATUS_WARN ]; $rows[] = [ __( 'Enabled categories / tags', 'clusterlane' ), count( $settings->enabled_categories() ) . ' / ' . count( $settings->enabled_tags() ), self::STATUS_INFO ]; $rows[] = [ __( 'Scan batch size', 'clusterlane' ), (string) (int) $settings->get( 'batch_size', 25 ), self::STATUS_INFO ]; } return $rows; } /** * How the site's content is organised (1.1.1): which post types hold the * content and whether they are scanned, which taxonomies sort it, and how * well that turns into clusters. Explains the usual reasons Clusterlane * finds little on a site with plenty of content — articles in a custom * post type that isn't scanned, most posts left in the default category, * one category holding nearly everything, or categories that aren't used * as clusters while another taxonomy (regions, topics) carries the structure. */ private function rows_structure(): array { global $wpdb; $settings = $this->plugin->get( 'settings' ); if ( ! $settings ) return [ [ __( 'Settings', 'clusterlane' ), __( 'unavailable', 'clusterlane' ), self::STATUS_WARN ] ]; $rows = []; $scanned = array_values( array_filter( array_map( 'sanitize_key', (array) $settings->post_types() ) ) ); $rendered = $settings->rendered_types(); // 1. Post types with their own pages, scanned or not. $types = get_post_types( [ 'public' => true ], 'objects' ) + get_post_types( [ 'publicly_queryable' => true ], 'objects' ); unset( $types['attachment'] ); foreach ( $types as $slug => $obj ) { $n = (int) ( wp_count_posts( $slug )->publish ?? 0 ); $is_scanned = in_array( $slug, $scanned, true ); if ( ! $is_scanned && $n === 0 ) continue; // Pages are usually fixed site pages (About, Contact) — not scanning them is normal. $warn = ! $is_scanned && $slug !== 'page' && $n >= 20; $value = sprintf( /* translators: %d: number of published items */ _n( '%d published', '%d published', $n, 'clusterlane' ), $n ) . ' · ' . ( $is_scanned ? ( in_array( $slug, $rendered, true ) ? __( 'scanned, pages read', 'clusterlane' ) : __( 'scanned', 'clusterlane' ) ) : __( 'not scanned', 'clusterlane' ) ); if ( $warn ) $value .= ' — ' . __( 'if these are articles, add the type under Settings → Content', 'clusterlane' ); $rows[] = [ $obj->labels->name . ' (' . $slug . ')', $value, $is_scanned ? self::STATUS_OK : ( $warn ? self::STATUS_WARN : self::STATUS_INFO ) ]; } if ( ! $scanned ) { $rows[] = [ __( 'Scanned types', 'clusterlane' ), __( 'none selected', 'clusterlane' ), self::STATUS_WARN ]; return $rows; } $total = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_status = 'publish' AND post_type IN (" . implode( ',', array_fill( 0, count( $scanned ), '%s' ) ) . ")", $scanned ) ); if ( $total === 0 ) return $rows; // Active clusters per source (taxonomy name, 'custom' or hub_). $sources = []; $ctable = $wpdb->prefix . 'clusterlane_clusters'; $has_ct = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $ctable ) ) === $ctable; if ( $has_ct ) { foreach ( (array) $wpdb->get_results( $wpdb->prepare( 'SELECT cluster_type, COUNT(*) AS n FROM %i WHERE is_active = 1 GROUP BY cluster_type', $ctable ) ) as $r ) { $sources[ (string) $r->cluster_type ] = (int) $r->n; } } // 2. Taxonomies that sort the scanned content. foreach ( get_object_taxonomies( $scanned, 'objects' ) as $tax => $obj ) { if ( $tax === 'post_format' || ( ! $obj->public && ! $obj->show_ui ) ) continue; $r = $wpdb->get_row( $wpdb->prepare( "SELECT COUNT(DISTINCT tt.term_id) AS terms, COUNT(*) AS rels, COUNT(DISTINCT p.ID) AS posts FROM {$wpdb->term_relationships} tr JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id JOIN {$wpdb->posts} p ON p.ID = tr.object_id WHERE tt.taxonomy = %s AND p.post_status = 'publish' AND p.post_type IN (" . implode( ',', array_fill( 0, count( $scanned ), '%s' ) ) . ")", array_merge( [ $tax ], $scanned ) ) ); if ( ! $r || ! (int) $r->terms ) continue; // registered but unused $posts = (int) $r->posts; $clusters = (int) ( $sources[ $tax ] ?? 0 ); $parts = [ sprintf( /* translators: %d: number of terms */ _n( '%d term', '%d terms', (int) $r->terms, 'clusterlane' ), (int) $r->terms ), sprintf( /* translators: %d: percent of articles */ __( 'on %d%% of articles', 'clusterlane' ), (int) round( 100 * $posts / $total ) ), sprintf( /* translators: %s: average number of terms */ __( '%s per article', 'clusterlane' ), number_format_i18n( (int) $r->rels / max( 1, $posts ), 1 ) ), ]; if ( $obj->hierarchical ) { $depth = self::term_depth( $tax ); if ( $depth > 1 ) $parts[] = sprintf( /* translators: %d: number of levels */ __( '%d levels', 'clusterlane' ), $depth ); } $parts[] = $clusters ? sprintf( /* translators: %d: number of clusters */ _n( '%d used as cluster', '%d used as clusters', $clusters, 'clusterlane' ), $clusters ) : __( 'not used as clusters', 'clusterlane' ); $rows[] = [ $obj->labels->name . ' (' . $tax . ')', implode( ' · ', $parts ), self::STATUS_INFO ]; } $other = array_diff_key( $sources, array_flip( get_object_taxonomies( $scanned ) ) ); if ( $other ) { $labels = []; foreach ( $other as $type => $n ) { $labels[] = ( $type === 'custom' ? __( 'custom', 'clusterlane' ) : ( str_starts_with( $type, 'hub_' ) ? __( 'hub pages', 'clusterlane' ) : $type ) ) . ': ' . $n; } $rows[] = [ __( 'Other clusters', 'clusterlane' ), implode( ' · ', $labels ), self::STATUS_INFO ]; } // 3. Posts left only in the default category ("Uncategorized"). $default = (int) get_option( 'default_category' ); if ( $default && in_array( 'post', $scanned, true ) ) { $only = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM ( SELECT p.ID FROM {$wpdb->posts} p JOIN {$wpdb->term_relationships} tr ON tr.object_id = p.ID JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id AND tt.taxonomy = 'category' WHERE p.post_type = 'post' AND p.post_status = 'publish' GROUP BY p.ID HAVING COUNT(*) = 1 AND MAX(tt.term_id) = %d ) x", $default ) ); if ( $only > 0 ) { $term = get_term( $default, 'category' ); $pct = (int) round( 100 * $only / $total ); $warn = $only >= 10 && $pct >= 20; $rows[] = [ sprintf( /* translators: %s: category name */ __( 'Only in “%s”', 'clusterlane' ), $term && ! is_wp_error( $term ) ? $term->name : __( 'default category', 'clusterlane' ) ), sprintf( /* translators: %1$d: number, %2$d: percent */ __( '%1$d articles (%2$d%%)', 'clusterlane' ), $only, $pct ) . ( $warn ? ' — ' . __( 'give them a real category so they can join a cluster', 'clusterlane' ) : '' ), $warn ? self::STATUS_WARN : self::STATUS_INFO, ]; } } // 4. How the scanned articles spread over the active clusters. $mtable = $wpdb->prefix . 'clusterlane_post_metrics'; $ptable = $wpdb->prefix . 'clusterlane_cluster_post_class'; if ( $has_ct && $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $ptable ) ) === $ptable && $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $mtable ) ) === $mtable ) { $n_scanned = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i m JOIN {$wpdb->posts} p ON p.ID = m.post_id AND p.post_status = 'publish' AND p.post_type IN (" . implode( ',', array_fill( 0, count( $scanned ), '%s' ) ) . ")", array_merge( [ $mtable ], $scanned ) ) ); if ( $n_scanned > 0 ) { $outside = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i m JOIN {$wpdb->posts} p ON p.ID = m.post_id AND p.post_status = 'publish' AND p.post_type IN (" . implode( ',', array_fill( 0, count( $scanned ), '%s' ) ) . ") WHERE NOT EXISTS ( SELECT 1 FROM %i c JOIN %i k ON k.cluster_key = c.cluster_key AND k.is_active = 1 WHERE c.post_id = m.post_id )", array_merge( [ $mtable ], $scanned, [ $ptable, $ctable ] ) ) ); $pct = (int) round( 100 * $outside / $n_scanned ); $warn = $n_scanned >= 20 && $pct >= 40; $rows[] = [ __( 'Articles in no cluster', 'clusterlane' ), sprintf( /* translators: %1$d: number, %2$d: number, %3$d: percent */ __( '%1$d of %2$d (%3$d%%)', 'clusterlane' ), $outside, $n_scanned, $pct ) . ( $warn ? ' — ' . __( 'choose more categories or tags in Cluster Admin, or the taxonomy that carries the structure', 'clusterlane' ) : '' ), $warn ? self::STATUS_WARN : ( $outside === 0 ? self::STATUS_OK : self::STATUS_INFO ) ]; $sizes = array_map( 'intval', (array) $wpdb->get_col( $wpdb->prepare( 'SELECT total_members FROM %i WHERE is_active = 1 ORDER BY total_members', $ctable ) ) ); if ( count( $sizes ) > 1 ) { $rows[] = [ __( 'Cluster sizes', 'clusterlane' ), sprintf( /* translators: %1$d: smallest, %2$d: median, %3$d: largest */ __( 'smallest %1$d · median %2$d · largest %3$d', 'clusterlane' ), $sizes[0], $sizes[ intdiv( count( $sizes ), 2 ) ], end( $sizes ) ), self::STATUS_INFO ]; } $big = $wpdb->get_row( $wpdb->prepare( 'SELECT cluster_label, total_members FROM %i WHERE is_active = 1 ORDER BY total_members DESC LIMIT 1', $ctable ) ); if ( $big && count( $sizes ) > 1 ) { $share = (int) round( 100 * (int) $big->total_members / $n_scanned ); $warn = $n_scanned >= 30 && $share > 50; $rows[] = [ __( 'Largest cluster', 'clusterlane' ), sprintf( /* translators: %1$s: cluster name, %2$d: number, %3$d: percent */ __( '%1$s — %2$d articles (%3$d%%)', 'clusterlane' ), (string) $big->cluster_label, (int) $big->total_members, $share ) . ( $warn ? ' — ' . __( 'very broad; subcategories or tags give sharper clusters', 'clusterlane' ) : '' ), $warn ? self::STATUS_WARN : self::STATUS_INFO ]; } } } // 5. Page builders on scanned articles (text is read, but some writes are blocked). $builders = []; if ( defined( 'ELEMENTOR_VERSION' ) ) $builders['Elementor'] = [ '_elementor_edit_mode', 'builder' ]; if ( defined( 'FL_BUILDER_VERSION' ) ) $builders['Beaver Builder'] = [ '_fl_builder_enabled', '1' ]; foreach ( $builders as $name => [ $key, $val ] ) { $n = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->postmeta} pm JOIN {$wpdb->posts} p ON p.ID = pm.post_id WHERE pm.meta_key = %s AND pm.meta_value = %s AND p.post_status = 'publish' AND p.post_type IN (" . implode( ',', array_fill( 0, count( $scanned ), '%s' ) ) . ")", array_merge( [ $key, $val ], $scanned ) ) ); if ( $n ) { $rows[] = [ $name, sprintf( /* translators: %1$d: number, %2$d: number */ __( '%1$d of %2$d articles', 'clusterlane' ), $n, $total ), self::STATUS_INFO ]; } } return $rows; } /** Number of levels in a hierarchical taxonomy (1 = flat). */ private static function term_depth( string $tax ): int { global $wpdb; $parents = []; foreach ( (array) $wpdb->get_results( $wpdb->prepare( "SELECT term_id, parent FROM {$wpdb->term_taxonomy} WHERE taxonomy = %s", $tax ) ) as $r ) { $parents[ (int) $r->term_id ] = (int) $r->parent; } $max = 0; foreach ( array_keys( $parents ) as $id ) { $d = 1; $seen = [ $id => true ]; while ( ( $p = $parents[ $id ] ?? 0 ) && isset( $parents[ $p ] ) && ! isset( $seen[ $p ] ) && $d < 20 ) { $seen[ $p ] = true; $id = $p; $d++; } $max = max( $max, $d ); } return $max; } /** "Claude Opus 5.5 (claude-opus-5-5)" — readable name plus the exact ID for support. */ private static function model_name( string $model ): string { $cat = class_exists( \Clusterlane\Core\AiClient::class ) ? \Clusterlane\Core\AiClient::catalog() : []; return isset( $cat[ $model ] ) ? $cat[ $model ]['label'] . ' (' . $model . ')' : $model; } private function rows_ai(): array { $settings = $this->plugin->get( 'settings' ); if ( ! $settings ) return [ [ __( 'Settings', 'clusterlane' ), __( 'unavailable', 'clusterlane' ), self::STATUS_WARN ] ]; // No AI in this edition (or premium code not installed): one neutral row, no key warnings. if ( ! \Clusterlane\Core\Edition::has( 'ai' ) || ! class_exists( \Clusterlane\Core\AiUsage::class ) ) { return [ [ __( 'AI features', 'clusterlane' ), \Clusterlane\Core\Edition::missing_message( 'ai' ), self::STATUS_INFO ] ]; } $source = $settings->ai_source(); // site | network | none $has_key = $source !== 'none'; // A key can be stored but undecryptable (e.g. AUTH_KEY changed after a migration). $decrypts = $has_key && $settings->ai_api_key() !== ''; $where = $source === 'network' ? __( 'Network Admin → Clusterlane', 'clusterlane' ) : __( 'Settings', 'clusterlane' ); $rows = [ [ __( 'Provider', 'clusterlane' ), $settings->ai_provider(), self::STATUS_INFO ], [ __( 'Everyday model', 'clusterlane' ), self::model_name( $settings->ai_model() ), self::STATUS_INFO ], [ __( 'Analysis model', 'clusterlane' ), $source === 'network' ? __( 'network key — one model for everything', 'clusterlane' ) : ( (string) $settings->get( 'ai_model_analysis', '' ) !== '' ? self::model_name( (string) $settings->get( 'ai_model_analysis', '' ) ) : __( 'same as everyday model', 'clusterlane' ) ), self::STATUS_INFO ], [ __( 'Tone of analyses', 'clusterlane' ), (string) $settings->get( 'ai_tone', 'editorial' ) === 'technical' ? __( 'technical', 'clusterlane' ) : __( 'editorial', 'clusterlane' ), self::STATUS_INFO ], [ __( 'API key', 'clusterlane' ), ! $has_key ? __( 'not configured', 'clusterlane' ) : ( $decrypts ? __( 'configured, decrypts OK', 'clusterlane' ) : sprintf( /* translators: %s: text */ __( 'stored but cannot be decrypted — re-enter it in %s', 'clusterlane' ), $where ) ), $decrypts ? self::STATUS_OK : self::STATUS_WARN ], ]; $u = \Clusterlane\Core\AiUsage::current(); $limit = \Clusterlane\Core\AiUsage::user_limit( $settings ); $rows[] = [ __( 'Usage this month', 'clusterlane' ), sprintf( /* translators: %1$d: number, %2$s: text, %3$s: text */ __( '%1$d calls · %2$s tokens · %3$s est.', 'clusterlane' ), (int) $u['calls'], number_format_i18n( $u['input'] + $u['output'] ), \Clusterlane\Core\AiUsage::format_cost( (float) $u['cost'] ) ), self::STATUS_INFO ]; // Calls (and cost, where tracked) per model this month. $models = (array) ( $u['models'] ?? [] ); if ( $models ) { arsort( $models ); $cat = class_exists( \Clusterlane\Core\AiClient::class ) ? \Clusterlane\Core\AiClient::catalog() : []; $parts = []; foreach ( $models as $m => $n ) { $cost = $u['model_cost'][ $m ] ?? null; $parts[] = ( $cat[ $m ]['label'] ?? $m ) . ': ' . (int) $n . ( $cost !== null ? ' (' . \Clusterlane\Core\AiUsage::format_cost( (float) $cost ) . ')' : '' ); } $rows[] = [ __( 'Calls by model', 'clusterlane' ), implode( ' · ', $parts ), self::STATUS_INFO ]; } $rows[] = [ __( 'Editor limit', 'clusterlane' ), $limit ? sprintf( /* translators: %1$d: number, %2$d: number */ __( '%1$d / %2$d calls', 'clusterlane' ), (int) $u['calls_limited'], $limit ) : __( 'none', 'clusterlane' ), ( $limit && $u['calls_limited'] >= $limit ) ? self::STATUS_WARN : self::STATUS_INFO ]; if ( class_exists( \Clusterlane\Core\NetworkAi::class ) && \Clusterlane\Core\NetworkAi::enabled() ) { if ( $source === 'network' && \Clusterlane\Core\NetworkAi::site_limit() > 0 ) { $nl = \Clusterlane\Core\NetworkAi::site_limit(); $rows[] = [ __( 'Network allowance', 'clusterlane' ), sprintf( /* translators: %1$d: number, %2$d: number */ __( '%1$d / %2$d calls', 'clusterlane' ), (int) $u['calls'], $nl ), $u['calls'] >= $nl ? self::STATUS_WARN : self::STATUS_INFO ]; } $rows[] = [ __( 'Key source', 'clusterlane' ), [ 'site' => __( 'this site’s own key', 'clusterlane' ), 'network' => __( 'network key', 'clusterlane' ), 'none' => __( 'none', 'clusterlane' ) ][ $source ], self::STATUS_INFO ]; $rows[] = [ __( 'Network policy', 'clusterlane' ), \Clusterlane\Core\NetworkAi::site_keys_allowed() ? __( 'site keys allowed', 'clusterlane' ) : __( 'network key only', 'clusterlane' ), self::STATUS_INFO ]; } return $rows; } private function rows_access(): array { $rows = []; $counts = count_users(); $avail = $counts['avail_roles'] ?? []; foreach ( wp_roles()->roles as $slug => $role ) { $caps = $role['capabilities'] ?? []; $manage = ! empty( $caps[ Capabilities::MANAGE ] ); $use = ! empty( $caps[ Capabilities::USE ] ); $fallback = ! empty( $caps['manage_options'] ) && apply_filters( 'clusterlane_admin_cap_fallback', true ); if ( ! $manage && ! $use && ! $fallback ) continue; $ai = $manage || $fallback || ! empty( $caps[ Capabilities::USE_AI ] ); $links = $manage || $fallback || ! empty( $caps[ Capabilities::LINKS ] ); $seo = $manage || $fallback || ! empty( $caps[ Capabilities::SEO ] ); $level = ( ( $manage || $fallback ) ? 'manage + use' : 'use' ) . ( $ai ? ' + AI' : '' ) . ( $links ? ' + links' : '' ) . ( $seo ? ' + SEO' : '' ); if ( ! $manage && $fallback ) $level .= ' ' . __( '(via manage_options fallback)', 'clusterlane' ); $rows[] = [ translate_user_role( $role['name'] ) . ' (' . $slug . ')', $level . ' · ' . sprintf( /* translators: %d: number */ _n( '%d user', '%d users', (int) ( $avail[ $slug ] ?? 0 ), 'clusterlane' ), (int) ( $avail[ $slug ] ?? 0 ) ), self::STATUS_INFO, ]; } if ( ! $rows ) { $rows[] = [ __( 'Roles', 'clusterlane' ), __( 'no role has Clusterlane access', 'clusterlane' ), self::STATUS_WARN ]; } $rows[] = [ __( 'Admin lock-out fallback', 'clusterlane' ), apply_filters( 'clusterlane_admin_cap_fallback', true ) ? __( 'active', 'clusterlane' ) : __( 'disabled by filter', 'clusterlane' ), self::STATUS_INFO ]; $rows[] = [ __( 'Role map filtered', 'clusterlane' ), has_filter( 'clusterlane_role_capabilities' ) ? __( 'yes (custom mapping)', 'clusterlane' ) : __( 'no (defaults)', 'clusterlane' ), self::STATUS_INFO ]; return $rows; } private function rows_wordpress(): array { global $wp_version; $theme = wp_get_theme(); $parent = $theme->parent(); $rows = [ [ __( 'Site URL', 'clusterlane' ), home_url(), self::STATUS_INFO ], [ __( 'WordPress', 'clusterlane' ), (string) $wp_version, self::STATUS_INFO ], ...$this->rows_multisite(), [ __( 'Language', 'clusterlane' ), get_locale(), self::STATUS_INFO ], [ __( 'Multilingual', 'clusterlane' ), \Clusterlane\Core\Lang::active() ? ( \Clusterlane\Core\Lang::plugin() === 'wpml' ? 'WPML' : 'Polylang' ) . ': ' . implode( ', ', array_keys( \Clusterlane\Core\Lang::languages() ) ) : __( 'No', 'clusterlane' ), self::STATUS_INFO ], $this->row_timezone(), [ __( 'Theme', 'clusterlane' ), $theme->get( 'Name' ) . ' ' . $theme->get( 'Version' ) . ( $parent ? ' (child of ' . $parent->get( 'Name' ) . ' ' . $parent->get( 'Version' ) . ')' : '' ), self::STATUS_INFO ], ...$this->rows_debug(), [ 'WP_MEMORY_LIMIT', defined( 'WP_MEMORY_LIMIT' ) ? (string) WP_MEMORY_LIMIT : '—', self::STATUS_INFO ], [ 'DISABLE_WP_CRON', ( defined( 'DISABLE_WP_CRON' ) && DISABLE_WP_CRON ) ? 'true' : 'false', self::STATUS_INFO ], ]; return $rows; } private function rows_multisite(): array { if ( ! is_multisite() ) { return [ [ __( 'Multisite', 'clusterlane' ), __( 'no', 'clusterlane' ), self::STATUS_INFO ] ]; } return [ [ __( 'Multisite', 'clusterlane' ), sprintf( /* translators: 1: activation mode, 2: number of sites */ __( 'yes · %1$s · %2$d sites', 'clusterlane' ), is_subdomain_install() ? __( 'subdomain', 'clusterlane' ) : __( 'subdirectory', 'clusterlane' ), (int) get_sites( [ 'count' => true ] ) ), self::STATUS_INFO ], [ __( 'This site ID', 'clusterlane' ), (string) get_current_blog_id() . ( is_main_site() ? ' ' . __( '(main site)', 'clusterlane' ) : '' ), self::STATUS_INFO ], [ __( 'Clusterlane activation', 'clusterlane' ), \Clusterlane\Core\Multisite::is_network_active() ? __( 'network-wide', 'clusterlane' ) : __( 'this site only', 'clusterlane' ), self::STATUS_INFO ], ]; } /** * A fixed UTC offset (e.g. "+02:00") doesn't follow daylight saving — * the site clock is an hour off half the year. A city (Europe/Stockholm) does. */ private function row_timezone(): array { $tz_string = (string) get_option( 'timezone_string', '' ); if ( $tz_string !== '' ) { return [ __( 'Timezone', 'clusterlane' ), $tz_string, self::STATUS_OK ]; } return [ __( 'Timezone', 'clusterlane' ), sprintf( /* translators: %s: text */ __( '%s — fixed offset, ignores daylight saving. Pick a city under Settings → General.', 'clusterlane' ), wp_timezone_string() ), self::STATUS_WARN ]; } /** * WP_DEBUG itself is fine in production as long as errors are logged, * not printed. Warn only when they'd be displayed to visitors. */ private function rows_debug(): array { $debug = defined( 'WP_DEBUG' ) && WP_DEBUG; // WP_DEBUG_DISPLAY defaults to true when undefined. $display = ! defined( 'WP_DEBUG_DISPLAY' ) || WP_DEBUG_DISPLAY; $log = defined( 'WP_DEBUG_LOG' ) ? WP_DEBUG_LOG : false; $log_label = $log === false ? 'off' : ( is_string( $log ) ? $log : 'on (wp-content/debug.log)' ); return [ [ 'WP_DEBUG', $debug ? 'on' : 'off', self::STATUS_INFO ], [ 'WP_DEBUG_DISPLAY', ( $display ? 'on' : 'off' ) . ( $debug && $display ? ' ' . __( '— errors may be shown to visitors', 'clusterlane' ) : '' ), $debug && $display ? self::STATUS_WARN : ( $debug ? self::STATUS_OK : self::STATUS_INFO ) ], [ 'WP_DEBUG_LOG', $log_label, self::STATUS_INFO ], ]; } private function rows_server(): array { global $wpdb; $mem_raw = (string) ini_get( 'memory_limit' ); $mem_bytes = wp_convert_hr_to_bytes( $mem_raw ); $mem_ok = $mem_raw === '-1' || $mem_bytes >= 256 * MB_IN_BYTES; $disabled = array_map( 'trim', explode( ',', (string) ini_get( 'disable_functions' ) ) ); $stl_ok = function_exists( 'set_time_limit' ) && ! in_array( 'set_time_limit', $disabled, true ); $php_ok = version_compare( PHP_VERSION, '8.0', '>=' ); $cdn = isset( $_SERVER['HTTP_CF_RAY'] ) ? 'Cloudflare' : __( 'none detected', 'clusterlane' ); return [ [ 'PHP', PHP_VERSION, $php_ok ? self::STATUS_OK : self::STATUS_WARN ], [ 'memory_limit', $mem_raw . ( $mem_ok ? '' : ' ' . __( '(256M+ recommended)', 'clusterlane' ) ), $mem_ok ? self::STATUS_OK : self::STATUS_WARN ], [ 'max_execution_time', (string) ini_get( 'max_execution_time' ), self::STATUS_INFO ], [ 'set_time_limit()', $stl_ok ? __( 'available', 'clusterlane' ) : __( 'disabled — long AI calls may time out', 'clusterlane' ), $stl_ok ? self::STATUS_OK : self::STATUS_WARN ], [ 'OPcache', ini_get( 'opcache.enable' ) ? __( 'enabled', 'clusterlane' ) : __( 'disabled', 'clusterlane' ), self::STATUS_INFO ], [ __( 'Database', 'clusterlane' ), $wpdb->db_server_info() ?: $wpdb->db_version(), self::STATUS_INFO ], [ __( 'Web server', 'clusterlane' ), sanitize_text_field( wp_unslash( $_SERVER['SERVER_SOFTWARE'] ?? '—' ) ), self::STATUS_INFO ], [ __( 'CDN / proxy', 'clusterlane' ), $cdn, self::STATUS_INFO ], ]; } private function rows_plugins(): array { if ( ! function_exists( 'get_plugins' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } $all = get_plugins(); $active = (array) get_option( 'active_plugins', [] ); $network = is_multisite() ? array_keys( (array) get_site_option( 'active_sitewide_plugins', [] ) ) : []; $rows = []; foreach ( array_unique( array_merge( $active, $network ) ) as $file ) { if ( ! isset( $all[ $file ] ) ) continue; $label = $all[ $file ]['Name']; if ( in_array( $file, $network, true ) ) $label .= ' [network]'; $rows[] = [ $label, (string) $all[ $file ]['Version'], self::STATUS_INFO ]; } usort( $rows, fn( $a, $b ) => strcasecmp( $a[0], $b[0] ) ); return $rows ?: [ [ __( 'Plugins', 'clusterlane' ), '—', self::STATUS_INFO ] ]; } /* ------------------------------------------------------------------ Plain-text report ------------------------------------------------------------------ */ public function to_text( array $sections ): string { $out = []; $out[] = '=== ' . \Clusterlane\Core\Brand::name() . ' System Report ==='; $out[] = 'Generated: ' . gmdate( 'Y-m-d H:i:s' ) . ' UTC'; $out[] = 'Contains no API keys or passwords.'; $out[] = ''; $warnings = []; foreach ( $sections as $section ) { foreach ( $section['rows'] as $row ) { if ( $row[2] === self::STATUS_WARN ) $warnings[] = $section['title'] . ' → ' . $row[0] . ': ' . $row[1]; } } $out[] = '-- Warnings (' . count( $warnings ) . ') --'; $out = array_merge( $out, $warnings ?: [ 'none' ] ); $out[] = ''; foreach ( $sections as $section ) { $out[] = '-- ' . $section['title'] . ' --'; // Cap the label column so one very long plugin name doesn't push // every value far to the right; longer labels are truncated. $max = 40; $width = 0; foreach ( $section['rows'] as $row ) $width = max( $width, min( $max, mb_strlen( $row[0] ) ) ); foreach ( $section['rows'] as $row ) { $label = mb_strlen( $row[0] ) > $max ? mb_substr( $row[0], 0, $max - 1 ) . '…' : $row[0]; $flag = $row[2] === self::STATUS_WARN ? ' [!]' : ''; $out[] = $label . str_repeat( ' ', $width - mb_strlen( $label ) ) . ' : ' . $row[1] . $flag; } $out[] = ''; } return implode( "\n", $out ); } /* ------------------------------------------------------------------ HTML ------------------------------------------------------------------ */ public function render(): void { if ( ! Capabilities::can_manage() ) return; $sections = $this->collect(); $text = $this->to_text( $sections ); $host = sanitize_file_name( (string) wp_parse_url( home_url(), PHP_URL_HOST ) ); $filename = 'clusterlane-report-' . $host . '-' . gmdate( 'Ymd-His' ) . '.txt'; $warn_count = 0; foreach ( $sections as $s ) foreach ( $s['rows'] as $r ) if ( $r[2] === self::STATUS_WARN ) $warn_count++; ?>

$section ): ?>

add_filter( 'clusterlane_role_capabilities', function ( $map ) {
    $map['author'] = [ 'use_clusterlane' ];
    return $map;
} );
// Re-apply the mapping once:
// delete_option( 'clusterlane_caps_version' );

add_filter( 'clusterlane_admin_cap_fallback', '__return_false' );

( function () { var report = document.getElementById( 'clusterlane-support-report' ); var copyBtn = document.getElementById( 'clusterlane-support-copy' ); var dlBtn = document.getElementById( 'clusterlane-support-download' ); if ( ! report || ! copyBtn || ! dlBtn ) return; function flash( btn, label ) { var original = btn.innerHTML; btn.textContent = label; btn.disabled = true; setTimeout( function () { btn.innerHTML = original; btn.disabled = false; }, 1600 ); } copyBtn.addEventListener( 'click', function () { var text = report.value; if ( navigator.clipboard && window.isSecureContext ) { navigator.clipboard.writeText( text ).then( function () { flash( copyBtn, '✓ ' ); }, function () { fallbackCopy( text ); } ); } else { fallbackCopy( text ); } } ); function fallbackCopy( text ) { report.style.display = 'block'; report.select(); try { document.execCommand( 'copy' ); flash( copyBtn, '✓ ' ); } catch ( e ) { flash( copyBtn, '' ); } report.style.display = ''; } dlBtn.addEventListener( 'click', function () { var blob = new Blob( [ report.value ], { type: 'text/plain;charset=utf-8' } ); var url = URL.createObjectURL( blob ); var a = document.createElement( 'a' ); a.href = url; a.download = dlBtn.dataset.filename || 'clusterlane-report.txt'; document.body.appendChild( a ); a.click(); a.remove(); setTimeout( function () { URL.revokeObjectURL( url ); }, 1000 ); flash( dlBtn, '✓ ' ); } ); } )();