>|null */ private ?array $live_connections = null; /** * The screen id `add_menu_page()` gave this page, or '' before it ran. * * Taken from core rather than written out as `toplevel_page_albert`, which * is what the enqueue guard compared against. That literal is core's own * derivation from the menu slug, so restating it is a second copy of a rule * this class does not own: rename the slug and the assets stop loading, * silently, on a page that still renders. * * @since 1.4.0 * @var string */ private string $screen_id = ''; /** * Constructor. * * @param LoggingRepository $logging_repository Ability log repository. * * @since 1.1.0 */ public function __construct( LoggingRepository $logging_repository ) { $this->logging_repository = $logging_repository; } /** * Register WordPress hooks. * * @return void * @since 1.0.0 */ public function register_hooks(): void { add_action( 'wp_ajax_' . Attention::DISMISS_ACTION, [ $this, 'handle_dismiss_attention' ] ); add_action( 'admin_menu', [ $this, 'add_menu_pages' ], Menu::POSITION_DASHBOARD ); 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). $this->screen_id = (string) 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, identified by the screen id core // handed back from add_menu_page() rather than by a copy of it. if ( $this->screen_id === '' || $hook !== $this->screen_id ) { return; } wp_enqueue_style( 'albert-admin', ALBERT_PLUGIN_URL . 'assets/css/admin-settings.css', [ Assets::PRIMITIVES_HANDLE ], Assets::version( 'assets/css/admin-settings.css' ) ); wp_enqueue_script( 'albert-admin-utils', ALBERT_PLUGIN_URL . 'assets/js/albert-admin-utils.js', [], Assets::version( 'assets/js/albert-admin-utils.js' ), true ); // The same script Connections and Settings load, not a Dashboard-specific // one. `admin-dashboard.js` used to exist purely to wire a copy button, // against its own `.albert-copy-btn` class, duplicating the handler in // `admin-settings.js` that every other screen already used. One endpoint // field, one class, one handler. wp_enqueue_script( 'albert-admin', ALBERT_PLUGIN_URL . 'assets/js/admin-settings.js', [ 'albert-admin-utils' ], Assets::version( 'assets/js/admin-settings.js' ), true ); wp_localize_script( 'albert-admin', 'albertAdmin', [ 'ajaxUrl' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( 'albert_oauth_nonce' ), 'dismissNonce' => wp_create_nonce( Attention::DISMISS_ACTION ), 'i18n' => [ 'copied' => __( 'Copied!', 'albert-ai-butler' ), 'copyFailed' => __( 'Copy failed', 'albert-ai-butler' ), ], ] ); // The onboarding checklist's "Choose users" button opens the Connections // screen's picker, so the dashboard loads the same script and the same // localised data rather than a copy of either. UserPickerModal::enqueue( 'dashboard' ); ConnectDialog::enqueue(); } /** * Render dashboard page. * * Two states, not one layout with conditional cards (handoff §8.1): an * unfinished setup is a task list, a finished one is a status readout, and * they answer different questions. * * There are deliberately no buttons in the header. "Manage abilities" and * "View connections" are navigation, not actions; the submenu and the tab * row already reach both, and on a finished setup there is no single next * action to promote. The only filled button on the screen belongs to the * current onboarding step, where there genuinely is one thing to do next. * * @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' ) ); } $has_allowed_users = AllowedUsers::has_any(); $active_connections = count( $this->get_live_connections() ); $has_connections = $active_connections > 0; $setup_complete = $has_allowed_users && $has_connections; $abilities = $this->get_ability_counts(); $mcp_endpoint = McpServer::get_endpoint_url(); ?>


render_complete_state( $active_connections, $abilities, $mcp_endpoint ); } else { $this->render_onboarding_state( $has_allowed_users, $has_connections, $abilities, $mcp_endpoint ); } ?>

render_stat_row( $abilities, $active_connections ); ?> render_attention_card(); ?>
render_activity_card(); ?> render_suggestions_card(); ?>
render_endpoint_card( $mcp_endpoint ); ?> render_recommendation_card(); ?> render_resources_card(); ?>
get_my_latest_connection(); $done = ( $has_allowed_users ? 1 : 0 ) + ( $mine !== null ? 1 : 0 ); $total = 3; ?>

    render_setup_step( 1, $has_allowed_users, ! $has_allowed_users, __( 'Add an allowed user', 'albert-ai-butler' ), __( 'Only the users you pick here can authorise an AI assistant to act on this site.', 'albert-ai-butler' ), function (): void { ?> render_setup_step( 2, $mine !== null, $has_allowed_users && $mine === null, __( 'Connect an AI assistant', 'albert-ai-butler' ), '', function () use ( $mine ): void { if ( $mine !== null ) { ?>

    ago( (int) $mine['created_ts'] ) ) ); ?>

    render_setup_step( 3, false, false, __( 'Choose what Albert may do', 'albert-ai-butler' ), '', function () use ( $review ): void { ?>

render_suggestions_card( true ); ?> render_resources_card(); ?>
  • 0 ) { $abilities_meta = sprintf( '%2$s', esc_url( admin_url( 'admin.php?page=albert-abilities' ) ), esc_html( sprintf( /* translators: %d: number of abilities currently switched off. */ _n( 'Review the %d you switched off', 'Review the %d you switched off', $disabled, 'albert-ai-butler' ), $disabled ) ) ); } $stats = [ [ 'label' => __( 'Enabled abilities', 'albert-ai-butler' ), 'value' => sprintf( /* translators: 1: enabled ability count, 2: total ability count. */ __( '%1$d / %2$d', 'albert-ai-butler' ), $abilities['enabled'], $abilities['total'] ), 'meta' => $abilities_meta, ], [ 'label' => __( 'Connections', 'albert-ai-butler' ), 'value' => number_format_i18n( $active_connections ), 'meta' => esc_html( $this->get_connection_names() ), ], ]; $stats[] = $this->privacy_stat(); /** * Filters the tiles shown in the Dashboard's stat row. * * Each tile is `[ 'label' => string, 'value' => string, 'meta' => string ]` * plus an optional `indicator`. `label` and `value` are escaped as text; * `meta` may contain a link and is `wp_kses`'d down to ``; * `indicator` names a status dot (`strict`/`balanced`/`off`) rather than * passing markup. * * Free seeds the figures it can compute from data it retains; an add-on * with its own history (call volume, failure rate, duration) appends * tiles here rather than Free guessing at numbers it cannot verify. See * docs/features/70-admin-design-system.md §4. * * @since 1.4.0 * * @param array $stats Stat tiles. */ $stats = apply_filters( 'albert/dashboard/stats', $stats ); if ( ! is_array( $stats ) || empty( $stats ) ) { return; } ?>
    [ 'href' => [] ] ] ); ?>
    get_recent_activity(); // Three states, not two. Somebody who bought Premium and left it off // does not need a sales pitch, they need telling that the history they // paid for is being discarded while it sits there. $premium_state = AddonState::of( 'AlbertPremium\\AlbertPremiumService', 'albert-premium-service/albert-premium-service.php' ); $premium_installed = $premium_state === AddonState::INACTIVE; ?>

    render_status_dot( $activity['status'] ); ?>
    activity_truncated ) { ?>

    value => [ 'label' => __( 'Strict', 'albert-ai-butler' ), 'meta' => __( 'Personal details are removed', 'albert-ai-butler' ), 'tone' => 'strict', ], PrivacyMode::Balanced->value => [ 'label' => __( 'Balanced', 'albert-ai-butler' ), 'meta' => __( 'Emails and names are masked', 'albert-ai-butler' ), 'tone' => 'balanced', ], PrivacyMode::Off->value => [ 'label' => __( 'Off', 'albert-ai-butler' ), 'meta' => __( 'Assistants see personal details in full', 'albert-ai-butler' ), 'tone' => 'off', ], ]; $current = $copy[ $mode->value ] ?? $copy[ PrivacyMode::Balanced->value ]; return [ 'label' => __( 'Privacy', 'albert-ai-butler' ), 'value' => $current['label'], // A name, not markup. The renderer escapes `value` as text, so a // tile that wanted a coloured dot would otherwise have to smuggle // HTML past esc_html() and would simply print its own tags. 'indicator' => $current['tone'], 'meta' => esc_html( $current['meta'] ), ]; } /** * Dismiss one attention item for the current user. * * Capability first, then the nonce, then the allow-list: only an item that * currently exists and declares itself dismissible can be dismissed, so a * crafted id cannot silence a warning the owner is not allowed to silence. * That matters because the consequential items (a connection about to be * dropped) are exactly the ones somebody might want gone. * * @return void * @since 1.4.0 */ public function handle_dismiss_attention(): void { if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( [ 'message' => __( 'Insufficient permissions.', 'albert-ai-butler' ) ], 403 ); } check_ajax_referer( Attention::DISMISS_ACTION, 'nonce' ); $id = isset( $_POST['id'] ) ? sanitize_text_field( wp_unslash( $_POST['id'] ) ) : ''; if ( $id === '' ) { wp_send_json_error( [ 'message' => __( 'Nothing to dismiss.', 'albert-ai-butler' ) ], 400 ); } $attention = new Attention(); $allowed = false; foreach ( $attention->items() as $item ) { if ( (string) ( $item['id'] ?? '' ) === $id && Attention::is_dismissible( $item ) ) { $allowed = true; break; } } if ( ! $allowed ) { wp_send_json_error( [ 'message' => __( 'That item cannot be dismissed.', 'albert-ai-butler' ) ], 400 ); } Attention::dismiss( $id, get_current_user_id() ); wp_send_json_success(); } /** * Render "Needs your attention". * * Always rendered, never hidden when empty. A card that disappears when * there is nothing wrong is indistinguishable from one that is broken, and * the value of this card is that an owner can glance at it and trust the * absence of items. * * What qualifies as an item lives in {@see Attention}; this only draws what * it is handed. * * @return void * @since 1.4.0 */ private function render_attention_card(): void { $items = ( new Attention() )->items(); ?>

    $item Attention item. * * @return void * @since 1.4.0 */ private function render_attention_item( array $item ): void { $tone = isset( $item['tone'] ) && in_array( $item['tone'], [ 'danger', 'warning', 'info' ], true ) ? (string) $item['tone'] : 'info'; $action = isset( $item['action'] ) && is_array( $item['action'] ) ? $item['action'] : null; $dismissible = Attention::is_dismissible( $item ); ?>
  • render_data_attributes( $action['attributes'] ?? [] ); ?>>
  • value. * * @return void * @since 1.5.0 */ private function render_data_attributes( $attributes ): void { if ( ! is_array( $attributes ) ) { return; } foreach ( $attributes as $name => $value ) { if ( ! is_string( $name ) || ! preg_match( '/^data-[a-z0-9-]+$/', $name ) || ! is_scalar( $value ) ) { continue; } printf( ' %1$s="%2$s"', esc_attr( $name ), esc_attr( (string) $value ) ); } } /** * Say so when the endpoint address is not this site's own. * * `active` is information: an owner who did not set the filter should know * the address came from somewhere else. `invalid` is a fault, and the more * important of the two, because the filter was ignored and the address on * screen is silently not the one somebody configured. Nothing else in the * plugin surfaces that. * * @return void * @since 1.4.0 */ private function render_endpoint_override_notice(): void { $state = McpServer::get_external_url_state()['state']; if ( $state === 'inactive' ) { return; } $is_invalid = $state === 'invalid'; ?>

    , 2: closing wrapping a filter name */ esc_html__( 'An %1$salbert/mcp/external_url%2$s filter returned an address Albert could not use, so this is your site\'s own address instead.', 'albert-ai-butler' ), '', '' ); } else { printf( /* translators: 1: opening , 2: closing wrapping a filter name */ esc_html__( 'This address comes from the %1$salbert/mcp/external_url%2$s filter, not from your site address.', 'albert-ai-butler' ), '', '' ); } ?>

    all(); if ( $prompts === [] ) { return; } ?>

    current(); if ( $addons === [] ) { return; } foreach ( $addons as $addon ) { $this->render_one_recommendation( $addon ); } } /** * One add-on card. * * @param array $addon Recommendation, already state-tagged. * * @return void * @since 1.4.0 */ private function render_one_recommendation( array $addon ): void { ?>

    render_endpoint_override_notice(); ?>

    show_resources() ) { return; } ?>

    |null * @since 1.5.0 */ private function get_my_latest_connection(): ?array { $user_id = get_current_user_id(); $mine = array_filter( $this->get_live_connections(), static fn( array $connection ): bool => in_array( $user_id, (array) ( $connection['user_ids'] ?? [] ), true ) ); if ( $mine === [] ) { return null; } usort( $mine, static fn( array $a, array $b ): int => (int) $b['created_ts'] <=> (int) $a['created_ts'] ); $latest = $mine[0]; $latest['name'] = (string) ( $latest['label'] !== '' ? $latest['label'] : $latest['name'] ); return $latest; } /** * "2 minutes ago", or "just now" inside the first minute. * * @param int $timestamp A Unix timestamp. * * @return string * @since 1.5.0 */ private function ago( int $timestamp ): string { if ( time() - $timestamp < MINUTE_IN_SECONDS ) { return __( 'just now', 'albert-ai-butler' ); } /* translators: %s: a human-readable time difference, e.g. "2 minutes". */ return sprintf( __( '%s ago', 'albert-ai-butler' ), human_time_diff( $timestamp ) ); } /** * The connections this site actually has, by the one definition of the word. * * {@see ClientRepository::getLiveConnections()} is that definition, and it * is what the Connections screen lists, what both retention sweeps act on, * and what {@see Attention::connections_about_to_go()} counts down. This * screen used to ask its own question instead — `revoked = 0` on the access * tokens alone — and got a different answer in both directions. * * `revoked = 0` has no expiry check in it and never looks at a refresh * token, so this screen counted a client whose every token had expired: for * up to a day, until `Cron\TokenCleanup` removes the rows, and indefinitely * on a site where WP-Cron never runs. The screen then read "1 connection" * and offered the finished-setup state over an assistant that could not * call anything. * * Held for the request because the page asks three times: the setup state, * the stat tile's figure, and the names beneath it. * * @return array> Live connections, richest first. * @since 1.4.0 */ private function get_live_connections(): array { if ( $this->live_connections === null ) { $this->live_connections = ( new ClientRepository() )->getLiveConnections(); } return $this->live_connections; } /** * Enabled and total ability counts. * * Returns the two numbers rather than a preformatted "65/65" string: the * stat tile needs them apart to work out how many are switched off, and the * status line needs them apart to put them in a translatable sentence. * * @return array{enabled: int, total: int} * @since 1.4.0 */ private function get_ability_counts(): array { // Asked of the manager, not counted here. The manager snapshots both // figures inside enforce_disabled(), while the registry still holds // every ability; by the time this screen renders, the disabled ones // have been unregistered and counting the registry reports them as // enabled. That is what made the tile read "57 of 57" on a site with // 46 abilities switched off, and hid the "review the ones you switched // off" link, which only appears when the two numbers differ. $manager = Plugin::get_instance()->get_abilities_manager(); // Nullable until abilities are registered on `init`. This screen renders // long after that, so the guard is belt and braces. But a dashboard // that fatals rather than showing one fewer figure is the worse trade, // and PHPStan does not report nullable method calls below level 8. if ( $manager === null ) { return [ 'enabled' => 0, 'total' => 0, ]; } return $manager->get_ability_counts(); } /** * The names of the currently connected clients, for the stat tile's meta line. * * Names are self-reported by the connecting app, and the owner's own label * takes precedence where one exists, matching the Connections screen. Past * two, this says "and N more" rather than growing the tile. * * Read off {@see self::get_live_connections()} rather than queried again, * so the names and the figure above them can only ever describe the same * set. They were two separate queries, and a tile reading "2" over a list * of three names is worse than either number alone. * * @return string A short, already-plain list, or an empty string when nothing is connected. * @since 1.4.0 */ private function get_connection_names(): string { $names = []; foreach ( $this->get_live_connections() as $connection ) { $label = isset( $connection['label'] ) ? trim( (string) $connection['label'] ) : ''; $name = $label !== '' ? $label : trim( (string) ( $connection['name'] ?? '' ) ); if ( $name !== '' ) { $names[] = $name; } } $names = array_values( array_unique( $names ) ); sort( $names ); if ( $names === [] ) { return ''; } if ( count( $names ) <= 2 ) { return implode( ', ', $names ); } $shown = array_slice( $names, 0, 2 ); return sprintf( /* translators: 1: a comma-separated list of assistant names, 2: how many further assistants are connected. */ _n( '%1$s and %2$d more', '%1$s and %2$d more', count( $names ) - 2, 'albert-ai-butler' ), implode( ', ', $shown ), count( $names ) - 2 ); } /** * 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, 'id' => '', 'actor' => $user ? $user->display_name : __( 'System', 'albert-ai-butler' ), ]; } // Merge in recent ability executions. foreach ( $this->logging_repository->recent( 6 ) as $row ) { $user = get_userdata( (int) $row->user_id ); // Three outcomes, not two. A `warning` is passed through as itself // so the row renders amber: collapsing it into `error` would paint // the site's own permission rules red for doing their job, and // collapsing it into `success` would hide that the call never ran. $row_status = isset( $row->status ) ? (string) $row->status : ''; $status = in_array( $row_status, Outcome::STATUSES, true ) ? $row_status : Outcome::SUCCESS; $events[] = [ 'status' => $status, 'timestamp' => (int) $row->created_ts, 'event' => $this->resolve_ability_label( $row->ability_name ), 'id' => (string) $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']; } ); // Whether anything was left over. The fade below is drawn only when this // is true, so it always means "there is more" rather than decorating the // bottom of a complete list, which is what it did before: it sat over // the last real row and implied rows that did not exist. $this->activity_truncated = count( $events ) > self::ACTIVITY_ROWS; // One row past the five, when there is one. The fade is 56px of opaque // gradient pulled up over the bottom of the table, so whichever row // ends up underneath it cannot be read — at the gradient's midpoint the // text is down to about 2:1. Giving it a sixth row to cover means the // five that are meant to be read stay legible, and the fade stops being // a thing that hides information and becomes what it claims to be: the // next entry, half-visible, and Premium is how you see the rest. $events = array_slice( $events, 0, $this->activity_truncated ? self::ACTIVITY_ROWS + 1 : self::ACTIVITY_ROWS ); $now = time(); $activity = []; foreach ( $events as $event ) { $activity[] = [ 'status' => $event['status'], 'event' => $event['event'], 'id' => $event['id'], '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 { return AbilitiesRegistry::resolve_label( $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`, `warning`, `error`, or `connection`. * * @return void * @since 1.2.0 * @since 1.4.0 Renders `warning` as an amber "Blocked". */ private function render_status_dot( string $status ): void { switch ( $status ) { case 'error': $modifier = 'error'; $word = __( 'Failed', 'albert-ai-butler' ); break; case Outcome::WARNING: // Amber, and the word says what happened rather than how bad it // is. The site refused on purpose; that belongs between the // quiet success above it and the loud failure below, not // dressed up as either. $modifier = 'warning'; $word = __( 'Blocked', 'albert-ai-butler' ); break; case 'connection': $modifier = 'connection'; $word = __( 'Connection', 'albert-ai-butler' ); break; default: $modifier = 'success'; $word = __( 'Success', 'albert-ai-butler' ); break; } ?>