logging_repository = $logging_repository; } /** * Register WordPress hooks. * * @return void * @since 1.0.0 */ public function register_hooks(): void { add_action( 'admin_menu', [ $this, 'add_menu_pages' ], 9 ); // Priority 9 to run before Abilities at 10. add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_assets' ] ); } /** * Add top-level menu and dashboard page. * * Creates the top-level "Albert" menu with Dashboard as the default page, * then adds "Dashboard" as the first submenu (which replaces the auto-generated one). * * @return void * @since 1.0.0 */ public function add_menu_pages(): void { // Add top-level menu (shows Dashboard by default). add_menu_page( __( 'Albert Dashboard', 'albert-ai-butler' ), __( 'Albert', 'albert-ai-butler' ), 'manage_options', $this->page_slug, [ $this, 'render_dashboard_page' ], 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbDpzcGFjZT0icHJlc2VydmUiIHZpZXdCb3g9IjAgMCAyNTYgMjU2Ij48cGF0aCBmaWxsPSIjYTdhYWFkIiBkPSJNNjkuNCAxNC40Yy0uOS44LTEgMy40LTEgMTguOSAwIDE2LjcuMSAxOC4xIDEuMSAxOSAuNi42IDEuNSAxIDIgMXM5LjgtMi4zIDIwLjctNWwxOS44LTUgMi44IDIuOWM3LjggOC4xIDE5LjUgOC4yIDI3LjMuNWwzLjQtMy40IDIwLjEgNS4xYzEyLjggMy4zIDIwLjUgNC45IDIxLjQgNC42LjctLjIgMS42LTEuMSAyLTIgLjMtLjguNi04LjkuNi0xNy45IDAtMTcuOS0uMi0xOS40LTMuMi0xOS43LS45LS4xLTEwLjUgMi0yMS4zIDQuOGwtMTkuNyA1LTIuOC0zLjFjLTMuOS00LjEtNy45LTUuOC0xMy44LTUuOS01LjcgMC0xMC40IDItMTQuMSA2LjJsLTIuNSAyLjgtMTkuNC00LjljLTEwLjYtMi44LTIwLTUtMjAuOS01LS45LjEtMiAuNS0yLjUgMS4xeiIvPjxwYXRoIGZpbGw9IiNhN2FhYWQiIGQ9Ik0yNS4yIDUyLjRjLTYgMy41LTExLjUgNi44LTEyLjIgNy40LTMuMSAyLjgtMy0xLjctMyA5MS4xIDAgNjMuNC4yIDg3LjEuNyA4OC4yIDEuNyAzLjYtNSAzLjQgMTE3LjMgMy40czExNS43LjIgMTE3LjMtMy40Yy41LTEuMS43LTI0LjIuNy04NS43IDAtODIuOCAwLTg0LjItMS4yLTg2LjYtMS4zLTIuNi0yLTMuMy0xNS40LTEzLjgtNC45LTMuOS05LjEtNi44LTkuMy02LjYtLjIuMi0xOS40IDM3LjEtNDIuNyA4MS45LTIzLjIgNDQuOC00My44IDg0LjMtNDUuNyA4Ny44bC0zLjQgNi4zLTQuMS03LjktNDUuOC04Ny44Yy0yMi45LTQzLjktNDEuNy04MC4xLTQyLTgwLjMtLjEtLjEtNS4yIDIuNS0xMS4yIDZ6Ii8+PHBhdGggZmlsbD0iI2E3YWFhZCIgZD0iTTEyNS42IDc2LjdjLTcuOSAxLjUtMTMuNCA5LjgtMTEuNyAxNy44IDIuOCAxMi45IDE5LjQgMTYuNiAyNyA2IDguMS0xMS4yLTEuNy0yNi4zLTE1LjMtMjMuOHpNMTIzLjYgMTI4YTE0LjQgMTQuNCAwIDAgMC04LjIgNy41Yy0zLjEgNi4zLTEuOCAxMy42IDMuMyAxOC4xIDMuMSAyLjcgNS43IDMuNiAxMCAzLjYgNC40IDAgNy42LTEuMiAxMC4zLTMuOSA5LjctOS4zIDQuMS0yNS4yLTkuMy0yNi0yLjQtLjEtNC43LjEtNi4xLjd6Ii8+PC9zdmc+', 80 ); // Add Dashboard submenu (replaces auto-generated first submenu). add_submenu_page( $this->parent_slug, __( 'Dashboard', 'albert-ai-butler' ), __( 'Dashboard', 'albert-ai-butler' ), 'manage_options', $this->page_slug, [ $this, 'render_dashboard_page' ] ); } /** * Enqueue dashboard assets. * * @param string $hook Current admin page hook. * * @return void * @since 1.0.0 */ public function enqueue_assets( string $hook ): void { // Only load on our dashboard page. if ( $hook !== 'toplevel_page_albert' ) { return; } wp_enqueue_style( 'albert-admin', ALBERT_PLUGIN_URL . 'assets/css/admin-settings.css', [], ALBERT_VERSION ); wp_enqueue_script( 'albert-admin-utils', ALBERT_PLUGIN_URL . 'assets/js/albert-admin-utils.js', [], ALBERT_VERSION, true ); wp_enqueue_script( 'albert-dashboard', ALBERT_PLUGIN_URL . 'assets/js/admin-dashboard.js', [ 'albert-admin-utils' ], ALBERT_VERSION, true ); } /** * Render dashboard page. * * @return void * @since 1.0.0 */ public function render_dashboard_page(): void { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You do not have sufficient permissions to access this page.', 'albert-ai-butler' ) ); } // Gather setup state. $has_allowed_users = ! empty( get_option( 'albert_allowed_users', [] ) ); $active_connections = $this->get_active_connections_count(); $has_connections = $active_connections > 0; $setup_complete = $has_allowed_users && $has_connections; $enabled_abilities = $this->get_enabled_abilities_count(); $mcp_endpoint = McpServer::get_endpoint_url(); ?>

get_recent_activity(); $premium_not_active = ! class_exists( 'AlbertPremium\\AlbertPremiumService' ); if ( ! empty( $recent_activity ) ) { ?>
render_status_dot( $activity['status'] ); ?>

get_var( $wpdb->prepare( 'SELECT COUNT(DISTINCT client_id) FROM %i WHERE revoked = 0', $tables['access_tokens'] ) ); return (int) $count; } /** * Get count of enabled abilities. * * @return string Enabled/Total abilities count. * @since 1.0.0 */ private function get_enabled_abilities_count(): string { $disabled_abilities = AbilitiesPage::get_disabled_abilities(); $all_abilities = wp_get_abilities(); $enabled_count = 0; $total_count = count( $all_abilities ); foreach ( $all_abilities as $ability ) { $name = $ability->get_name(); // Ability is enabled if NOT in the disabled list. if ( ! in_array( $name, $disabled_abilities, true ) ) { ++$enabled_count; } } return sprintf( '%d/%d', $enabled_count, $total_count ); } /** * Get recent activity from OAuth sessions and ability executions. * * Each row is structured by column so the renderer can lay it out as a * data table: a status (success / error / connection), the resolved event * label, the acting user, and a relative timestamp. Ability slugs are * resolved to human labels via {@see self::resolve_ability_label()}, which * reads from the in-memory abilities manager (avoiding wp_get_ability(), * unsafe in this render context) and falls back to a prettified slug. * * @return array Recent activity rows. * @since 1.0.0 */ private function get_recent_activity(): array { global $wpdb; $tables = Tables::oauth(); // Get the most recent distinct connections — one row per client, not per // token. A new access-token row is created on every silent refresh (about // hourly), so keying on the client and its registration time avoids // surfacing a "new connection" each time the session merely refreshes. // INNER JOIN ensures the client actually obtained a token, MAX(user_id) // recovers the authorizing user (clients register anonymously), and // UNIX_TIMESTAMP() yields a time-zone-safe epoch. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $connections = $wpdb->get_results( $wpdb->prepare( 'SELECT c.client_id, c.name, UNIX_TIMESTAMP( c.created_at ) AS created_ts, MAX( t.user_id ) AS user_id FROM %i c INNER JOIN %i t ON t.client_id = c.client_id GROUP BY c.client_id, c.name, c.created_at ORDER BY c.created_at DESC LIMIT %d', $tables['clients'], $tables['access_tokens'], 5 ) ); $events = []; foreach ( $connections as $row ) { $user = get_userdata( $row->user_id ); $client_name = $row->name ?? ''; // Action-first event label; append the client name when we have one. $event = __( 'New connection', 'albert-ai-butler' ); if ( $client_name !== '' ) { $event = sprintf( /* translators: %s: Client name */ __( 'New connection — %s', 'albert-ai-butler' ), $client_name ); } $events[] = [ 'status' => 'connection', 'timestamp' => (int) $row->created_ts, 'event' => $event, 'actor' => $user ? $user->display_name : __( 'System', 'albert-ai-butler' ), ]; } // Merge in recent ability executions. foreach ( $this->logging_repository->recent( 5 ) as $row ) { $user = get_userdata( (int) $row->user_id ); $is_error = isset( $row->status ) && $row->status === 'error'; $events[] = [ 'status' => $is_error ? 'error' : 'success', 'timestamp' => (int) $row->created_ts, 'event' => $this->resolve_ability_label( $row->ability_name ), 'actor' => $user ? $user->display_name : __( 'Unknown', 'albert-ai-butler' ), ]; } // Sort by timestamp DESC and keep the most recent 5. usort( $events, static function ( array $a, array $b ): int { return $b['timestamp'] <=> $a['timestamp']; } ); $events = array_slice( $events, 0, 5 ); $now = time(); $activity = []; foreach ( $events as $event ) { $activity[] = [ 'status' => $event['status'], 'event' => $event['event'], 'actor' => $event['actor'], 'time' => sprintf( /* translators: %s: Time difference */ __( '%s ago', 'albert-ai-butler' ), human_time_diff( $event['timestamp'], $now ) ), ]; } return $activity; } /** * Resolve a logged ability slug to its human-readable label. * * Prefers the in-memory abilities manager, whose label data is populated * during bootstrap and is therefore available even on this admin page — * where the WordPress Abilities API registry is not yet populated and * calling wp_get_ability() would emit PHP notices. Abilities the manager * does not hold (e.g. third-party abilities registered directly with * WordPress) fall back to {@see AbilitiesRegistry::label_for()}, which * itself prettifies the slug when the Abilities API is unavailable. * * @param string $slug Ability slug, e.g. `albert/find-posts`. * * @return string Human-readable label. * @since 1.2.0 */ private function resolve_ability_label( string $slug ): string { $manager = Plugin::get_instance()->get_abilities_manager(); if ( $manager !== null ) { $label = $manager->get_label( $slug ); if ( $label !== null && $label !== '' ) { return $label; } } return AbilitiesRegistry::label_for( $slug ); } /** * Render a status cell: a glowing dot paired with a visible word. * * The visible word is required — status is never conveyed by colour alone * (WCAG 2.2 AA, 1.4.1). * * @param string $status One of `success`, `error`, or `connection`. * * @return void * @since 1.2.0 */ private function render_status_dot( string $status ): void { switch ( $status ) { case 'error': $modifier = 'error'; $word = __( 'Failed', 'albert-ai-butler' ); break; case 'connection': $modifier = 'connection'; $word = __( 'Connection', 'albert-ai-butler' ); break; default: $modifier = 'success'; $word = __( 'Success', 'albert-ai-butler' ); break; } ?>