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;
}
?>