From a43ffdf8f48cdd14b14e8f7290953957e727f537 Mon Sep 17 00:00:00 2001
From: James LePage <36246732+Jameswlepage@users.noreply.github.com>
Date: Thu, 18 Dec 2025 16:02:21 -0500
Subject: [PATCH 1/8] Add AI Request Logging experiment
Adds a comprehensive logging system for AI requests that provides:
- Observability: Track all AI operations with request/response previews
- Cost tracking: Automatic cost estimation based on provider/model pricing
- Admin UI: Full-featured log viewer with filtering, search, and stats
- REST API: Endpoints for querying logs programmatically
Architecture:
- AI_Request_Log_Manager: Facade coordinating all logging operations
- AI_Request_Log_Schema: Database table creation and management
- AI_Request_Log_Repository: CRUD with caching and cursor pagination
- AI_Request_Cost_Calculator: Provider pricing and cost estimation
- Logging_HTTP_Client: Decorator for automatic request capture
Features:
- Configurable retention period and max rows
- Daily cleanup cron job
- FULLTEXT search with LIKE fallback
- Filter by type, provider, status, operation, date range
- Summary statistics by period
---
docs/experiments/ai-request-logging.md | 33 +
includes/Experiment_Loader.php | 1 +
.../AI_Request_Logging/AI_Request_Logging.php | 153 ++
.../Logging/AI_Request_Cost_Calculator.php | 189 ++
includes/Logging/AI_Request_Log_Manager.php | 412 ++++
includes/Logging/AI_Request_Log_Page.php | 131 ++
.../Logging/AI_Request_Log_Repository.php | 949 +++++++++
includes/Logging/AI_Request_Log_Schema.php | 278 +++
.../Logging/Logging_Discovery_Strategy.php | 92 +
includes/Logging/Logging_HTTP_Client.php | 645 ++++++
.../REST/AI_Request_Log_Controller.php | 460 ++++
includes/helpers.php | 17 +
src/admin/_common.scss | 150 ++
src/admin/_dataviews.scss | 26 +
src/admin/ai-request-logs/_dataviews-base.css | 1847 +++++++++++++++++
.../components/HeaderPeriodSelector.tsx | 53 +
.../components/LogDetailModal.tsx | 352 ++++
.../ai-request-logs/components/LogsTable.tsx | 561 +++++
.../components/SettingsPanel.tsx | 129 ++
.../components/SummaryCards.tsx | 108 +
src/admin/ai-request-logs/index.tsx | 419 ++++
src/admin/ai-request-logs/style.scss | 469 +++++
src/admin/ai-request-logs/types.ts | 85 +
.../components/ProviderTooltipContent.tsx | 81 +
src/admin/components/icons/AiIcon.tsx | 26 +
src/admin/components/icons/AnthropicIcon.tsx | 22 +
src/admin/components/icons/CloudflareIcon.tsx | 29 +
src/admin/components/icons/DeepSeekIcon.tsx | 22 +
src/admin/components/icons/FalIcon.tsx | 26 +
src/admin/components/icons/GoogleIcon.tsx | 22 +
src/admin/components/icons/GrokIcon.tsx | 22 +
src/admin/components/icons/GroqIcon.tsx | 22 +
.../components/icons/HuggingFaceIcon.tsx | 45 +
src/admin/components/icons/McpIcon.tsx | 25 +
src/admin/components/icons/OllamaIcon.tsx | 25 +
src/admin/components/icons/OpenAiIcon.tsx | 22 +
src/admin/components/icons/OpenRouterIcon.tsx | 25 +
src/admin/components/icons/XaiIcon.tsx | 22 +
src/admin/components/icons/index.ts | 14 +
src/admin/components/provider-icons.tsx | 56 +
src/admin/hooks/usePersistedView.ts | 99 +
.../Logging/AI_Request_Log_ManagerTest.php | 148 ++
webpack.config.js | 5 +
43 files changed, 8317 insertions(+)
create mode 100644 docs/experiments/ai-request-logging.md
create mode 100644 includes/Experiments/AI_Request_Logging/AI_Request_Logging.php
create mode 100644 includes/Logging/AI_Request_Cost_Calculator.php
create mode 100644 includes/Logging/AI_Request_Log_Manager.php
create mode 100644 includes/Logging/AI_Request_Log_Page.php
create mode 100644 includes/Logging/AI_Request_Log_Repository.php
create mode 100644 includes/Logging/AI_Request_Log_Schema.php
create mode 100644 includes/Logging/Logging_Discovery_Strategy.php
create mode 100644 includes/Logging/Logging_HTTP_Client.php
create mode 100644 includes/Logging/REST/AI_Request_Log_Controller.php
create mode 100644 src/admin/_common.scss
create mode 100644 src/admin/_dataviews.scss
create mode 100644 src/admin/ai-request-logs/_dataviews-base.css
create mode 100644 src/admin/ai-request-logs/components/HeaderPeriodSelector.tsx
create mode 100644 src/admin/ai-request-logs/components/LogDetailModal.tsx
create mode 100644 src/admin/ai-request-logs/components/LogsTable.tsx
create mode 100644 src/admin/ai-request-logs/components/SettingsPanel.tsx
create mode 100644 src/admin/ai-request-logs/components/SummaryCards.tsx
create mode 100644 src/admin/ai-request-logs/index.tsx
create mode 100644 src/admin/ai-request-logs/style.scss
create mode 100644 src/admin/ai-request-logs/types.ts
create mode 100644 src/admin/components/ProviderTooltipContent.tsx
create mode 100644 src/admin/components/icons/AiIcon.tsx
create mode 100644 src/admin/components/icons/AnthropicIcon.tsx
create mode 100644 src/admin/components/icons/CloudflareIcon.tsx
create mode 100644 src/admin/components/icons/DeepSeekIcon.tsx
create mode 100644 src/admin/components/icons/FalIcon.tsx
create mode 100644 src/admin/components/icons/GoogleIcon.tsx
create mode 100644 src/admin/components/icons/GrokIcon.tsx
create mode 100644 src/admin/components/icons/GroqIcon.tsx
create mode 100644 src/admin/components/icons/HuggingFaceIcon.tsx
create mode 100644 src/admin/components/icons/McpIcon.tsx
create mode 100644 src/admin/components/icons/OllamaIcon.tsx
create mode 100644 src/admin/components/icons/OpenAiIcon.tsx
create mode 100644 src/admin/components/icons/OpenRouterIcon.tsx
create mode 100644 src/admin/components/icons/XaiIcon.tsx
create mode 100644 src/admin/components/icons/index.ts
create mode 100644 src/admin/components/provider-icons.tsx
create mode 100644 src/admin/hooks/usePersistedView.ts
create mode 100644 tests/Integration/Includes/Logging/AI_Request_Log_ManagerTest.php
diff --git a/docs/experiments/ai-request-logging.md b/docs/experiments/ai-request-logging.md
new file mode 100644
index 00000000..4f653d31
--- /dev/null
+++ b/docs/experiments/ai-request-logging.md
@@ -0,0 +1,33 @@
+# AI Request Logging
+
+## Summary
+Provides an opt-in observability surface that records every AI request (provider, model, duration, token counts, status, cost estimate) and exposes them through a React-powered dashboard under `Settings → AI Request Logs`. When enabled, outbound HTTP calls made via the WP AI Client are wrapped with a logging HTTP client.
+
+## Key Hooks & Entry Points
+- `WordPress\AI\Experiments\AI_Request_Logging\AI_Request_Logging::register()`:
+ - `rest_api_init` → registers `WordPress\AI\Logging\REST\AI_Request_Log_Controller`, which exposes `/ai/v1/logs`, `/summary`, `/filters`, and per-log endpoints.
+ - `is_admin()` guard → instantiates `WordPress\AI\Logging\AI_Request_Log_Page` to add the Settings submenu and enqueue assets.
+- Plugin bootstrap now only initializes `WordPress\AI\Logging\Logging_Discovery_Strategy` (and `AI_Request_Log_Manager::init()`) when the experiment toggle is enabled, ensuring the HTTP wrapper and daily cleanup job stay disabled otherwise.
+- Database + cleanup are handled inside `AI_Request_Log_Manager::init()` (table creation, cron scheduling, option storage).
+
+## Assets & Data Flow
+1. When `AI Request Logs` is visited, `Asset_Loader` enqueues `admin/ai-request-logs` (`src/admin/ai-request-logs/index.tsx`) plus its stylesheet. The localized payload (`window.AiRequestLogsSettings`) includes REST routes, a nonce, and initial state (enabled flag, retention days, summary, filters).
+2. The React app:
+ - Configures `@wordpress/api-fetch` with the nonce/root.
+ - Fetches logs (`GET /ai/v1/logs` with search/filter params) and displays them in a table with pagination.
+ - Fetches summaries (`GET /ai/v1/logs/summary`) for the KPI cards and filter metadata (`GET /ai/v1/logs/filters`).
+ - Posts to `/ai/v1/logs` to toggle logging and retention, and sends `DELETE /ai/v1/logs` to purge the table.
+3. On the backend, every AI HTTP request flows through `Logging_HTTP_Client`, which records metrics via `AI_Request_Log_Manager::log()` before returning the response to callers. Logs are stored in the `wp_ai_request_logs` table alongside JSON context for later inspection.
+
+## Testing
+1. Enable Experiments globally, toggle **AI Request Logging**, and ensure valid AI credentials exist (the experiment won’t enable otherwise).
+2. Trigger an AI-powered feature (e.g., Type Ahead or Title Generation) so the system issues at least one completion request.
+3. Navigate to `Settings → AI Request Logs`. Confirm the chart and table populate and that the “Logging enabled” toggle reflects the settings page switch.
+4. Change the retention days value, save, and verify the option persists (reload the page or inspect `ai_request_logs_retention_days`).
+5. Click “Purge logs”, confirm the success notice, and check the table empties.
+6. Disable the experiment and reload a front-end AI feature; no new rows should appear, and the `Logging_Discovery_Strategy` hooks should remain inactive.
+
+## Notes
+- The HTTP logging layer only boots when both the global experiment switch and the `ai-request-logging` toggle are on, preventing unnecessary DB tables or cron events on installs that don’t need observability.
+- REST endpoints require `manage_options`.
+- Model cost estimates rely on the static pricing table inside `AI_Request_Log_Manager`; update it as provider pricing evolves.
diff --git a/includes/Experiment_Loader.php b/includes/Experiment_Loader.php
index c7223b27..3aafa2f5 100644
--- a/includes/Experiment_Loader.php
+++ b/includes/Experiment_Loader.php
@@ -107,6 +107,7 @@ private function get_default_experiments(): array {
\WordPress\AI\Experiments\Image_Generation\Image_Generation::class,
\WordPress\AI\Experiments\Title_Generation\Title_Generation::class,
\WordPress\AI\Experiments\Excerpt_Generation\Excerpt_Generation::class,
+ \WordPress\AI\Experiments\AI_Request_Logging\AI_Request_Logging::class,
);
/**
diff --git a/includes/Experiments/AI_Request_Logging/AI_Request_Logging.php b/includes/Experiments/AI_Request_Logging/AI_Request_Logging.php
new file mode 100644
index 00000000..881811ca
--- /dev/null
+++ b/includes/Experiments/AI_Request_Logging/AI_Request_Logging.php
@@ -0,0 +1,153 @@
+ 'ai-request-logging',
+ 'label' => esc_html__( 'AI Request Logging', 'ai' ),
+ 'description' => esc_html__( 'Log AI requests for observability, debugging, and cost tracking. View logs under Settings → AI Request Logs.', 'ai' ),
+ );
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function register(): void {
+ $manager = $this->get_manager();
+
+ add_action(
+ 'rest_api_init',
+ static function () use ( $manager ) {
+ $controller = new AI_Request_Log_Controller( $manager );
+ $controller->register_routes();
+ }
+ );
+
+ if ( ! is_admin() ) {
+ return;
+ }
+
+ $page = new AI_Request_Log_Page( $manager );
+ $page->init();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function register_settings(): void {
+ register_setting(
+ Settings_Registration::OPTION_GROUP,
+ AI_Request_Log_Manager::OPTION_RETENTION_DAYS,
+ array(
+ 'type' => 'integer',
+ 'default' => AI_Request_Log_Manager::DEFAULT_RETENTION_DAYS,
+ 'sanitize_callback' => array( $this, 'sanitize_retention_days' ),
+ )
+ );
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function render_settings_fields(): void {
+ $retention_days = $this->get_manager()->get_retention_days();
+ ?>
+
+ manager ) {
+ $this->manager = get_request_log_manager() ?? new AI_Request_Log_Manager();
+ }
+
+ return $this->manager;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function get_entry_points(): array {
+ return array(
+ array(
+ 'label' => esc_html__( 'Dashboard', 'ai' ),
+ 'url' => admin_url( 'options-general.php?page=ai-request-logs' ),
+ 'type' => 'dashboard',
+ ),
+ );
+ }
+}
diff --git a/includes/Logging/AI_Request_Cost_Calculator.php b/includes/Logging/AI_Request_Cost_Calculator.php
new file mode 100644
index 00000000..3584d67d
--- /dev/null
+++ b/includes/Logging/AI_Request_Cost_Calculator.php
@@ -0,0 +1,189 @@
+>
+ */
+ private static array $model_costs = array(
+ 'openai' => array(
+ 'gpt-5.1' => array(
+ 'input' => 0.00125,
+ 'output' => 0.01,
+ ),
+ 'gpt-5-mini' => array(
+ 'input' => 0.00025,
+ 'output' => 0.002,
+ ),
+ 'gpt-5-nano' => array(
+ 'input' => 0.00005,
+ 'output' => 0.0004,
+ ),
+ 'gpt-5-pro' => array(
+ 'input' => 0.015,
+ 'output' => 0.12,
+ ),
+ 'gpt-4' => array(
+ 'input' => 0.03,
+ 'output' => 0.06,
+ ),
+ 'gpt-4-turbo' => array(
+ 'input' => 0.01,
+ 'output' => 0.03,
+ ),
+ 'gpt-4o' => array(
+ 'input' => 0.005,
+ 'output' => 0.015,
+ ),
+ 'gpt-4o-mini' => array(
+ 'input' => 0.00015,
+ 'output' => 0.0006,
+ ),
+ 'gpt-3.5-turbo' => array(
+ 'input' => 0.0005,
+ 'output' => 0.0015,
+ ),
+ ),
+ 'anthropic' => array(
+ 'claude-4.5-opus' => array(
+ 'input' => 0.005,
+ 'output' => 0.025,
+ ),
+ 'claude-4.5-sonnet' => array(
+ 'input' => 0.003,
+ 'output' => 0.015,
+ ),
+ 'claude-4.5-haiku' => array(
+ 'input' => 0.001,
+ 'output' => 0.005,
+ ),
+ 'claude-haiku-4-5' => array(
+ 'input' => 0.001,
+ 'output' => 0.005,
+ ),
+ 'claude-3-opus' => array(
+ 'input' => 0.015,
+ 'output' => 0.075,
+ ),
+ 'claude-3-5-sonnet' => array(
+ 'input' => 0.003,
+ 'output' => 0.015,
+ ),
+ 'claude-3-sonnet' => array(
+ 'input' => 0.003,
+ 'output' => 0.015,
+ ),
+ 'claude-3-haiku' => array(
+ 'input' => 0.00025,
+ 'output' => 0.00125,
+ ),
+ ),
+ 'google' => array(
+ 'gemini-3-pro-preview' => array(
+ 'input' => 0.002,
+ 'output' => 0.012,
+ ),
+ 'gemini-3-pro-preview-high-context' => array(
+ 'input' => 0.004,
+ 'output' => 0.018,
+ ),
+ 'gemini-2.5-pro' => array(
+ 'input' => 0.00125,
+ 'output' => 0.01,
+ ),
+ 'gemini-2.5-pro-high-context' => array(
+ 'input' => 0.0025,
+ 'output' => 0.015,
+ ),
+ 'gemini-2.5-flash' => array(
+ 'input' => 0.0003,
+ 'output' => 0.0025,
+ ),
+ 'gemini-2.5-flash-lite' => array(
+ 'input' => 0.0001,
+ 'output' => 0.0004,
+ ),
+ 'gemini-1.5-pro' => array(
+ 'input' => 0.00125,
+ 'output' => 0.005,
+ ),
+ 'gemini-1.5-flash' => array(
+ 'input' => 0.000075,
+ 'output' => 0.0003,
+ ),
+ ),
+ );
+
+ /**
+ * Estimates the cost of an AI request based on token usage.
+ *
+ * @since x.x.x
+ *
+ * @param string $provider The AI provider identifier.
+ * @param string $model The model identifier.
+ * @param int $tokens_input Number of input tokens.
+ * @param int $tokens_output Number of output tokens.
+ * @return float|null Estimated cost in USD, or null if pricing is unknown.
+ */
+ public function estimate( string $provider, string $model, int $tokens_input, int $tokens_output ): ?float {
+ $costs = $this->get_model_costs();
+
+ $provider_lower = strtolower( $provider );
+ $model_lower = strtolower( $model );
+
+ // Try exact match first.
+ if ( isset( $costs[ $provider_lower ][ $model_lower ] ) ) {
+ $pricing = $costs[ $provider_lower ][ $model_lower ];
+ return ( $tokens_input / 1000 * $pricing['input'] ) +
+ ( $tokens_output / 1000 * $pricing['output'] );
+ }
+
+ // Try prefix match for model variants (e.g., gpt-4-turbo-preview -> gpt-4-turbo).
+ if ( isset( $costs[ $provider_lower ] ) ) {
+ foreach ( $costs[ $provider_lower ] as $model_key => $pricing ) {
+ if ( str_starts_with( $model_lower, $model_key ) ) {
+ return ( $tokens_input / 1000 * $pricing['input'] ) +
+ ( $tokens_output / 1000 * $pricing['output'] );
+ }
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Returns the model costs registry, allowing for filtering.
+ *
+ * @since x.x.x
+ *
+ * @return array> Model pricing data.
+ */
+ public function get_model_costs(): array {
+ /**
+ * Filters the model cost registry.
+ *
+ * Allows plugins to add or modify pricing data for AI models.
+ *
+ * @since x.x.x
+ *
+ * @param array> $model_costs Model pricing data.
+ */
+ return apply_filters( 'ai_model_costs', self::$model_costs );
+ }
+}
diff --git a/includes/Logging/AI_Request_Log_Manager.php b/includes/Logging/AI_Request_Log_Manager.php
new file mode 100644
index 00000000..ab3e9fde
--- /dev/null
+++ b/includes/Logging/AI_Request_Log_Manager.php
@@ -0,0 +1,412 @@
+schema = $schema ?? new AI_Request_Log_Schema();
+ $this->cost_calculator = $cost_calculator ?? new AI_Request_Cost_Calculator();
+ $this->repository = $repository ?? new AI_Request_Log_Repository( $this->schema, $this->cost_calculator );
+ }
+
+ /**
+ * Initializes the log manager.
+ *
+ * @since 0.1.0
+ */
+ public function init(): void {
+ add_action( 'admin_init', array( $this->schema, 'maybe_create_table' ) );
+ add_action( 'ai_request_logs_cleanup', array( $this, 'cleanup_old_logs' ) );
+
+ $this->schema->maybe_create_table();
+
+ if ( wp_next_scheduled( 'ai_request_logs_cleanup' ) ) {
+ return;
+ }
+
+ wp_schedule_event( time(), 'daily', 'ai_request_logs_cleanup' );
+ }
+
+ /**
+ * Whether logging is currently enabled.
+ *
+ * @since 0.1.0
+ *
+ * @return bool True if logging is enabled.
+ */
+ public function is_logging_enabled(): bool {
+ return (bool) get_option( self::OPTION_LOGGING_ENABLED, true );
+ }
+
+ /**
+ * Enables or disables logging.
+ *
+ * @since 0.1.0
+ *
+ * @param bool $enabled Whether to enable logging.
+ */
+ public function set_logging_enabled( bool $enabled ): void {
+ $current_value = get_option( self::OPTION_LOGGING_ENABLED, null );
+
+ // get_option() returns false when the option is missing, so explicitly check for null.
+ if ( null === $current_value ) {
+ add_option( self::OPTION_LOGGING_ENABLED, $enabled, '', false );
+ return;
+ }
+
+ update_option( self::OPTION_LOGGING_ENABLED, $enabled, false );
+ }
+
+ /**
+ * Gets the retention period in days.
+ *
+ * @since 0.1.0
+ *
+ * @return int Number of days to retain logs.
+ */
+ public function get_retention_days(): int {
+ return (int) get_option( self::OPTION_RETENTION_DAYS, self::DEFAULT_RETENTION_DAYS );
+ }
+
+ /**
+ * Sets the retention period in days.
+ *
+ * @since 0.1.0
+ *
+ * @param int $days Number of days to retain logs.
+ */
+ public function set_retention_days( int $days ): void {
+ update_option( self::OPTION_RETENTION_DAYS, max( 1, $days ), false );
+ }
+
+ /**
+ * Gets the maximum number of rows to retain.
+ *
+ * @since 0.1.0
+ *
+ * @return int Maximum rows.
+ */
+ public function get_max_rows(): int {
+ return (int) get_option( self::OPTION_MAX_ROWS, self::DEFAULT_MAX_ROWS );
+ }
+
+ /**
+ * Sets the maximum number of rows to retain.
+ *
+ * @since 0.1.0
+ *
+ * @param int $max_rows Maximum rows.
+ */
+ public function set_max_rows( int $max_rows ): void {
+ update_option( self::OPTION_MAX_ROWS, max( 1000, $max_rows ), false );
+ }
+
+ /**
+ * Starts a timer for measuring request duration.
+ *
+ * @since 0.1.0
+ *
+ * @return array{start: int, memory_start: int} Timer data.
+ */
+ public function start_timer(): array {
+ return array(
+ 'start' => hrtime( true ),
+ 'memory_start' => memory_get_usage(),
+ );
+ }
+
+ /**
+ * Ends a timer and returns duration in milliseconds.
+ *
+ * @since 0.1.0
+ *
+ * @param array{start: int, memory_start: int} $timer Timer data from start_timer().
+ * @return int Duration in milliseconds.
+ */
+ public function end_timer( array $timer ): int {
+ $end = hrtime( true );
+ return (int) ( ( $end - $timer['start'] ) / 1e6 );
+ }
+
+ /**
+ * Logs an AI request.
+ *
+ * @since 0.1.0
+ *
+ * @param array{
+ * type: string,
+ * operation: string,
+ * provider?: string,
+ * model?: string,
+ * duration_ms?: int,
+ * tokens_input?: int,
+ * tokens_output?: int,
+ * status: string,
+ * error_message?: string,
+ * user_id?: int,
+ * context?: array
+ * } $data Log data.
+ * @return string|false The log ID on success, false on failure.
+ */
+ public function log( array $data ) {
+ if ( ! $this->is_logging_enabled() ) {
+ return false;
+ }
+
+ return $this->repository->insert( $data );
+ }
+
+ /**
+ * Retrieves a single log entry by ID.
+ *
+ * @since 0.1.0
+ *
+ * @param string $log_id The log identifier.
+ * @return array|null The log entry or null if not found.
+ */
+ public function get_log( string $log_id ): ?array {
+ return $this->repository->find( $log_id );
+ }
+
+ /**
+ * Retrieves logs with filtering and pagination.
+ *
+ * @since 0.1.0
+ *
+ * @param array $args Query arguments.
+ * @return array{items: list>, total: int, pages: int, next_cursor?: array{id: int, timestamp: string}} Results.
+ */
+ public function get_logs( array $args = array() ): array {
+ return $this->repository->query( $args );
+ }
+
+ /**
+ * Gets aggregate statistics for the dashboard.
+ *
+ * @since 0.1.0
+ *
+ * @param string $period Time period: 'day', 'week', 'month', or 'all'.
+ * @param bool $force_refresh Whether to bypass the cache.
+ * @return array Aggregated statistics.
+ */
+ public function get_summary( string $period = 'day', bool $force_refresh = false ): array {
+ return $this->repository->get_summary( $period, $force_refresh );
+ }
+
+ /**
+ * Gets distinct values for filter dropdowns.
+ *
+ * @since 0.1.0
+ *
+ * @param bool $force_refresh Whether to bypass the cache.
+ * @return array{types: list, providers: list, statuses: list, operations: list} Filter options.
+ */
+ public function get_filter_options( bool $force_refresh = false ): array {
+ return $this->repository->get_filter_options( $force_refresh );
+ }
+
+ /**
+ * Deletes logs older than the retention period.
+ *
+ * @since 0.1.0
+ *
+ * @return int Number of logs deleted.
+ */
+ public function cleanup_old_logs(): int {
+ $total_deleted = $this->repository->cleanup_by_retention( $this->get_retention_days() );
+ $total_deleted += $this->repository->cleanup_by_max_rows( $this->get_max_rows() );
+
+ if ( $total_deleted > 0 ) {
+ $this->repository->invalidate_caches();
+ }
+
+ return $total_deleted;
+ }
+
+ /**
+ * Purges all logs from the database.
+ *
+ * @since 0.1.0
+ *
+ * @return int Number of logs deleted.
+ */
+ public function purge_all_logs(): int {
+ return $this->repository->purge_all();
+ }
+
+ /**
+ * Gets table statistics for admin display.
+ *
+ * @since 0.1.0
+ *
+ * @return array{row_count: int, table_size_bytes: int, table_size_formatted: string, max_rows: int, retention_days: int} Table stats.
+ */
+ public function get_table_stats(): array {
+ $stats = $this->schema->get_table_stats();
+
+ return array_merge(
+ $stats,
+ array(
+ 'max_rows' => $this->get_max_rows(),
+ 'retention_days' => $this->get_retention_days(),
+ )
+ );
+ }
+
+ /**
+ * Estimates the cost of an AI request based on token usage.
+ *
+ * @since 0.1.0
+ *
+ * @param string $provider The AI provider.
+ * @param string $model The model identifier.
+ * @param int $tokens_input Number of input tokens.
+ * @param int $tokens_output Number of output tokens.
+ * @return float|null Estimated cost in USD, or null if unknown.
+ */
+ public function estimate_cost( string $provider, string $model, int $tokens_input, int $tokens_output ): ?float {
+ return $this->cost_calculator->estimate( $provider, $model, $tokens_input, $tokens_output );
+ }
+
+ /**
+ * Invalidates the filter options cache.
+ *
+ * @since 0.1.0
+ */
+ public function invalidate_filter_cache(): void {
+ $this->repository->invalidate_filter_cache();
+ }
+
+ /**
+ * Invalidates the summary cache.
+ *
+ * @since 0.1.0
+ */
+ public function invalidate_summary_cache(): void {
+ $this->repository->invalidate_summary_cache();
+ }
+
+ /**
+ * Invalidates all caches.
+ *
+ * @since 0.1.0
+ */
+ public function invalidate_caches(): void {
+ $this->repository->invalidate_caches();
+ }
+
+ /**
+ * Returns the schema manager for direct access if needed.
+ *
+ * @since 0.1.0
+ *
+ * @return \WordPress\AI\Logging\AI_Request_Log_Schema The schema manager.
+ */
+ public function get_schema(): AI_Request_Log_Schema {
+ return $this->schema;
+ }
+
+ /**
+ * Returns the repository for direct access if needed.
+ *
+ * @since 0.1.0
+ *
+ * @return \WordPress\AI\Logging\AI_Request_Log_Repository The repository.
+ */
+ public function get_repository(): AI_Request_Log_Repository {
+ return $this->repository;
+ }
+
+ /**
+ * Returns the cost calculator for direct access if needed.
+ *
+ * @since 0.1.0
+ *
+ * @return \WordPress\AI\Logging\AI_Request_Cost_Calculator The cost calculator.
+ */
+ public function get_cost_calculator(): AI_Request_Cost_Calculator {
+ return $this->cost_calculator;
+ }
+}
diff --git a/includes/Logging/AI_Request_Log_Page.php b/includes/Logging/AI_Request_Log_Page.php
new file mode 100644
index 00000000..93e17fdc
--- /dev/null
+++ b/includes/Logging/AI_Request_Log_Page.php
@@ -0,0 +1,131 @@
+manager = $manager;
+ }
+
+ /**
+ * Bootstraps hooks.
+ */
+ public function init(): void {
+ add_action( 'admin_menu', array( $this, 'register_menu' ) );
+ }
+
+ /**
+ * Registers the options page under Settings.
+ */
+ public function register_menu(): void {
+ $page_hook = add_options_page(
+ __( 'AI Request Logs', 'ai' ),
+ __( 'AI Request Logs', 'ai' ),
+ 'manage_options',
+ self::PAGE_SLUG,
+ array( $this, 'render_page' )
+ );
+
+ if ( ! $page_hook ) {
+ return;
+ }
+
+ add_action( "load-{$page_hook}", array( $this, 'on_load' ) );
+ }
+
+ /**
+ * Ensures assets are loaded when the page is visited.
+ */
+ public function on_load(): void {
+ add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) );
+ }
+
+ /**
+ * Enqueues the React bundle and passes localized data.
+ */
+ public function enqueue_assets(): void {
+ Asset_Loader::enqueue_script( 'ai_request_logs', 'admin/ai-request-logs' );
+ Asset_Loader::enqueue_style( 'ai_request_logs', 'admin/style-ai-request-logs' );
+
+ Asset_Loader::localize_script(
+ 'ai_request_logs',
+ 'AiRequestLogsSettings',
+ array(
+ 'rest' => array(
+ 'nonce' => wp_create_nonce( 'wp_rest' ),
+ 'root' => esc_url_raw( rest_url() ),
+ 'routes' => array(
+ 'logs' => 'ai/v1/logs',
+ 'summary' => 'ai/v1/logs/summary',
+ 'filters' => 'ai/v1/logs/filters',
+ ),
+ ),
+ 'initialState' => array(
+ 'enabled' => $this->manager->is_logging_enabled(),
+ 'retentionDays' => $this->manager->get_retention_days(),
+ 'summary' => $this->manager->get_summary( 'day' ),
+ 'filters' => $this->manager->get_filter_options(),
+ ),
+ 'providerMetadata' => Provider_Metadata_Registry::get_metadata(),
+ )
+ );
+ }
+
+ /**
+ * Outputs the root DOM node for the React app.
+ */
+ public function render_page(): void {
+ if ( ! current_user_can( 'manage_options' ) ) {
+ return;
+ }
+ ?>
+
+ schema = $schema;
+ $this->cost_calculator = $cost_calculator;
+ }
+
+ /**
+ * Logs an AI request.
+ *
+ * @since x.x.x
+ *
+ * @param array{
+ * type: string,
+ * operation: string,
+ * provider?: string,
+ * model?: string,
+ * duration_ms?: int,
+ * tokens_input?: int,
+ * tokens_output?: int,
+ * status: string,
+ * error_message?: string,
+ * user_id?: int,
+ * context?: array
+ * } $data Log data.
+ * @return string|false The log ID on success, false on failure.
+ */
+ public function insert( array $data ) {
+ global $wpdb;
+
+ $log_id = wp_generate_uuid4();
+ $tokens_total = ( $data['tokens_input'] ?? 0 ) + ( $data['tokens_output'] ?? 0 );
+
+ $cost_estimate = $this->cost_calculator->estimate(
+ $data['provider'] ?? '',
+ $data['model'] ?? '',
+ $data['tokens_input'] ?? 0,
+ $data['tokens_output'] ?? 0
+ );
+
+ $context = $data['context'] ?? array();
+ $request_preview = $context['input_preview'] ?? null;
+ $response_preview = $context['output_preview'] ?? null;
+
+ $insert_data = array(
+ 'log_id' => $log_id,
+ 'timestamp' => current_time( 'mysql', true ),
+ 'type' => $data['type'],
+ 'operation' => $data['operation'],
+ 'provider' => $data['provider'] ?? null,
+ 'model' => $data['model'] ?? null,
+ 'duration_ms' => $data['duration_ms'] ?? null,
+ 'tokens_input' => $data['tokens_input'] ?? null,
+ 'tokens_output' => $data['tokens_output'] ?? null,
+ 'tokens_total' => $tokens_total > 0 ? $tokens_total : null,
+ 'cost_estimate' => $cost_estimate,
+ 'status' => $data['status'],
+ 'error_message' => $data['error_message'] ?? null,
+ 'user_id' => $data['user_id'] ?? get_current_user_id(),
+ 'context' => ! empty( $context ) ? wp_json_encode( $context ) : null,
+ 'request_preview' => $request_preview,
+ 'response_preview' => $response_preview,
+ );
+
+ $result = $wpdb->insert(
+ $this->schema->get_table_name(),
+ $insert_data,
+ array( '%s', '%s', '%s', '%s', '%s', '%s', '%d', '%d', '%d', '%d', '%f', '%s', '%s', '%d', '%s', '%s', '%s' )
+ );
+
+ if ( false === $result ) {
+ return false;
+ }
+
+ $this->invalidate_summary_cache();
+
+ /**
+ * Fires after an AI request is logged.
+ *
+ * @since x.x.x
+ *
+ * @param string $log_id The unique log identifier.
+ * @param array $data The log data.
+ */
+ do_action( 'ai_request_logged', $log_id, $insert_data );
+
+ return $log_id;
+ }
+
+ /**
+ * Retrieves a single log entry by ID.
+ *
+ * @since x.x.x
+ *
+ * @param string $log_id The log identifier.
+ * @return array|null The log entry or null if not found.
+ */
+ public function find( string $log_id ): ?array {
+ global $wpdb;
+
+ $table_name = $this->schema->get_table_name();
+
+ $row = $wpdb->get_row(
+ $wpdb->prepare(
+ "SELECT * FROM {$table_name} WHERE log_id = %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $log_id
+ ),
+ ARRAY_A
+ );
+
+ if ( ! $row ) {
+ return null;
+ }
+
+ return $this->format_log_row( $row );
+ }
+
+ /**
+ * Retrieves logs with filtering and pagination.
+ *
+ * @since x.x.x
+ *
+ * @param array{
+ * type?: string,
+ * status?: string,
+ * provider?: string,
+ * operation?: string,
+ * operation_pattern?: string,
+ * user_id?: int,
+ * date_from?: string,
+ * date_to?: string,
+ * search?: string,
+ * page?: int,
+ * per_page?: int,
+ * orderby?: string,
+ * order?: string,
+ * cursor_id?: int,
+ * cursor_timestamp?: string
+ * } $args Query arguments.
+ * @return array{items: list>, total: int, pages: int, next_cursor?: array{id: int, timestamp: string}} Results.
+ */
+ public function query( array $args = array() ): array {
+ global $wpdb;
+
+ $defaults = array(
+ 'type' => '',
+ 'status' => '',
+ 'provider' => '',
+ 'operation' => '',
+ 'operation_pattern' => '',
+ 'tokens_gt' => null,
+ 'tokens_lt' => null,
+ 'tokens_filter' => '',
+ 'user_id' => 0,
+ 'date_from' => '',
+ 'date_to' => '',
+ 'search' => '',
+ 'page' => 1,
+ 'per_page' => 25,
+ 'orderby' => 'timestamp',
+ 'order' => 'DESC',
+ 'cursor_id' => null,
+ 'cursor_timestamp' => null,
+ );
+
+ $args = wp_parse_args( $args, $defaults );
+
+ $use_cursor = 'timestamp' === $args['orderby']
+ && null !== $args['cursor_id']
+ && null !== $args['cursor_timestamp'];
+
+ $table_name = $this->schema->get_table_name();
+ $where = array( '1=1' );
+ $values = array();
+
+ $this->build_where_clauses( $args, $where, $values );
+
+ $where_clause = implode( ' AND ', $where );
+
+ $allowed_orderby = array( 'timestamp', 'type', 'operation', 'duration_ms', 'tokens_total', 'cost_estimate', 'status' );
+ $orderby = in_array( $args['orderby'], $allowed_orderby, true ) ? $args['orderby'] : 'timestamp';
+ $order = 'ASC' === strtoupper( $args['order'] ) ? 'ASC' : 'DESC';
+
+ $count_sql = "SELECT COUNT(*) FROM {$table_name} WHERE {$where_clause}"; // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ if ( ! empty( $values ) ) {
+ $count_sql = $wpdb->prepare( $count_sql, $values ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
+ }
+ $total = (int) $wpdb->get_var( $count_sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
+
+ $per_page = max( 1, min( 100, (int) $args['per_page'] ) );
+ $pages = (int) ceil( $total / $per_page );
+ $page = max( 1, min( ( $pages ? $pages : 1 ), (int) $args['page'] ) );
+
+ if ( $use_cursor ) {
+ $rows = $this->query_with_cursor( $table_name, $where_clause, $values, $args, $orderby, $order, $per_page );
+ } else {
+ $rows = $this->query_with_offset( $table_name, $where_clause, $values, $orderby, $order, $per_page, $page );
+ }
+
+ $items = array_map( array( $this, 'format_log_row' ), ( $rows ? $rows : array() ) );
+
+ $result = array(
+ 'items' => $items,
+ 'total' => $total,
+ 'pages' => max( 1, $pages ),
+ );
+
+ if ( ! empty( $rows ) ) {
+ $last_row = end( $rows );
+ $result['next_cursor'] = array(
+ 'id' => (int) $last_row['id'],
+ 'timestamp' => $last_row['timestamp'],
+ );
+ }
+
+ return $result;
+ }
+
+ /**
+ * Gets aggregate statistics for the dashboard.
+ *
+ * @since x.x.x
+ *
+ * @param string $period Time period: 'day', 'week', 'month', or 'all'.
+ * @param bool $force_refresh Whether to bypass the cache.
+ * @return array{
+ * total_requests: int,
+ * total_tokens: int,
+ * total_cost: float,
+ * avg_duration_ms: float,
+ * success_rate: float,
+ * by_type: array,
+ * by_provider: array,
+ * by_status: array
+ * } Aggregated statistics.
+ */
+ public function get_summary( string $period = 'day', bool $force_refresh = false ): array {
+ $cache_key = self::CACHE_GROUP . '_summary_' . $period;
+
+ if ( ! $force_refresh ) {
+ $cached = get_transient( $cache_key );
+ if ( false !== $cached ) {
+ return $cached;
+ }
+ }
+
+ global $wpdb;
+
+ $table_name = $this->schema->get_table_name();
+ $date_condition = $this->get_date_condition( $period );
+
+ $sql = "SELECT
+ COUNT(*) as total_requests,
+ COALESCE(SUM(tokens_total), 0) as total_tokens,
+ COALESCE(SUM(cost_estimate), 0) as total_cost,
+ COALESCE(AVG(duration_ms), 0) as avg_duration_ms,
+ SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) as success_count,
+ type,
+ provider,
+ status
+ FROM {$table_name}
+ WHERE 1=1 {$date_condition}
+ GROUP BY type, provider, status"; // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+
+ $rows = $wpdb->get_results( $sql, ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
+
+ $result = $this->aggregate_summary_rows( ( $rows ? $rows : array() ) );
+
+ set_transient( $cache_key, $result, self::SUMMARY_CACHE_EXPIRATION );
+
+ return $result;
+ }
+
+ /**
+ * Gets distinct values for filter dropdowns.
+ *
+ * @since x.x.x
+ *
+ * @param bool $force_refresh Whether to bypass the cache.
+ * @return array{types: list, providers: list, statuses: list, operations: list} Filter options.
+ */
+ public function get_filter_options( bool $force_refresh = false ): array {
+ $cache_key = self::CACHE_GROUP . '_filter_options';
+
+ if ( ! $force_refresh ) {
+ $cached = get_transient( $cache_key );
+ if ( false !== $cached ) {
+ return $cached;
+ }
+ }
+
+ global $wpdb;
+
+ $table_name = $this->schema->get_table_name();
+
+ $sql = "SELECT 'type' as category, type as value FROM {$table_name} WHERE type IS NOT NULL GROUP BY type
+ UNION ALL
+ SELECT 'provider' as category, provider as value FROM {$table_name} WHERE provider IS NOT NULL GROUP BY provider
+ UNION ALL
+ SELECT 'status' as category, status as value FROM {$table_name} WHERE status IS NOT NULL GROUP BY status
+ UNION ALL
+ SELECT 'operation' as category, operation as value FROM {$table_name} WHERE operation IS NOT NULL GROUP BY operation"; // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+
+ $rows = $wpdb->get_results( $sql, ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
+
+ $result = $this->aggregate_filter_options( ( $rows ? $rows : array() ) );
+
+ set_transient( $cache_key, $result, self::FILTER_CACHE_EXPIRATION );
+
+ return $result;
+ }
+
+ /**
+ * Deletes logs older than the retention period.
+ *
+ * @since x.x.x
+ *
+ * @param int $retention_days Number of days to retain logs.
+ * @return int Number of logs deleted.
+ */
+ public function cleanup_by_retention( int $retention_days ): int {
+ global $wpdb;
+
+ $table_name = $this->schema->get_table_name();
+ $total_deleted = 0;
+
+ do {
+ $deleted = $wpdb->query(
+ $wpdb->prepare(
+ "DELETE FROM {$table_name} WHERE timestamp < DATE_SUB(NOW(), INTERVAL %d DAY) LIMIT %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $retention_days,
+ self::DELETE_BATCH_SIZE
+ )
+ );
+
+ $batch_deleted = ( $deleted ? $deleted : 0 );
+ $total_deleted += $batch_deleted;
+
+ if ( $batch_deleted < self::DELETE_BATCH_SIZE ) {
+ continue;
+ }
+
+ usleep( 100000 );
+ } while ( $batch_deleted >= self::DELETE_BATCH_SIZE );
+
+ return $total_deleted;
+ }
+
+ /**
+ * Deletes oldest logs when table exceeds max rows limit.
+ *
+ * @since x.x.x
+ *
+ * @param int $max_rows Maximum number of rows to retain.
+ * @return int Number of logs deleted.
+ */
+ public function cleanup_by_max_rows( int $max_rows ): int {
+ global $wpdb;
+
+ $table_name = $this->schema->get_table_name();
+
+ $current_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table_name}" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+
+ if ( $current_count <= $max_rows ) {
+ return 0;
+ }
+
+ $rows_to_delete = $current_count - $max_rows;
+ $total_deleted = 0;
+
+ while ( $rows_to_delete > 0 ) {
+ $batch_size = min( $rows_to_delete, self::DELETE_BATCH_SIZE );
+
+ $deleted = $wpdb->query(
+ $wpdb->prepare(
+ "DELETE FROM {$table_name} WHERE id IN (SELECT id FROM (SELECT id FROM {$table_name} ORDER BY timestamp ASC LIMIT %d) AS oldest)", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $batch_size
+ )
+ );
+
+ $batch_deleted = ( $deleted ? $deleted : 0 );
+ $total_deleted += $batch_deleted;
+ $rows_to_delete -= $batch_deleted;
+
+ if ( 0 === $batch_deleted ) {
+ break;
+ }
+
+ if ( $batch_deleted < self::DELETE_BATCH_SIZE ) {
+ continue;
+ }
+
+ usleep( 100000 );
+ }
+
+ return $total_deleted;
+ }
+
+ /**
+ * Purges all logs from the database.
+ *
+ * @since x.x.x
+ *
+ * @return int Number of logs deleted.
+ */
+ public function purge_all(): int {
+ global $wpdb;
+
+ $table_name = $this->schema->get_table_name();
+
+ $count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table_name}" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+
+ $wpdb->query( "TRUNCATE TABLE {$table_name}" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+
+ $this->invalidate_caches();
+
+ return $count;
+ }
+
+ /**
+ * Invalidates all caches.
+ *
+ * @since x.x.x
+ */
+ public function invalidate_caches(): void {
+ $this->invalidate_filter_cache();
+ $this->invalidate_summary_cache();
+ }
+
+ /**
+ * Invalidates the filter options cache.
+ *
+ * @since x.x.x
+ */
+ public function invalidate_filter_cache(): void {
+ delete_transient( self::CACHE_GROUP . '_filter_options' );
+ }
+
+ /**
+ * Invalidates the summary cache.
+ *
+ * @since x.x.x
+ */
+ public function invalidate_summary_cache(): void {
+ $periods = array( 'minute', 'hour', 'day', 'week', 'month', 'all' );
+ foreach ( $periods as $period ) {
+ delete_transient( self::CACHE_GROUP . '_summary_' . $period );
+ }
+ }
+
+ /**
+ * Builds WHERE clauses for the query.
+ *
+ * @since x.x.x
+ *
+ * @param array $args Query arguments.
+ * @param list $where WHERE clauses array (modified by reference).
+ * @param list $values Values array for prepared statement (modified by reference).
+ */
+ private function build_where_clauses( array $args, array &$where, array &$values ): void {
+ if ( ! empty( $args['type'] ) ) {
+ $where[] = 'type = %s';
+ $values[] = $args['type'];
+ }
+
+ if ( ! empty( $args['status'] ) ) {
+ $where[] = 'status = %s';
+ $values[] = $args['status'];
+ }
+
+ if ( ! empty( $args['provider'] ) ) {
+ $where[] = 'provider = %s';
+ $values[] = $args['provider'];
+ }
+
+ if ( ! empty( $args['operation'] ) ) {
+ $operations = array_filter( array_map( 'trim', explode( ',', $args['operation'] ) ) );
+ if ( 1 === count( $operations ) ) {
+ $where[] = 'operation = %s';
+ $values[] = $operations[0];
+ } elseif ( count( $operations ) > 1 ) {
+ $placeholders = implode( ', ', array_fill( 0, count( $operations ), '%s' ) );
+ $where[] = "operation IN ( $placeholders )";
+ $values = array_merge( $values, $operations );
+ }
+ }
+
+ if ( ! empty( $args['operation_pattern'] ) ) {
+ $where[] = 'operation REGEXP %s';
+ $values[] = $args['operation_pattern'];
+ }
+
+ $this->build_token_filter_clauses( $args, $where, $values );
+
+ if ( ! empty( $args['user_id'] ) ) {
+ $where[] = 'user_id = %d';
+ $values[] = $args['user_id'];
+ }
+
+ if ( ! empty( $args['date_from'] ) ) {
+ $where[] = 'timestamp >= %s';
+ $values[] = $args['date_from'];
+ }
+
+ if ( ! empty( $args['date_to'] ) ) {
+ $where[] = 'timestamp <= %s';
+ $values[] = $args['date_to'];
+ }
+
+ if ( empty( $args['search'] ) ) {
+ return;
+ }
+
+ $this->build_search_clause( $args['search'], $where, $values );
+ }
+
+ /**
+ * Builds token filter clauses.
+ *
+ * @since x.x.x
+ *
+ * @param array $args Query arguments.
+ * @param list $where WHERE clauses array (modified by reference).
+ * @param list $values Values array (modified by reference).
+ */
+ private function build_token_filter_clauses( array $args, array &$where, array &$values ): void {
+ if ( isset( $args['tokens_gt'] ) && is_numeric( $args['tokens_gt'] ) ) {
+ $where[] = 'tokens_total > %d';
+ $values[] = (int) $args['tokens_gt'];
+ }
+
+ if ( isset( $args['tokens_lt'] ) && is_numeric( $args['tokens_lt'] ) ) {
+ $where[] = 'tokens_total < %d';
+ $values[] = (int) $args['tokens_lt'];
+ }
+
+ if ( empty( $args['tokens_filter'] ) ) {
+ return;
+ }
+
+ $filter = $args['tokens_filter'];
+ if ( 'none' === $filter ) {
+ $where[] = '(tokens_total IS NULL OR tokens_total = 0)';
+ } elseif ( str_starts_with( $filter, 'gt:' ) ) {
+ $where[] = 'tokens_total > %d';
+ $values[] = (int) substr( $filter, 3 );
+ } elseif ( str_starts_with( $filter, 'lt:' ) ) {
+ $where[] = 'tokens_total < %d';
+ $values[] = (int) substr( $filter, 3 );
+ }
+ }
+
+ /**
+ * Builds the search clause for full-text or LIKE search.
+ *
+ * @since x.x.x
+ *
+ * @param string $search Search term.
+ * @param list $where WHERE clauses array (modified by reference).
+ * @param list $values Values array (modified by reference).
+ */
+ private function build_search_clause( string $search, array &$where, array &$values ): void {
+ global $wpdb;
+
+ $search_like = '%' . $wpdb->esc_like( $search ) . '%';
+
+ // Only use fulltext if index exists AND autocommit is enabled (tests use transactions).
+ $use_fulltext = $this->schema->has_fulltext_index() && $this->is_autocommit_enabled();
+
+ if ( $use_fulltext ) {
+ $boolean_query = $this->build_fulltext_search_query( $search );
+
+ if ( '' !== $boolean_query ) {
+ $where[] = '(MATCH(operation, request_preview, response_preview) AGAINST(%s IN BOOLEAN MODE) OR error_message LIKE %s)';
+ $values[] = $boolean_query;
+ $values[] = $search_like;
+ return;
+ }
+ }
+
+ $where[] = '(operation LIKE %s OR error_message LIKE %s OR request_preview LIKE %s OR response_preview LIKE %s)';
+ $values[] = $search_like;
+ $values[] = $search_like;
+ $values[] = $search_like;
+ $values[] = $search_like;
+ }
+
+ /**
+ * Builds a boolean-mode FULLTEXT query string.
+ *
+ * @since x.x.x
+ *
+ * @param string $search Raw search string.
+ * @return string Boolean FULLTEXT query or empty string.
+ */
+ private function build_fulltext_search_query( string $search ): string {
+ $search = trim( $search );
+
+ if ( '' === $search ) {
+ return '';
+ }
+
+ $tokens = preg_split( '/\s+/', $search );
+ if ( ! $tokens ) {
+ return '';
+ }
+
+ $clauses = array();
+
+ foreach ( $tokens as $token ) {
+ $token = trim( (string) $token );
+ if ( '' === $token ) {
+ continue;
+ }
+
+ $token = preg_replace( '/[+\-><()~*"@]+/', ' ', $token );
+ $token = trim( (string) $token );
+
+ if ( '' === $token ) {
+ continue;
+ }
+
+ $length = function_exists( 'mb_strlen' )
+ ? mb_strlen( $token, 'UTF-8' )
+ : strlen( $token );
+
+ if ( $length < 3 ) {
+ continue;
+ }
+
+ $clauses[] = '+' . $token . '*';
+ }
+
+ return implode( ' ', $clauses );
+ }
+
+ /**
+ * Determines if the current database connection has autocommit enabled.
+ *
+ * In transactional test environments (e.g., WP_UnitTestCase), fulltext indexes
+ * are not updated until commit, so prefer LIKE queries when autocommit is off.
+ *
+ * @since x.x.x
+ *
+ * @return bool True if autocommit is enabled.
+ */
+ private function is_autocommit_enabled(): bool {
+ global $wpdb;
+
+ $autocommit = $wpdb->get_var( 'SELECT @@autocommit' );
+
+ if ( null === $autocommit ) {
+ return true;
+ }
+
+ return 1 === (int) $autocommit;
+ }
+
+ /**
+ * Queries logs using cursor-based pagination.
+ *
+ * @since x.x.x
+ *
+ * @param string $table_name Table name.
+ * @param string $where_clause WHERE clause.
+ * @param list $values Prepared values.
+ * @param array $args Query arguments.
+ * @param string $orderby Order by column.
+ * @param string $order Sort order.
+ * @param int $per_page Results per page.
+ * @return list> Query results.
+ */
+ private function query_with_cursor( string $table_name, string $where_clause, array $values, array $args, string $orderby, string $order, int $per_page ): array {
+ global $wpdb;
+
+ $cursor_values = $values;
+
+ if ( 'DESC' === $order ) {
+ $cursor_values[] = $args['cursor_timestamp'];
+ $cursor_values[] = $args['cursor_timestamp'];
+ $cursor_values[] = (int) $args['cursor_id'];
+ $cursor_condition = '((timestamp < %s) OR (timestamp = %s AND id < %d))';
+ } else {
+ $cursor_values[] = $args['cursor_timestamp'];
+ $cursor_values[] = $args['cursor_timestamp'];
+ $cursor_values[] = (int) $args['cursor_id'];
+ $cursor_condition = '((timestamp > %s) OR (timestamp = %s AND id > %d))';
+ }
+
+ $sql = "SELECT * FROM {$table_name} WHERE {$where_clause} AND {$cursor_condition} ORDER BY {$orderby} {$order}, id {$order} LIMIT %d"; // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $cursor_values[] = $per_page;
+
+ $results = $wpdb->get_results(
+ $wpdb->prepare( $sql, $cursor_values ), // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
+ ARRAY_A
+ );
+
+ return ( $results ? $results : array() );
+ }
+
+ /**
+ * Queries logs using offset-based pagination.
+ *
+ * @since x.x.x
+ *
+ * @param string $table_name Table name.
+ * @param string $where_clause WHERE clause.
+ * @param list $values Prepared values.
+ * @param string $orderby Order by column.
+ * @param string $order Sort order.
+ * @param int $per_page Results per page.
+ * @param int $page Current page number.
+ * @return list> Query results.
+ */
+ private function query_with_offset( string $table_name, string $where_clause, array $values, string $orderby, string $order, int $per_page, int $page ): array {
+ global $wpdb;
+
+ $offset = ( $page - 1 ) * $per_page;
+
+ $sql = "SELECT * FROM {$table_name} WHERE {$where_clause} ORDER BY {$orderby} {$order}, id {$order} LIMIT %d OFFSET %d"; // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $values[] = $per_page;
+ $values[] = $offset;
+
+ $results = $wpdb->get_results(
+ $wpdb->prepare( $sql, $values ), // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
+ ARRAY_A
+ );
+
+ return ( $results ? $results : array() );
+ }
+
+ /**
+ * Gets the SQL date condition for a time period.
+ *
+ * @since x.x.x
+ *
+ * @param string $period Time period.
+ * @return string SQL condition.
+ */
+ private function get_date_condition( string $period ): string {
+ switch ( $period ) {
+ case 'minute':
+ return 'AND timestamp >= DATE_SUB(NOW(), INTERVAL 1 MINUTE)';
+ case 'hour':
+ return 'AND timestamp >= DATE_SUB(NOW(), INTERVAL 1 HOUR)';
+ case 'day':
+ return 'AND timestamp >= DATE_SUB(NOW(), INTERVAL 1 DAY)';
+ case 'week':
+ return 'AND timestamp >= DATE_SUB(NOW(), INTERVAL 1 WEEK)';
+ case 'month':
+ return 'AND timestamp >= DATE_SUB(NOW(), INTERVAL 1 MONTH)';
+ default:
+ return '';
+ }
+ }
+
+ /**
+ * Aggregates summary rows into statistics.
+ *
+ * @since x.x.x
+ *
+ * @param list> $rows Raw query rows.
+ * @return array Aggregated statistics.
+ */
+ private function aggregate_summary_rows( array $rows ): array {
+ $total_requests = 0;
+ $total_tokens = 0;
+ $total_cost = 0.0;
+ $total_duration = 0.0;
+ $success_count = 0;
+ $by_type = array();
+ $by_provider = array();
+ $by_status = array();
+
+ foreach ( $rows as $row ) {
+ $count = (int) $row['total_requests'];
+
+ $total_requests += $count;
+ $total_tokens += (int) $row['total_tokens'];
+ $total_cost += (float) $row['total_cost'];
+ $total_duration += (float) $row['avg_duration_ms'] * $count;
+ $success_count += (int) $row['success_count'];
+
+ if ( $row['type'] ) {
+ $by_type[ $row['type'] ] = ( $by_type[ $row['type'] ] ?? 0 ) + $count;
+ }
+
+ if ( $row['provider'] ) {
+ $by_provider[ $row['provider'] ] = ( $by_provider[ $row['provider'] ] ?? 0 ) + $count;
+ }
+
+ if ( ! $row['status'] ) {
+ continue;
+ }
+
+ $by_status[ $row['status'] ] = ( $by_status[ $row['status'] ] ?? 0 ) + $count;
+ }
+
+ $avg_duration_ms = $total_requests > 0 ? $total_duration / $total_requests : 0;
+ $success_rate = $total_requests > 0 ? $success_count / $total_requests * 100 : 0;
+
+ return array(
+ 'total_requests' => $total_requests,
+ 'total_tokens' => $total_tokens,
+ 'total_cost' => $total_cost,
+ 'avg_duration_ms' => round( $avg_duration_ms, 2 ),
+ 'success_rate' => round( $success_rate, 2 ),
+ 'by_type' => $by_type,
+ 'by_provider' => $by_provider,
+ 'by_status' => $by_status,
+ );
+ }
+
+ /**
+ * Aggregates filter option rows.
+ *
+ * @since x.x.x
+ *
+ * @param list> $rows Raw query rows.
+ * @return array{types: list, providers: list, statuses: list, operations: list} Filter options.
+ */
+ private function aggregate_filter_options( array $rows ): array {
+ $result = array(
+ 'types' => array(),
+ 'providers' => array(),
+ 'statuses' => array(),
+ 'operations' => array(),
+ );
+
+ foreach ( $rows as $row ) {
+ switch ( $row['category'] ) {
+ case 'type':
+ $result['types'][] = $row['value'];
+ break;
+ case 'provider':
+ $result['providers'][] = $row['value'];
+ break;
+ case 'status':
+ $result['statuses'][] = $row['value'];
+ break;
+ case 'operation':
+ $result['operations'][] = $row['value'];
+ break;
+ }
+ }
+
+ sort( $result['types'] );
+ sort( $result['providers'] );
+ sort( $result['statuses'] );
+ sort( $result['operations'] );
+
+ return $result;
+ }
+
+ /**
+ * Formats a raw database row into a structured log entry.
+ *
+ * @since x.x.x
+ *
+ * @param array $row Raw database row.
+ * @return array Formatted log entry.
+ */
+ private function format_log_row( array $row ): array {
+ $duration = $row['duration_ms'] ? (int) $row['duration_ms'] : null;
+ $tokens_total = $row['tokens_total'] ? (int) $row['tokens_total'] : null;
+ $tokens_per_second = null;
+
+ if ( null !== $tokens_total && $duration && $duration > 0 ) {
+ $tokens_per_second = $tokens_total / ( $duration / 1000 );
+ }
+
+ return array(
+ 'id' => $row['log_id'],
+ 'timestamp' => $row['timestamp'],
+ 'type' => $row['type'],
+ 'operation' => $row['operation'],
+ 'provider' => $row['provider'],
+ 'model' => $row['model'],
+ 'duration_ms' => $duration,
+ 'tokens_input' => $row['tokens_input'] ? (int) $row['tokens_input'] : null,
+ 'tokens_output' => $row['tokens_output'] ? (int) $row['tokens_output'] : null,
+ 'tokens_total' => $tokens_total,
+ 'tokens_per_second' => null !== $tokens_per_second ? (float) $tokens_per_second : null,
+ 'cost_estimate' => $row['cost_estimate'] ? (float) $row['cost_estimate'] : null,
+ 'status' => $row['status'],
+ 'error_message' => $row['error_message'],
+ 'user_id' => $row['user_id'] ? (int) $row['user_id'] : null,
+ 'context' => $row['context'] ? json_decode( $row['context'], true ) : null,
+ );
+ }
+}
diff --git a/includes/Logging/AI_Request_Log_Schema.php b/includes/Logging/AI_Request_Log_Schema.php
new file mode 100644
index 00000000..9da607ae
--- /dev/null
+++ b/includes/Logging/AI_Request_Log_Schema.php
@@ -0,0 +1,278 @@
+get_table_name();
+
+ // Skip if table already exists.
+ $existing_table = $wpdb->get_var(
+ $wpdb->prepare(
+ 'SHOW TABLES LIKE %s',
+ $table_name
+ )
+ );
+
+ if ( $existing_table === $table_name ) {
+ return;
+ }
+
+ $this->create_table();
+ }
+
+ /**
+ * Returns the full table name with prefix.
+ *
+ * @since x.x.x
+ *
+ * @return string The prefixed table name.
+ */
+ public function get_table_name(): string {
+ global $wpdb;
+ return $wpdb->prefix . self::TABLE_NAME;
+ }
+
+ /**
+ * Creates the logs database table.
+ *
+ * @since x.x.x
+ */
+ private function create_table(): void {
+ global $wpdb;
+
+ $table_name = $this->get_table_name();
+ $charset_collate = $wpdb->get_charset_collate();
+
+ $sql = "CREATE TABLE {$table_name} (
+ id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ log_id VARCHAR(36) NOT NULL,
+ timestamp DATETIME NOT NULL,
+ type VARCHAR(32) NOT NULL,
+ operation VARCHAR(255) NOT NULL,
+ provider VARCHAR(64) DEFAULT NULL,
+ model VARCHAR(128) DEFAULT NULL,
+ duration_ms INT UNSIGNED DEFAULT NULL,
+ tokens_input INT UNSIGNED DEFAULT NULL,
+ tokens_output INT UNSIGNED DEFAULT NULL,
+ tokens_total INT UNSIGNED DEFAULT NULL,
+ cost_estimate DECIMAL(10, 6) DEFAULT NULL,
+ status VARCHAR(32) NOT NULL,
+ error_message TEXT DEFAULT NULL,
+ user_id BIGINT UNSIGNED DEFAULT NULL,
+ context JSON DEFAULT NULL,
+ request_preview TEXT DEFAULT NULL,
+ response_preview TEXT DEFAULT NULL,
+ INDEX idx_timestamp (timestamp),
+ INDEX idx_type (type),
+ INDEX idx_status (status),
+ INDEX idx_user_id (user_id),
+ INDEX idx_log_id (log_id),
+ INDEX idx_provider (provider),
+ INDEX idx_operation (operation(191)),
+ INDEX idx_timestamp_type_status (timestamp, type, status),
+ INDEX idx_timestamp_provider (timestamp, provider)
+ ) {$charset_collate};";
+
+ require_once ABSPATH . 'wp-admin/includes/upgrade.php';
+ dbDelta( $sql );
+
+ $this->maybe_add_columns();
+ $this->maybe_add_indexes();
+ }
+
+ /**
+ * Adds missing columns to existing tables.
+ *
+ * @since x.x.x
+ */
+ private function maybe_add_columns(): void {
+ global $wpdb;
+
+ $table_name = $this->get_table_name();
+
+ $existing_columns = array();
+ $columns = $wpdb->get_results( "SHOW COLUMNS FROM {$table_name}", ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+
+ if ( $columns ) {
+ foreach ( $columns as $column ) {
+ $existing_columns[ $column['Field'] ] = true;
+ }
+ }
+
+ if ( ! isset( $existing_columns['request_preview'] ) ) {
+ $wpdb->query( "ALTER TABLE {$table_name} ADD COLUMN request_preview TEXT DEFAULT NULL AFTER context" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ }
+
+ if ( isset( $existing_columns['response_preview'] ) ) {
+ return;
+ }
+
+ $wpdb->query( "ALTER TABLE {$table_name} ADD COLUMN response_preview TEXT DEFAULT NULL AFTER request_preview" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ }
+
+ /**
+ * Adds missing indexes to existing tables.
+ *
+ * @since x.x.x
+ */
+ private function maybe_add_indexes(): void {
+ global $wpdb;
+
+ $table_name = $this->get_table_name();
+
+ $existing_indexes = array();
+ $indexes = $wpdb->get_results( "SHOW INDEX FROM {$table_name}", ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+
+ if ( $indexes ) {
+ foreach ( $indexes as $index ) {
+ $existing_indexes[ $index['Key_name'] ] = true;
+ }
+ }
+
+ $indexes_to_add = array(
+ 'idx_provider' => "ALTER TABLE {$table_name} ADD INDEX idx_provider (provider)",
+ 'idx_operation' => "ALTER TABLE {$table_name} ADD INDEX idx_operation (operation(191))",
+ 'idx_timestamp_type_status' => "ALTER TABLE {$table_name} ADD INDEX idx_timestamp_type_status (timestamp, type, status)",
+ 'idx_timestamp_provider' => "ALTER TABLE {$table_name} ADD INDEX idx_timestamp_provider (timestamp, provider)",
+ );
+
+ foreach ( $indexes_to_add as $index_name => $sql ) {
+ if ( isset( $existing_indexes[ $index_name ] ) ) {
+ continue;
+ }
+
+ $wpdb->query( $sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
+ }
+
+ $this->maybe_add_fulltext_index( $existing_indexes );
+ }
+
+ /**
+ * Adds FULLTEXT index for search if MySQL supports it.
+ *
+ * @since x.x.x
+ *
+ * @param array $existing_indexes Map of existing index names.
+ */
+ private function maybe_add_fulltext_index( array $existing_indexes ): void {
+ if ( isset( $existing_indexes['ft_search'] ) ) {
+ return;
+ }
+
+ global $wpdb;
+
+ $table_name = $this->get_table_name();
+ $mysql_version = $wpdb->get_var( 'SELECT VERSION()' );
+
+ if ( ! version_compare( $mysql_version, '5.6', '>=' ) ) {
+ return;
+ }
+
+ $wpdb->suppress_errors( true );
+ $wpdb->query( "ALTER TABLE {$table_name} ADD FULLTEXT INDEX ft_search (operation, request_preview, response_preview)" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $wpdb->suppress_errors( false );
+ }
+
+ /**
+ * Checks if the FULLTEXT search index exists on the table.
+ *
+ * @since x.x.x
+ *
+ * @return bool True if FULLTEXT index exists.
+ */
+ public function has_fulltext_index(): bool {
+ static $has_index = null;
+
+ if ( null !== $has_index ) {
+ return $has_index;
+ }
+
+ global $wpdb;
+
+ $table_name = $this->get_table_name();
+
+ $result = $wpdb->get_var(
+ $wpdb->prepare(
+ "SELECT COUNT(*) FROM information_schema.statistics
+ WHERE table_schema = %s
+ AND table_name = %s
+ AND index_name = 'ft_search'
+ AND index_type = 'FULLTEXT'",
+ DB_NAME,
+ $table_name
+ )
+ );
+
+ $has_index = (int) $result > 0;
+
+ return $has_index;
+ }
+
+ /**
+ * Gets table statistics for admin display.
+ *
+ * @since x.x.x
+ *
+ * @return array{row_count: int, table_size_bytes: int, table_size_formatted: string} Table stats.
+ */
+ public function get_table_stats(): array {
+ global $wpdb;
+
+ $table_name = $this->get_table_name();
+
+ $row_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table_name}" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+
+ $table_status = $wpdb->get_row(
+ $wpdb->prepare(
+ 'SELECT data_length + index_length as size FROM information_schema.tables WHERE table_schema = %s AND table_name = %s',
+ DB_NAME,
+ $table_name
+ ),
+ ARRAY_A
+ );
+
+ $table_size_bytes = (int) ( $table_status['size'] ?? 0 );
+
+ return array(
+ 'row_count' => $row_count,
+ 'table_size_bytes' => $table_size_bytes,
+ 'table_size_formatted' => size_format( $table_size_bytes ),
+ );
+ }
+}
diff --git a/includes/Logging/Logging_Discovery_Strategy.php b/includes/Logging/Logging_Discovery_Strategy.php
new file mode 100644
index 00000000..54d0578e
--- /dev/null
+++ b/includes/Logging/Logging_Discovery_Strategy.php
@@ -0,0 +1,92 @@
+>
+ */
+ public static function getCandidates( $type ) {
+ // Only handle PSR-18 HTTP Client discovery.
+ if ( ClientInterface::class !== $type ) {
+ return array();
+ }
+
+ // If logging is disabled or manager not set, return empty to fall through.
+ if ( ! self::$log_manager || ! self::$log_manager->is_logging_enabled() ) {
+ return array();
+ }
+
+ return array(
+ array(
+ 'class' => static function () {
+ return self::createLoggingClient();
+ },
+ ),
+ );
+ }
+
+ /**
+ * Create an instance of the logging HTTP client.
+ *
+ * @return \WordPress\AI\Logging\Logging_HTTP_Client
+ */
+ private static function createLoggingClient(): Logging_HTTP_Client {
+ $psr17_factory = new Psr17Factory();
+
+ // Create the underlying WordPress HTTP client.
+ $wordpress_client = new WordPress_HTTP_Client(
+ $psr17_factory,
+ $psr17_factory
+ );
+
+ // Wrap it with logging.
+ return new Logging_HTTP_Client( $wordpress_client, self::$log_manager );
+ }
+}
diff --git a/includes/Logging/Logging_HTTP_Client.php b/includes/Logging/Logging_HTTP_Client.php
new file mode 100644
index 00000000..8dec53c4
--- /dev/null
+++ b/includes/Logging/Logging_HTTP_Client.php
@@ -0,0 +1,645 @@
+client = $client;
+ $this->log_manager = $log_manager;
+ }
+
+ /**
+ * Sends a PSR-7 request and returns a PSR-7 response, logging the request.
+ *
+ * @param \Psr\Http\Message\RequestInterface $request The PSR-7 request.
+ * @return \Psr\Http\Message\ResponseInterface The PSR-7 response.
+ * @throws \Psr\Http\Client\ClientExceptionInterface If an error happens during processing.
+ */
+ public function sendRequest( RequestInterface $request ): ResponseInterface {
+ $timer = $this->log_manager->start_timer();
+ $log_data = $this->extract_request_data( $request );
+ $error_msg = null;
+ $status = 'success';
+
+ try {
+ $response = $this->client->sendRequest( $request );
+
+ $this->extract_response_data( $response, $log_data );
+
+ return $response;
+ } catch ( Throwable $e ) {
+ $status = 'error';
+ $error_msg = $e->getMessage();
+ throw $e;
+ } finally {
+ $log_data['duration_ms'] = $this->log_manager->end_timer( $timer );
+ $log_data['status'] = $status;
+ $log_data['error_message'] = $error_msg;
+
+ $this->log_manager->log( $log_data );
+ }
+ }
+
+ /**
+ * Sends a PSR-7 request with options and returns a PSR-7 response, logging the request.
+ *
+ * @param \Psr\Http\Message\RequestInterface $request The PSR-7 request.
+ * @param \WordPress\AiClient\Providers\Http\DTO\RequestOptions $options Transport options for the request.
+ * @return \Psr\Http\Message\ResponseInterface The PSR-7 response.
+ * @throws \Psr\Http\Client\ClientExceptionInterface If an error happens during processing.
+ */
+ public function sendRequestWithOptions( RequestInterface $request, RequestOptions $options ): ResponseInterface {
+ // If the wrapped client doesn't support options, fall back to regular send.
+ if ( ! $this->client instanceof ClientWithOptionsInterface ) {
+ return $this->sendRequest( $request );
+ }
+
+ $timer = $this->log_manager->start_timer();
+ $log_data = $this->extract_request_data( $request );
+ $error_msg = null;
+ $status = 'success';
+
+ try {
+ $response = $this->client->sendRequestWithOptions( $request, $options );
+
+ $this->extract_response_data( $response, $log_data );
+
+ return $response;
+ } catch ( Throwable $e ) {
+ $status = 'error';
+ $error_msg = $e->getMessage();
+ throw $e;
+ } finally {
+ $log_data['duration_ms'] = $this->log_manager->end_timer( $timer );
+ $log_data['status'] = $status;
+ $log_data['error_message'] = $error_msg;
+
+ $this->log_manager->log( $log_data );
+ }
+ }
+
+ /**
+ * Extracts logging data from the request.
+ *
+ * @param \Psr\Http\Message\RequestInterface $request The PSR-7 request.
+ * @return array Initial log data.
+ */
+ private function extract_request_data( RequestInterface $request ): array {
+ $uri = (string) $request->getUri();
+ $provider = $this->detect_provider( $uri );
+ $model = null;
+ $decoded = null;
+
+ // Try to extract model from request body.
+ $body = (string) $request->getBody();
+ if ( $body && $request->getBody()->isSeekable() ) {
+ $request->getBody()->rewind();
+ }
+
+ if ( $body ) {
+ $decoded = json_decode( $body, true );
+ if ( is_array( $decoded ) && isset( $decoded['model'] ) ) {
+ $model = (string) $decoded['model'];
+ }
+ }
+
+ // Build operation name from URL path.
+ $path = $request->getUri()->getPath();
+ $operation = $provider ? $provider . ':' . basename( $path ) : basename( $path );
+
+ $context = array(
+ 'url' => $uri,
+ 'method' => $request->getMethod(),
+ );
+
+ if ( is_array( $decoded ) ) {
+ $input_preview = $this->extract_input_preview( $decoded );
+ if ( $input_preview ) {
+ $context['input_preview'] = $input_preview;
+ }
+ }
+
+ $context['request_kind'] = $this->detect_request_kind( $provider, $path, $decoded );
+
+ return array(
+ 'type' => 'ai_client',
+ 'operation' => $operation,
+ 'provider' => $provider,
+ 'model' => $model,
+ 'context' => $context,
+ );
+ }
+
+ /**
+ * Extracts token usage and other data from the response.
+ *
+ * @param \Psr\Http\Message\ResponseInterface $response The PSR-7 response.
+ * @param array $log_data Log data to update (passed by reference).
+ */
+ private function extract_response_data( ResponseInterface $response, array &$log_data ): void {
+ $body = (string) $response->getBody();
+ if ( $response->getBody()->isSeekable() ) {
+ $response->getBody()->rewind();
+ }
+
+ if ( ! $body ) {
+ return;
+ }
+
+ $decoded = json_decode( $body, true );
+ if ( ! is_array( $decoded ) ) {
+ return;
+ }
+
+ // Extract model if not already set.
+ if ( empty( $log_data['model'] ) && isset( $decoded['model'] ) ) {
+ $log_data['model'] = (string) $decoded['model'];
+ }
+
+ // Extract token usage - OpenAI format.
+ if ( isset( $decoded['usage'] ) && is_array( $decoded['usage'] ) ) {
+ $usage = $decoded['usage'];
+ $log_data['tokens_input'] = $usage['prompt_tokens'] ?? $usage['input_tokens'] ?? null;
+ $log_data['tokens_output'] = $usage['completion_tokens'] ?? $usage['output_tokens'] ?? null;
+ }
+
+ // Anthropic format.
+ if ( isset( $decoded['usage']['input_tokens'] ) ) {
+ $log_data['tokens_input'] = $decoded['usage']['input_tokens'];
+ $log_data['tokens_output'] = $decoded['usage']['output_tokens'] ?? null;
+ }
+
+ // Google format.
+ if ( isset( $decoded['usageMetadata'] ) && is_array( $decoded['usageMetadata'] ) ) {
+ $usage = $decoded['usageMetadata'];
+ $log_data['tokens_input'] = $usage['promptTokenCount'] ?? null;
+ $log_data['tokens_output'] = $usage['candidatesTokenCount'] ?? null;
+ }
+
+ $context = array();
+ if ( isset( $log_data['context'] ) && is_array( $log_data['context'] ) ) {
+ $context = $log_data['context'];
+ } elseif ( isset( $log_data['context'] ) && is_string( $log_data['context'] ) ) {
+ $maybe_context = json_decode( $log_data['context'], true );
+ if ( is_array( $maybe_context ) ) {
+ $context = $maybe_context;
+ }
+ }
+
+ $output_preview = $this->extract_output_preview( $decoded );
+ if ( $output_preview ) {
+ $context['output_preview'] = $output_preview;
+ }
+
+ if ( is_array( $decoded ) ) {
+ $media_context = $this->extract_media_context( $decoded );
+ if ( ! empty( $media_context ) ) {
+ $context = array_merge( $context, $media_context );
+ }
+ }
+
+ if ( empty( $context ) ) {
+ return;
+ }
+
+ $log_data['context'] = $context;
+ }
+
+ /**
+ * Detects the AI provider from the request URL.
+ *
+ * @param string $url The request URL.
+ * @return string|null The detected provider name or null.
+ */
+ private function detect_provider( string $url ): ?string {
+ $host = parse_url( $url, PHP_URL_HOST );
+
+ if ( ! $host ) {
+ return null;
+ }
+
+ $host_lower = strtolower( $host );
+
+ if ( strpos( $host_lower, 'openai' ) !== false ) {
+ return 'openai';
+ }
+
+ if ( strpos( $host_lower, 'anthropic' ) !== false ) {
+ return 'anthropic';
+ }
+
+ if ( strpos( $host_lower, 'googleapis' ) !== false || strpos( $host_lower, 'google' ) !== false ) {
+ return 'google';
+ }
+
+ if ( strpos( $host_lower, 'fal.run' ) !== false || strpos( $host_lower, 'fal.ai' ) !== false ) {
+ return 'fal';
+ }
+
+ if ( strpos( $host_lower, 'cloudflare' ) !== false || strpos( $host_lower, 'workers.ai' ) !== false ) {
+ return 'cloudflare';
+ }
+
+ if ( strpos( $host_lower, 'groq' ) !== false ) {
+ return 'groq';
+ }
+
+ if ( strpos( $host_lower, 'x.ai' ) !== false || strpos( $host_lower, 'xai' ) !== false ) {
+ return 'grok';
+ }
+
+ if ( strpos( $host_lower, 'huggingface' ) !== false ) {
+ return 'huggingface';
+ }
+
+ if ( strpos( $host_lower, 'deepseek' ) !== false ) {
+ return 'deepseek';
+ }
+
+ if ( strpos( $host_lower, 'ollama' ) !== false ) {
+ return 'ollama';
+ }
+
+ if ( strpos( $host_lower, 'openrouter' ) !== false ) {
+ return 'openrouter';
+ }
+
+ if ( strpos( $host_lower, 'azure' ) !== false ) {
+ return 'azure';
+ }
+
+ if ( strpos( $host_lower, 'cohere' ) !== false ) {
+ return 'cohere';
+ }
+
+ if ( strpos( $host_lower, 'mistral' ) !== false ) {
+ return 'mistral';
+ }
+
+ return null;
+ }
+
+ /**
+ * Extracts a human-readable preview of the prompt/input payload.
+ *
+ * @param array|null $payload Request payload.
+ * @return string|null
+ */
+ private function extract_input_preview( ?array $payload ): ?string {
+ if ( empty( $payload ) ) {
+ return null;
+ }
+
+ if ( isset( $payload['messages'] ) && is_array( $payload['messages'] ) ) {
+ $segments = array();
+
+ foreach ( $payload['messages'] as $message ) {
+ if ( ! is_array( $message ) ) {
+ continue;
+ }
+
+ $role = $message['role'] ?? 'user';
+ $content = $this->stringify_content( $message['content'] ?? '' );
+
+ if ( '' === $content ) {
+ continue;
+ }
+
+ $segments[] = sprintf( '[%s] %s', $role, $content );
+
+ if ( strlen( implode( "\n", $segments ) ) >= self::PAYLOAD_PREVIEW_LIMIT ) {
+ break;
+ }
+ }
+
+ if ( $segments ) {
+ return $this->truncate_string( implode( "\n", $segments ) );
+ }
+ }
+
+ foreach ( array( 'prompt', 'input', 'contents' ) as $field ) {
+ if ( ! isset( $payload[ $field ] ) ) {
+ continue;
+ }
+
+ $content = $this->stringify_content( $payload[ $field ] );
+ if ( '' !== $content ) {
+ return $this->truncate_string( $content );
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Extracts a human-readable preview of the response payload.
+ *
+ * @param array|null $payload Response payload.
+ * @return string|null
+ */
+ private function extract_output_preview( ?array $payload ): ?string {
+ if ( empty( $payload ) ) {
+ return null;
+ }
+
+ if ( isset( $payload['choices'] ) && is_array( $payload['choices'] ) ) {
+ foreach ( $payload['choices'] as $choice ) {
+ if ( ! is_array( $choice ) ) {
+ continue;
+ }
+
+ if ( isset( $choice['message']['content'] ) ) {
+ $content = $this->stringify_content( $choice['message']['content'] );
+ if ( '' !== $content ) {
+ return $this->truncate_string( $content );
+ }
+ }
+
+ if ( ! isset( $choice['text'] ) ) {
+ continue;
+ }
+
+ $content = $this->stringify_content( $choice['text'] );
+ if ( '' !== $content ) {
+ return $this->truncate_string( $content );
+ }
+ }
+ }
+
+ if ( isset( $payload['output'] ) ) {
+ $content = $this->stringify_content( $payload['output'] );
+ if ( '' !== $content ) {
+ return $this->truncate_string( $content );
+ }
+ }
+
+ if ( isset( $payload['candidates'] ) && is_array( $payload['candidates'] ) ) {
+ foreach ( $payload['candidates'] as $candidate ) {
+ if ( ! is_array( $candidate ) ) {
+ continue;
+ }
+
+ if ( isset( $candidate['content'] ) ) {
+ $content = $this->stringify_content( $candidate['content'] );
+ if ( '' !== $content ) {
+ return $this->truncate_string( $content );
+ }
+ }
+
+ if ( ! isset( $candidate['output'] ) ) {
+ continue;
+ }
+
+ $content = $this->stringify_content( $candidate['output'] );
+ if ( '' !== $content ) {
+ return $this->truncate_string( $content );
+ }
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Converts structured content into a plain string.
+ *
+ * @param mixed $content Structured content (string|array).
+ * @return string
+ */
+ private function stringify_content( $content ): string {
+ if ( is_string( $content ) ) {
+ return trim( $content );
+ }
+
+ if ( is_array( $content ) ) {
+ if ( isset( $content['b64_json'] ) && is_string( $content['b64_json'] ) ) {
+ return '[base64 image]';
+ }
+
+ if ( isset( $content['image_base64'] ) && is_string( $content['image_base64'] ) ) {
+ return '[base64 image]';
+ }
+
+ $parts = array();
+
+ foreach ( $content as $chunk ) {
+ if ( is_array( $chunk ) && isset( $chunk['text'] ) ) {
+ $parts[] = (string) $chunk['text'];
+ continue;
+ }
+
+ if ( is_array( $chunk ) && isset( $chunk['content'] ) ) {
+ $parts[] = $this->stringify_content( $chunk['content'] );
+ continue;
+ }
+
+ if ( is_scalar( $chunk ) ) {
+ $parts[] = (string) $chunk;
+ continue;
+ }
+
+ if ( ! is_array( $chunk ) ) {
+ continue;
+ }
+
+ $parts[] = $this->stringify_content( $chunk );
+ }
+
+ if ( $parts ) {
+ return trim( implode( "\n", array_filter( $parts ) ) );
+ }
+
+ return trim( (string) wp_json_encode( $content ) );
+ }
+
+ if ( is_scalar( $content ) ) {
+ return trim( (string) $content );
+ }
+
+ return '';
+ }
+
+ /**
+ * Truncates a string to the configured preview limit.
+ *
+ * @param string $value The string to truncate.
+ * @param int $limit Maximum length.
+ * @return string
+ */
+ private function truncate_string( string $value, int $limit = self::PAYLOAD_PREVIEW_LIMIT ): string {
+ $value = trim( $value );
+
+ if ( '' === $value ) {
+ return $value;
+ }
+
+ if ( function_exists( 'mb_strlen' ) && function_exists( 'mb_substr' ) ) {
+ if ( mb_strlen( $value, 'UTF-8' ) <= $limit ) {
+ return $value;
+ }
+
+ return mb_substr( $value, 0, $limit, 'UTF-8' ) . '...';
+ }
+
+ if ( strlen( $value ) <= $limit ) {
+ return $value;
+ }
+
+ return substr( $value, 0, $limit ) . '...';
+ }
+
+ /**
+ * Determines the high-level request kind.
+ *
+ * @param string|null $provider Provider identifier.
+ * @param string $path Request path.
+ * @param array|null $payload Request payload.
+ * @return string
+ */
+ private function detect_request_kind( ?string $provider, string $path, ?array $payload ): string {
+ $path_lower = strtolower( $path );
+
+ if ( 'fal' === $provider ) {
+ return 'image';
+ }
+
+ if ( str_contains( $path_lower, 'embeddings' ) ) {
+ return 'embeddings';
+ }
+
+ if (
+ str_contains( $path_lower, '/images' ) ||
+ str_contains( $path_lower, 'imagegeneration' ) ||
+ str_contains( $path_lower, 'image-generation' )
+ ) {
+ return 'image';
+ }
+
+ if ( isset( $payload['response_format']['type'] ) && 'json_schema' === $payload['response_format']['type'] ) {
+ return 'text';
+ }
+
+ return 'text';
+ }
+
+ /**
+ * Extracts image/media metadata from a response payload.
+ *
+ * @param array $payload Response data.
+ * @return array
+ */
+ private function extract_media_context( array $payload ): array {
+ $context = array();
+ $image_urls = array();
+ $base64_data = array();
+
+ if ( isset( $payload['images'] ) && is_array( $payload['images'] ) ) {
+ foreach ( $payload['images'] as $image ) {
+ if ( ! is_array( $image ) ) {
+ continue;
+ }
+
+ if ( isset( $image['url'] ) && is_string( $image['url'] ) ) {
+ $image_urls[] = $image['url'];
+ }
+
+ $encoded = $image['b64_json'] ?? ( $image['image_base64'] ?? null );
+ if ( ! is_string( $encoded ) || '' === $encoded ) {
+ continue;
+ }
+
+ $base64_data[] = array(
+ 'mime' => $image['content_type'] ?? 'image/png',
+ 'data' => $encoded,
+ );
+ }
+ }
+
+ if ( isset( $payload['data'] ) && is_array( $payload['data'] ) ) {
+ foreach ( $payload['data'] as $entry ) {
+ if ( ! is_array( $entry ) ) {
+ continue;
+ }
+
+ if ( isset( $entry['url'] ) && is_string( $entry['url'] ) ) {
+ $image_urls[] = $entry['url'];
+ }
+
+ if ( ! isset( $entry['b64_json'] ) || ! is_string( $entry['b64_json'] ) ) {
+ continue;
+ }
+
+ $base64_data[] = array(
+ 'mime' => $entry['mime'] ?? 'image/png',
+ 'data' => $entry['b64_json'],
+ );
+ }
+ }
+
+ if ( $image_urls ) {
+ $context['image_urls'] = array_slice( $image_urls, 0, self::MAX_IMAGE_SAMPLES );
+ $context['output_preview'] = $context['output_preview'] ?? sprintf(
+ 'Generated %d image(s).',
+ count( $image_urls )
+ );
+ }
+
+ if ( $base64_data ) {
+ $context['image_base64_samples'] = array_slice( $base64_data, 0, self::MAX_IMAGE_SAMPLES );
+ if ( empty( $context['output_preview'] ) ) {
+ $context['output_preview'] = sprintf(
+ 'Generated %d image(s).',
+ count( $base64_data )
+ );
+ }
+ }
+
+ return $context;
+ }
+}
diff --git a/includes/Logging/REST/AI_Request_Log_Controller.php b/includes/Logging/REST/AI_Request_Log_Controller.php
new file mode 100644
index 00000000..8545aa25
--- /dev/null
+++ b/includes/Logging/REST/AI_Request_Log_Controller.php
@@ -0,0 +1,460 @@
+namespace = 'ai/v1';
+ $this->rest_base = 'logs';
+ $this->manager = $manager;
+ }
+
+ /**
+ * Registers REST routes.
+ */
+ public function register_routes(): void {
+ // GET /ai/v1/logs - List logs with filtering.
+ // POST /ai/v1/logs - Update settings (enabled, retention).
+ // DELETE /ai/v1/logs - Purge all logs.
+ register_rest_route(
+ $this->namespace,
+ '/' . $this->rest_base,
+ array(
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_logs' ),
+ 'permission_callback' => array( $this, 'permissions_check' ),
+ 'args' => $this->get_collection_params(),
+ ),
+ array(
+ 'methods' => WP_REST_Server::EDITABLE,
+ 'callback' => array( $this, 'update_settings' ),
+ 'permission_callback' => array( $this, 'permissions_check' ),
+ 'args' => array(
+ 'enabled' => array(
+ 'type' => 'boolean',
+ 'required' => false,
+ ),
+ 'retention_days' => array(
+ 'type' => 'integer',
+ 'required' => false,
+ 'minimum' => 1,
+ 'maximum' => 365,
+ ),
+ 'max_rows' => array(
+ 'type' => 'integer',
+ 'required' => false,
+ 'minimum' => 1000,
+ 'maximum' => 10000000,
+ ),
+ ),
+ ),
+ array(
+ 'methods' => WP_REST_Server::DELETABLE,
+ 'callback' => array( $this, 'purge_logs' ),
+ 'permission_callback' => array( $this, 'permissions_check' ),
+ ),
+ )
+ );
+
+ // GET /ai/v1/logs/summary - Get aggregate statistics.
+ register_rest_route(
+ $this->namespace,
+ '/' . $this->rest_base . '/summary',
+ array(
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_summary' ),
+ 'permission_callback' => array( $this, 'permissions_check' ),
+ 'args' => array(
+ 'period' => array(
+ 'type' => 'string',
+ 'enum' => array( 'day', 'week', 'month', 'all' ),
+ 'default' => 'day',
+ ),
+ ),
+ ),
+ )
+ );
+
+ // GET /ai/v1/logs/filters - Get filter options.
+ register_rest_route(
+ $this->namespace,
+ '/' . $this->rest_base . '/filters',
+ array(
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_filters' ),
+ 'permission_callback' => array( $this, 'permissions_check' ),
+ ),
+ )
+ );
+
+ // GET /ai/v1/logs/stats - Get table statistics.
+ register_rest_route(
+ $this->namespace,
+ '/' . $this->rest_base . '/stats',
+ array(
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_stats' ),
+ 'permission_callback' => array( $this, 'permissions_check' ),
+ ),
+ )
+ );
+
+ // GET /ai/v1/logs/{id} - Get single log entry.
+ register_rest_route(
+ $this->namespace,
+ '/' . $this->rest_base . '/(?P[a-f0-9\-]+)',
+ array(
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_log' ),
+ 'permission_callback' => array( $this, 'permissions_check' ),
+ 'args' => array(
+ 'id' => array(
+ 'type' => 'string',
+ 'required' => true,
+ 'validate_callback' => array( $this, 'validate_uuid' ),
+ ),
+ ),
+ ),
+ )
+ );
+ }
+
+ /**
+ * Permission check - restricted to administrators.
+ */
+ public function permissions_check(): bool {
+ return current_user_can( 'manage_options' );
+ }
+
+ /**
+ * Validates a UUID format.
+ *
+ * @param string $value The value to validate.
+ * @return bool Whether the value is a valid UUID.
+ */
+ public function validate_uuid( string $value ): bool {
+ return (bool) preg_match( '/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i', $value );
+ }
+
+ /**
+ * Retrieves logs with filtering and pagination.
+ *
+ * @param \WP_REST_Request $request Request.
+ * @return \WP_REST_Response
+ */
+ public function get_logs( WP_REST_Request $request ): WP_REST_Response {
+ $args = array(
+ 'type' => $request->get_param( 'type' ) ?? '',
+ 'status' => $request->get_param( 'status' ) ?? '',
+ 'provider' => $request->get_param( 'provider' ) ?? '',
+ 'operation' => $request->get_param( 'operation' ) ?? '',
+ 'operation_pattern' => $request->get_param( 'operation_pattern' ) ?? '',
+ 'tokens_gt' => $request->get_param( 'tokens_gt' ),
+ 'tokens_lt' => $request->get_param( 'tokens_lt' ),
+ 'tokens_filter' => $request->get_param( 'tokens_filter' ) ?? '',
+ 'user_id' => $request->get_param( 'user_id' ) ?? 0,
+ 'date_from' => $request->get_param( 'date_from' ) ?? '',
+ 'date_to' => $request->get_param( 'date_to' ) ?? '',
+ 'search' => $request->get_param( 'search' ) ?? '',
+ 'page' => $request->get_param( 'page' ) ?? 1,
+ 'per_page' => $request->get_param( 'per_page' ) ?? 25,
+ 'orderby' => $request->get_param( 'orderby' ) ?? 'timestamp',
+ 'order' => $request->get_param( 'order' ) ?? 'DESC',
+ 'cursor_id' => $request->get_param( 'cursor_id' ),
+ 'cursor_timestamp' => $request->get_param( 'cursor_timestamp' ),
+ );
+
+ $result = $this->manager->get_logs( $args );
+
+ $response = rest_ensure_response( $result['items'] );
+ $response->header( 'X-WP-Total', (string) $result['total'] );
+ $response->header( 'X-WP-TotalPages', (string) $result['pages'] );
+
+ // Include cursor info for cursor-based pagination.
+ if ( isset( $result['next_cursor'] ) ) {
+ $response->header( 'X-WP-NextCursorId', (string) $result['next_cursor']['id'] );
+ $response->header( 'X-WP-NextCursorTimestamp', $result['next_cursor']['timestamp'] );
+ }
+
+ return $response;
+ }
+
+ /**
+ * Retrieves a single log entry.
+ *
+ * @param \WP_REST_Request $request Request.
+ * @return \WP_REST_Response|\WP_Error
+ */
+ public function get_log( WP_REST_Request $request ) {
+ $log_id = $request->get_param( 'id' );
+ $log = $this->manager->get_log( $log_id );
+
+ if ( ! $log ) {
+ return new WP_Error(
+ 'ai_log_not_found',
+ __( 'Log entry not found.', 'ai' ),
+ array( 'status' => 404 )
+ );
+ }
+
+ return rest_ensure_response( $log );
+ }
+
+ /**
+ * Retrieves aggregate statistics.
+ *
+ * @param \WP_REST_Request $request Request.
+ * @return \WP_REST_Response
+ */
+ public function get_summary( WP_REST_Request $request ): WP_REST_Response {
+ $period = $request->get_param( 'period' ) ?? 'day';
+ $summary = $this->manager->get_summary( $period );
+
+ return rest_ensure_response( $summary );
+ }
+
+ /**
+ * Retrieves filter options.
+ *
+ * @param \WP_REST_Request $request Request.
+ * @return \WP_REST_Response
+ */
+ public function get_filters( WP_REST_Request $request ): WP_REST_Response {
+ $filters = $this->manager->get_filter_options();
+
+ return rest_ensure_response( $filters );
+ }
+
+ /**
+ * Retrieves table statistics.
+ *
+ * @param \WP_REST_Request $request Request.
+ * @return \WP_REST_Response
+ */
+ public function get_stats( WP_REST_Request $request ): WP_REST_Response {
+ $stats = $this->manager->get_table_stats();
+
+ // Add warning thresholds.
+ $stats['warnings'] = array();
+
+ // Warn if table is over 80% of max rows.
+ if ( $stats['row_count'] > $stats['max_rows'] * 0.8 ) {
+ $stats['warnings'][] = array(
+ 'type' => 'row_count',
+ 'level' => $stats['row_count'] >= $stats['max_rows'] ? 'error' : 'warning',
+ 'message' => $stats['row_count'] >= $stats['max_rows']
+ ? __( 'Log table has reached the maximum row limit. Oldest logs will be automatically deleted.', 'ai' )
+ : sprintf(
+ /* translators: %d: Percentage of max rows used. */
+ __( 'Log table is at %d%% capacity. Consider increasing the max rows limit or reducing retention.', 'ai' ),
+ (int) ( $stats['row_count'] / $stats['max_rows'] * 100 )
+ ),
+ );
+ }
+
+ // Warn if table size exceeds 100MB.
+ $size_warning_threshold = 100 * 1024 * 1024; // 100MB.
+ $size_error_threshold = 500 * 1024 * 1024; // 500MB.
+
+ if ( $stats['table_size_bytes'] > $size_warning_threshold ) {
+ $stats['warnings'][] = array(
+ 'type' => 'table_size',
+ 'level' => $stats['table_size_bytes'] >= $size_error_threshold ? 'error' : 'warning',
+ 'message' => sprintf(
+ /* translators: %s: Table size formatted. */
+ __( 'Log table size is %s. Consider reducing retention period or max rows to improve performance.', 'ai' ),
+ $stats['table_size_formatted']
+ ),
+ );
+ }
+
+ return rest_ensure_response( $stats );
+ }
+
+ /**
+ * Updates logging settings.
+ *
+ * @param \WP_REST_Request $request Request.
+ * @return \WP_REST_Response
+ */
+ public function update_settings( WP_REST_Request $request ): WP_REST_Response {
+ if ( $request->has_param( 'enabled' ) ) {
+ $this->manager->set_logging_enabled( (bool) $request->get_param( 'enabled' ) );
+ }
+
+ if ( $request->has_param( 'retention_days' ) ) {
+ $this->manager->set_retention_days( (int) $request->get_param( 'retention_days' ) );
+ }
+
+ if ( $request->has_param( 'max_rows' ) ) {
+ $this->manager->set_max_rows( (int) $request->get_param( 'max_rows' ) );
+ }
+
+ return rest_ensure_response(
+ array(
+ 'enabled' => $this->manager->is_logging_enabled(),
+ 'retention_days' => $this->manager->get_retention_days(),
+ 'max_rows' => $this->manager->get_max_rows(),
+ )
+ );
+ }
+
+ /**
+ * Purges all logs.
+ *
+ * @param \WP_REST_Request $request Request.
+ * @return \WP_REST_Response
+ */
+ public function purge_logs( WP_REST_Request $request ): WP_REST_Response {
+ $deleted = $this->manager->purge_all_logs();
+
+ return rest_ensure_response(
+ array(
+ 'success' => true,
+ 'deleted' => $deleted,
+ 'message' => sprintf(
+ /* translators: %d: Number of deleted logs. */
+ __( 'Successfully purged %d log entries.', 'ai' ),
+ $deleted
+ ),
+ )
+ );
+ }
+
+ /**
+ * Gets collection parameters for logs list endpoint.
+ *
+ * @return array> Parameter definitions.
+ */
+ public function get_collection_params(): array {
+ return array(
+ 'type' => array(
+ 'description' => __( 'Filter by log type.', 'ai' ),
+ 'type' => 'string',
+ 'enum' => array( '', 'ai_client', 'mcp_tool', 'ability' ),
+ 'default' => '',
+ ),
+ 'status' => array(
+ 'description' => __( 'Filter by status.', 'ai' ),
+ 'type' => 'string',
+ 'enum' => array( '', 'success', 'error', 'timeout' ),
+ 'default' => '',
+ ),
+ 'provider' => array(
+ 'description' => __( 'Filter by AI provider.', 'ai' ),
+ 'type' => 'string',
+ 'default' => '',
+ ),
+ 'user_id' => array(
+ 'description' => __( 'Filter by user ID.', 'ai' ),
+ 'type' => 'integer',
+ 'default' => 0,
+ ),
+ 'date_from' => array(
+ 'description' => __( 'Filter logs from this date (YYYY-MM-DD HH:MM:SS).', 'ai' ),
+ 'type' => 'string',
+ 'format' => 'date-time',
+ ),
+ 'date_to' => array(
+ 'description' => __( 'Filter logs until this date (YYYY-MM-DD HH:MM:SS).', 'ai' ),
+ 'type' => 'string',
+ 'format' => 'date-time',
+ ),
+ 'search' => array(
+ 'description' => __( 'Search in operations, request previews, response previews, and error messages.', 'ai' ),
+ 'type' => 'string',
+ 'default' => '',
+ ),
+ 'operation_pattern' => array(
+ 'description' => __( 'Regex pattern to filter operations (e.g., ":completions$" for completion requests only).', 'ai' ),
+ 'type' => 'string',
+ 'default' => '',
+ ),
+ 'tokens_gt' => array(
+ 'description' => __( 'Filter logs with tokens greater than this value.', 'ai' ),
+ 'type' => 'integer',
+ 'minimum' => 0,
+ ),
+ 'tokens_lt' => array(
+ 'description' => __( 'Filter logs with tokens less than this value.', 'ai' ),
+ 'type' => 'integer',
+ 'minimum' => 0,
+ ),
+ 'tokens_filter' => array(
+ 'description' => __( 'Filter by tokens: "gt:N", "lt:N", or "none".', 'ai' ),
+ 'type' => 'string',
+ 'default' => '',
+ ),
+ 'page' => array(
+ 'description' => __( 'Current page of the collection.', 'ai' ),
+ 'type' => 'integer',
+ 'default' => 1,
+ 'minimum' => 1,
+ ),
+ 'per_page' => array(
+ 'description' => __( 'Maximum number of items per page.', 'ai' ),
+ 'type' => 'integer',
+ 'default' => 25,
+ 'minimum' => 1,
+ 'maximum' => 100,
+ ),
+ 'orderby' => array(
+ 'description' => __( 'Sort collection by attribute.', 'ai' ),
+ 'type' => 'string',
+ 'enum' => array( 'timestamp', 'type', 'operation', 'duration_ms', 'tokens_total', 'cost_estimate', 'status' ),
+ 'default' => 'timestamp',
+ ),
+ 'order' => array(
+ 'description' => __( 'Order sort attribute ascending or descending.', 'ai' ),
+ 'type' => 'string',
+ 'enum' => array( 'ASC', 'DESC' ),
+ 'default' => 'DESC',
+ ),
+ 'cursor_id' => array(
+ 'description' => __( 'Cursor ID for cursor-based pagination (use with cursor_timestamp).', 'ai' ),
+ 'type' => 'integer',
+ 'minimum' => 1,
+ ),
+ 'cursor_timestamp' => array(
+ 'description' => __( 'Cursor timestamp for cursor-based pagination (use with cursor_id).', 'ai' ),
+ 'type' => 'string',
+ 'format' => 'date-time',
+ ),
+ );
+ }
+}
diff --git a/includes/helpers.php b/includes/helpers.php
index f902c1db..e9a146d7 100644
--- a/includes/helpers.php
+++ b/includes/helpers.php
@@ -260,3 +260,20 @@ function has_valid_ai_credentials(): bool {
return false;
}
}
+
+/**
+ * Returns the request log manager singleton instance.
+ *
+ * @since 0.1.0
+ *
+ * @return \WordPress\AI\Logging\AI_Request_Log_Manager|null The log manager instance, or null if not available.
+ */
+function get_request_log_manager(): ?Logging\AI_Request_Log_Manager {
+ static $log_manager = null;
+
+ if ( null === $log_manager && class_exists( Logging\AI_Request_Log_Manager::class ) ) {
+ $log_manager = new Logging\AI_Request_Log_Manager();
+ }
+
+ return $log_manager;
+}
diff --git a/src/admin/_common.scss b/src/admin/_common.scss
new file mode 100644
index 00000000..1094ab4d
--- /dev/null
+++ b/src/admin/_common.scss
@@ -0,0 +1,150 @@
+/**
+ * Common Admin Page Styles
+ *
+ * Shared styles for AI admin pages including page headers with icons.
+ *
+ * @package WordPress\AI
+ */
+
+/* Remove default .wrap top margin/padding for AI pages */
+.wrap.ai-mcp-server,
+.wrap.ai-request-logs,
+.wrap.ai-experiments-page {
+ margin-top: 0;
+ padding-top: 0;
+}
+
+/* Force AI admin screens to use white page backgrounds */
+$ai-admin-white-pages: (
+ 'settings_page_ai-request-logs',
+ 'settings_page_wp-ai-client',
+ 'settings_page_ai-experiments',
+ 'toplevel_page_ai-mcp'
+);
+
+@each $page-class in $ai-admin-white-pages {
+ body.#{$page-class},
+ body.#{$page-class} #wpwrap,
+ body.#{$page-class} #wpcontent,
+ body.#{$page-class} #wpbody,
+ body.#{$page-class} #wpbody-content {
+ background-color: #fff;
+ }
+}
+
+/* Full-width page header (privacy-style) */
+.ai-admin-header {
+ background: #fff;
+ border-bottom: 1px solid #dcdcde;
+ margin: 0 0 1.5rem;
+ padding: 16px 20px;
+
+ // Extend to full width by pulling out of .wrap padding
+ .wrap > & {
+ margin-left: -20px;
+ margin-right: -20px;
+
+ @media screen and (max-width: 782px) {
+ margin-left: -10px;
+ margin-right: -10px;
+ padding-left: 10px;
+ padding-right: 10px;
+ }
+ }
+}
+
+/* Card border-radius standardization (4px) */
+.ai-mcp-server,
+.ai-request-logs {
+ .components-card {
+ border-radius: 4px;
+ }
+}
+
+.ai-admin-header__inner {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.75rem;
+ max-width: 1400px;
+}
+
+.ai-admin-header__left {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+}
+
+.ai-admin-header__right {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+
+ // Header toggle needs proper alignment
+ .components-toggle-control {
+ margin: 0;
+
+ .components-base-control__field {
+ margin-bottom: 0;
+ }
+ }
+}
+
+.ai-admin-header__icon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ width: 36px;
+ height: 36px;
+ padding: 6px;
+ background: #f6f7f7;
+ border-radius: 8px;
+ color: #1d2327;
+
+ svg {
+ width: 100%;
+ height: 100%;
+ }
+}
+
+.ai-admin-header__title {
+ h1 {
+ margin: 0;
+ padding: 0;
+ font-size: 23px;
+ font-weight: 600;
+ line-height: 1.3;
+ }
+}
+
+/* Legacy page header with icon (for inline use) */
+.ai-page-header {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ margin-bottom: 1.5rem;
+
+ h1 {
+ margin: 0;
+ padding: 0;
+ }
+}
+
+.ai-page-header__icon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ width: 36px;
+ height: 36px;
+ padding: 6px;
+ background: #fff;
+ border-radius: 8px;
+ color: #1d2327;
+
+ svg {
+ width: 100%;
+ height: 100%;
+ }
+}
diff --git a/src/admin/_dataviews.scss b/src/admin/_dataviews.scss
new file mode 100644
index 00000000..2f295bbe
--- /dev/null
+++ b/src/admin/_dataviews.scss
@@ -0,0 +1,26 @@
+// DataViews base styles (from @wordpress/dataviews package)
+// Copied from node_modules/@wordpress/dataviews/build-style/style.css
+@import './_dataviews-base.css';
+
+// Project-specific overrides
+
+// Reduce default padding on edges (default is 24px)
+.components-card__body:has(> .dataviews-wrapper) {
+ .dataviews__view-actions,
+ .dataviews-filters__container,
+ .dataviews-footer,
+ .dataviews-loading,
+ .dataviews-no-results {
+ padding-inline: 16px;
+ }
+
+ .dataviews-view-table tr th:first-child,
+ .dataviews-view-table tr td:first-child {
+ padding-inline-start: 16px;
+ }
+
+ .dataviews-view-table tr th:last-child,
+ .dataviews-view-table tr td:last-child {
+ padding-inline-end: 16px;
+ }
+}
diff --git a/src/admin/ai-request-logs/_dataviews-base.css b/src/admin/ai-request-logs/_dataviews-base.css
new file mode 100644
index 00000000..404347e1
--- /dev/null
+++ b/src/admin/ai-request-logs/_dataviews-base.css
@@ -0,0 +1,1847 @@
+/**
+ * Colors
+ */
+/**
+ * SCSS Variables.
+ *
+ * Please use variables from this sheet to ensure consistency across the UI.
+ * Don't add to this sheet unless you're pretty sure the value will be reused in many places.
+ * For example, don't add rules to this sheet that affect block visuals. It's purely for UI.
+ */
+/**
+ * Fonts & basic variables.
+ */
+/**
+ * Typography
+ */
+/**
+ * Grid System.
+ * https://make.wordpress.org/design/2019/10/31/proposal-a-consistent-spacing-system-for-wordpress/
+ */
+/**
+ * Radius scale.
+ */
+/**
+ * Elevation scale.
+ */
+/**
+ * Dimensions.
+ */
+/**
+ * Mobile specific styles
+ */
+/**
+ * Editor styles.
+ */
+/**
+ * Block & Editor UI.
+ */
+/**
+ * Block paddings.
+ */
+/**
+ * React Native specific.
+ * These variables do not appear to be used anywhere else.
+ */
+/**
+ * Typography
+ */
+/**
+ * Breakpoints & Media Queries
+ */
+/**
+* Converts a hex value into the rgb equivalent.
+*
+* @param {string} hex - the hexadecimal value to convert
+* @return {string} comma separated rgb values
+*/
+/**
+ * Long content fade mixin
+ *
+ * Creates a fading overlay to signify that the content is longer
+ * than the space allows.
+ */
+/**
+ * Breakpoint mixins
+ */
+/**
+ * Focus styles.
+ */
+/**
+ * Applies editor left position to the selector passed as argument
+ */
+/**
+ * Styles that are reused verbatim in a few places
+ */
+/**
+ * Allows users to opt-out of animations via OS-level preferences.
+ */
+/**
+ * Reset default styles for JavaScript UI based pages.
+ * This is a WP-admin agnostic reset
+ */
+/**
+ * Reset the WP Admin page styles for Gutenberg-like pages.
+ */
+.dataviews-wrapper,
+.dataviews-picker-wrapper {
+ height: 100%;
+ overflow: auto;
+ box-sizing: border-box;
+ scroll-padding-bottom: 64px;
+ /* stylelint-disable-next-line property-no-unknown -- '@container' not globally permitted */
+ container: dataviews-wrapper/inline-size;
+ display: flex;
+ flex-direction: column;
+ font-size: 13px;
+ line-height: 1.4;
+}
+
+.dataviews__view-actions,
+.dataviews-filters__container {
+ box-sizing: border-box;
+ padding: 16px 48px;
+ flex-shrink: 0;
+ position: sticky;
+ left: 0;
+}
+@media not (prefers-reduced-motion) {
+ .dataviews__view-actions,
+ .dataviews-filters__container {
+ transition: padding ease-out 0.1s;
+ }
+}
+
+.dataviews-no-results,
+.dataviews-loading {
+ padding: 0 48px;
+ flex-grow: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+@media not (prefers-reduced-motion) {
+ .dataviews-no-results,
+ .dataviews-loading {
+ transition: padding ease-out 0.1s;
+ }
+}
+
+.dataviews-loading-more {
+ text-align: center;
+}
+
+@container (max-width: 430px) {
+ .dataviews__view-actions,
+ .dataviews-filters__container {
+ padding: 12px 24px;
+ }
+ .dataviews-no-results,
+ .dataviews-loading {
+ padding-left: 24px;
+ padding-right: 24px;
+ }
+}
+.dataviews-title-field {
+ font-size: 13px;
+ font-weight: 499;
+ color: #2f2f2f;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ width: 100%;
+}
+.dataviews-title-field a {
+ text-decoration: none;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ overflow: hidden;
+ display: block;
+ flex-grow: 0;
+ color: #2f2f2f;
+}
+.dataviews-title-field a:hover {
+ color: var(--wp-admin-theme-color);
+}
+.dataviews-title-field a:focus {
+ color: var(--wp-admin-theme-color--rgb);
+ box-shadow: 0 0 0 var(--wp-admin-border-width-focus) var(--wp-admin-theme-color, #007cba);
+ border-radius: 2px;
+}
+.dataviews-title-field button.components-button.is-link {
+ text-decoration: none;
+ font-weight: inherit;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ overflow: hidden;
+ display: block;
+ width: 100%;
+ color: #1e1e1e;
+}
+.dataviews-title-field button.components-button.is-link:hover {
+ color: var(--wp-admin-theme-color);
+}
+
+.dataviews-title-field--clickable {
+ cursor: pointer;
+ color: #2f2f2f;
+}
+.dataviews-title-field--clickable:hover {
+ color: var(--wp-admin-theme-color);
+}
+.dataviews-title-field--clickable:focus {
+ color: var(--wp-admin-theme-color--rgb);
+ box-shadow: 0 0 0 var(--wp-admin-border-width-focus) var(--wp-admin-theme-color, #007cba);
+ border-radius: 2px;
+}
+
+/**
+ * Applying a consistent 24px padding when DataViews are placed within cards.
+ */
+.components-card__body:has(> .dataviews-wrapper),
+.components-card__body:has(> .dataviews-picker-wrapper) {
+ padding: 8px 0 0;
+ overflow: hidden;
+}
+.components-card__body:has(> .dataviews-wrapper) .dataviews__view-actions,
+.components-card__body:has(> .dataviews-wrapper) .dataviews-filters__container,
+.components-card__body:has(> .dataviews-wrapper) .dataviews-footer,
+.components-card__body:has(> .dataviews-wrapper) .dataviews-view-grid,
+.components-card__body:has(> .dataviews-wrapper) .dataviews-loading,
+.components-card__body:has(> .dataviews-wrapper) .dataviews-no-results,
+.components-card__body:has(> .dataviews-picker-wrapper) .dataviews__view-actions,
+.components-card__body:has(> .dataviews-picker-wrapper) .dataviews-filters__container,
+.components-card__body:has(> .dataviews-picker-wrapper) .dataviews-footer,
+.components-card__body:has(> .dataviews-picker-wrapper) .dataviews-view-grid,
+.components-card__body:has(> .dataviews-picker-wrapper) .dataviews-loading,
+.components-card__body:has(> .dataviews-picker-wrapper) .dataviews-no-results {
+ padding-inline: 24px;
+}
+.components-card__body:has(> .dataviews-wrapper) .dataviews-view-table tr td:first-child,
+.components-card__body:has(> .dataviews-wrapper) .dataviews-view-table tr th:first-child,
+.components-card__body:has(> .dataviews-picker-wrapper) .dataviews-view-table tr td:first-child,
+.components-card__body:has(> .dataviews-picker-wrapper) .dataviews-view-table tr th:first-child {
+ padding-inline-start: 24px;
+}
+.components-card__body:has(> .dataviews-wrapper) .dataviews-view-table tr td:last-child,
+.components-card__body:has(> .dataviews-wrapper) .dataviews-view-table tr th:last-child,
+.components-card__body:has(> .dataviews-picker-wrapper) .dataviews-view-table tr td:last-child,
+.components-card__body:has(> .dataviews-picker-wrapper) .dataviews-view-table tr th:last-child {
+ padding-inline-end: 24px;
+}
+
+.dataviews-bulk-actions-footer__item-count {
+ color: #1e1e1e;
+ font-weight: 499;
+ font-size: 11px;
+ text-transform: uppercase;
+}
+
+.dataviews-bulk-actions-footer__container {
+ margin-right: auto;
+ min-height: 32px;
+}
+
+.dataviews-filters__button {
+ position: relative;
+}
+
+.dataviews-filters__container {
+ padding-top: 0;
+}
+
+.dataviews-filters__reset-button.dataviews-filters__reset-button[aria-disabled=true], .dataviews-filters__reset-button.dataviews-filters__reset-button[aria-disabled=true]:hover {
+ opacity: 0;
+}
+.dataviews-filters__reset-button.dataviews-filters__reset-button[aria-disabled=true]:focus {
+ opacity: 1;
+}
+
+.dataviews-filters__summary-popover {
+ font-size: 13px;
+ line-height: 1.4;
+}
+.dataviews-filters__summary-popover .components-popover__content {
+ width: 100%;
+ min-width: 230px;
+ max-width: 250px;
+ border-radius: 4px;
+}
+.dataviews-filters__summary-popover.components-dropdown__content .components-popover__content {
+ padding: 0;
+}
+
+.dataviews-filters__summary-operators-container {
+ padding: 8px 16px;
+}
+.dataviews-filters__summary-operators-container:has(+ .dataviews-filters__search-widget-listbox), .dataviews-filters__summary-operators-container:has(+ .dataviews-filters__search-widget-no-elements), .dataviews-filters__summary-operators-container:has(+ .dataviews-filters__user-input-widget) {
+ border-bottom: 1px solid #e0e0e0;
+}
+.dataviews-filters__summary-operators-container:empty {
+ display: none;
+}
+.dataviews-filters__summary-operators-container .dataviews-filters__summary-operators-filter-name {
+ color: #757575;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ flex-shrink: 0; /* Prevents this element from shrinking */
+ max-width: calc(100% - 55px);
+}
+.dataviews-filters__summary-operators-container .dataviews-filters__summary-operators-filter-select {
+ width: 100%;
+ white-space: nowrap;
+ overflow: hidden;
+}
+
+.dataviews-filters__summary-chip-container {
+ position: relative;
+ white-space: pre-wrap;
+}
+.dataviews-filters__summary-chip-container .dataviews-filters__summary-chip {
+ border-radius: 16px;
+ border: 1px solid transparent;
+ cursor: pointer;
+ padding: 4px 12px;
+ min-height: 32px;
+ background: #f0f0f0;
+ color: #2f2f2f;
+ position: relative;
+ display: flex;
+ align-items: center;
+ box-sizing: border-box;
+}
+.dataviews-filters__summary-chip-container .dataviews-filters__summary-chip.is-not-clickable {
+ cursor: default;
+}
+.dataviews-filters__summary-chip-container .dataviews-filters__summary-chip.has-reset {
+ padding-inline-end: 28px;
+}
+.dataviews-filters__summary-chip-container .dataviews-filters__summary-chip:hover:not(.dataviews-filters__summary-chip-container .dataviews-filters__summary-chip.is-not-clickable), .dataviews-filters__summary-chip-container .dataviews-filters__summary-chip:focus-visible, .dataviews-filters__summary-chip-container .dataviews-filters__summary-chip[aria-expanded=true] {
+ background: #e0e0e0;
+ color: #1e1e1e;
+}
+.dataviews-filters__summary-chip-container .dataviews-filters__summary-chip.has-values {
+ color: var(--wp-admin-theme-color);
+ background: rgba(var(--wp-admin-theme-color--rgb), 0.04);
+}
+.dataviews-filters__summary-chip-container .dataviews-filters__summary-chip.has-values:hover, .dataviews-filters__summary-chip-container .dataviews-filters__summary-chip.has-values[aria-expanded=true] {
+ background: rgba(var(--wp-admin-theme-color--rgb), 0.12);
+}
+.dataviews-filters__summary-chip-container .dataviews-filters__summary-chip:focus-visible {
+ outline: none;
+ box-shadow: 0 0 0 var(--wp-admin-border-width-focus) var(--wp-admin-theme-color);
+}
+.dataviews-filters__summary-chip-container .dataviews-filters__summary-chip .dataviews-filters-__summary-filter-text-name {
+ font-weight: 499;
+}
+.dataviews-filters__summary-chip-container .dataviews-filters__summary-chip-remove {
+ width: 24px;
+ height: 24px;
+ border-radius: 50%;
+ border: 0;
+ padding: 0;
+ position: absolute;
+ right: 4px;
+ top: 50%;
+ transform: translateY(-50%);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: transparent;
+ cursor: pointer;
+}
+.dataviews-filters__summary-chip-container .dataviews-filters__summary-chip-remove svg {
+ fill: #757575;
+}
+.dataviews-filters__summary-chip-container .dataviews-filters__summary-chip-remove:hover, .dataviews-filters__summary-chip-container .dataviews-filters__summary-chip-remove:focus {
+ background: #e0e0e0;
+}
+.dataviews-filters__summary-chip-container .dataviews-filters__summary-chip-remove:hover svg, .dataviews-filters__summary-chip-container .dataviews-filters__summary-chip-remove:focus svg {
+ fill: #1e1e1e;
+}
+.dataviews-filters__summary-chip-container .dataviews-filters__summary-chip-remove.has-values svg {
+ fill: var(--wp-admin-theme-color);
+}
+.dataviews-filters__summary-chip-container .dataviews-filters__summary-chip-remove.has-values:hover {
+ background: rgba(var(--wp-admin-theme-color--rgb), 0.08);
+}
+.dataviews-filters__summary-chip-container .dataviews-filters__summary-chip-remove:focus-visible {
+ outline: none;
+ box-shadow: 0 0 0 var(--wp-admin-border-width-focus) var(--wp-admin-theme-color);
+}
+
+.dataviews-filters__search-widget-filter-combobox-list {
+ max-height: 184px;
+ padding: 4px;
+ overflow: auto;
+ border-top: 1px solid #e0e0e0;
+}
+.dataviews-filters__search-widget-filter-combobox-list .dataviews-filters__search-widget-filter-combobox-item-value [data-user-value] {
+ font-weight: 600;
+}
+
+.dataviews-filters__search-widget-listbox {
+ padding: 4px;
+ overflow: auto;
+}
+
+.dataviews-filters__search-widget-listitem {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ border-radius: 2px;
+ box-sizing: border-box;
+ padding: 4px 12px;
+ cursor: default;
+ min-height: 32px;
+ font-family: -apple-system, "system-ui", "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
+ font-weight: 400;
+ font-size: 13px;
+ line-height: 20px;
+}
+.dataviews-filters__search-widget-listitem:last-child {
+ margin-block-end: 0;
+}
+.dataviews-filters__search-widget-listitem:hover, .dataviews-filters__search-widget-listitem[data-active-item], .dataviews-filters__search-widget-listitem:focus {
+ background-color: var(--wp-admin-theme-color);
+ color: #fff;
+}
+.dataviews-filters__search-widget-listitem:hover .dataviews-filters__search-widget-listitem-description, .dataviews-filters__search-widget-listitem[data-active-item] .dataviews-filters__search-widget-listitem-description, .dataviews-filters__search-widget-listitem:focus .dataviews-filters__search-widget-listitem-description {
+ color: #fff;
+}
+.dataviews-filters__search-widget-listitem:hover .dataviews-filters__search-widget-listitem-single-selection, .dataviews-filters__search-widget-listitem[data-active-item] .dataviews-filters__search-widget-listitem-single-selection, .dataviews-filters__search-widget-listitem:focus .dataviews-filters__search-widget-listitem-single-selection {
+ border-color: var(--wp-admin-theme-color-darker-20, #183ad6);
+ background: #fff;
+}
+.dataviews-filters__search-widget-listitem:hover .dataviews-filters__search-widget-listitem-single-selection.is-selected, .dataviews-filters__search-widget-listitem[data-active-item] .dataviews-filters__search-widget-listitem-single-selection.is-selected, .dataviews-filters__search-widget-listitem:focus .dataviews-filters__search-widget-listitem-single-selection.is-selected {
+ border-color: var(--wp-admin-theme-color-darker-20, #183ad6);
+ background: var(--wp-admin-theme-color-darker-20, #183ad6);
+}
+.dataviews-filters__search-widget-listitem:hover .dataviews-filters__search-widget-listitem-multi-selection, .dataviews-filters__search-widget-listitem[data-active-item] .dataviews-filters__search-widget-listitem-multi-selection, .dataviews-filters__search-widget-listitem:focus .dataviews-filters__search-widget-listitem-multi-selection {
+ border-color: var(--wp-admin-theme-color-darker-20, #183ad6);
+}
+.dataviews-filters__search-widget-listitem:hover .dataviews-filters__search-widget-listitem-multi-selection.is-selected, .dataviews-filters__search-widget-listitem[data-active-item] .dataviews-filters__search-widget-listitem-multi-selection.is-selected, .dataviews-filters__search-widget-listitem:focus .dataviews-filters__search-widget-listitem-multi-selection.is-selected {
+ border-color: var(--wp-admin-theme-color-darker-20, #183ad6);
+ background: var(--wp-admin-theme-color-darker-20, #183ad6);
+}
+.dataviews-filters__search-widget-listitem .dataviews-filters__search-widget-listitem-description {
+ display: block;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ font-size: 12px;
+ line-height: 16px;
+ color: #757575;
+}
+.dataviews-filters__search-widget-listitem .dataviews-filters__search-widget-listitem-single-selection {
+ border: 1px solid #1e1e1e;
+ margin-right: 12px;
+ transition: none;
+ border-radius: 50%;
+ width: 24px;
+ height: 24px;
+ min-width: 24px;
+ max-width: 24px;
+ position: relative;
+}
+@media not (prefers-reduced-motion) {
+ .dataviews-filters__search-widget-listitem .dataviews-filters__search-widget-listitem-single-selection {
+ transition: box-shadow 0.1s linear;
+ }
+}
+@media (min-width: 600px) {
+ .dataviews-filters__search-widget-listitem .dataviews-filters__search-widget-listitem-single-selection {
+ height: 16px;
+ width: 16px;
+ min-width: 16px;
+ max-width: 16px;
+ }
+}
+.dataviews-filters__search-widget-listitem .dataviews-filters__search-widget-listitem-single-selection:checked::before {
+ box-sizing: inherit;
+ width: 12px;
+ height: 12px;
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ margin: 0;
+ background-color: #fff;
+ border: 4px solid #fff;
+}
+@media (min-width: 600px) {
+ .dataviews-filters__search-widget-listitem .dataviews-filters__search-widget-listitem-single-selection:checked::before {
+ width: 8px;
+ height: 8px;
+ }
+}
+.dataviews-filters__search-widget-listitem .dataviews-filters__search-widget-listitem-single-selection:focus {
+ box-shadow: 0 0 0 2px #fff, 0 0 0 4px var(--wp-admin-theme-color);
+ outline: 2px solid transparent;
+}
+.dataviews-filters__search-widget-listitem .dataviews-filters__search-widget-listitem-single-selection:checked {
+ background: var(--wp-admin-theme-color);
+ border: none;
+}
+.dataviews-filters__search-widget-listitem .dataviews-filters__search-widget-listitem-single-selection {
+ margin: 0;
+ padding: 0;
+}
+.dataviews-filters__search-widget-listitem .dataviews-filters__search-widget-listitem-single-selection.is-selected {
+ background: var(--wp-admin-theme-color, #3858e9);
+ border-color: var(--wp-admin-theme-color, #3858e9);
+}
+.dataviews-filters__search-widget-listitem .dataviews-filters__search-widget-listitem-single-selection.is-selected::before {
+ content: "";
+ border-radius: 50%;
+ box-sizing: inherit;
+ width: 12px;
+ height: 12px;
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ margin: 0;
+ background-color: #fff;
+ border: 4px solid #fff;
+}
+@media (min-width: 600px) {
+ .dataviews-filters__search-widget-listitem .dataviews-filters__search-widget-listitem-single-selection.is-selected::before {
+ width: 8px;
+ height: 8px;
+ }
+}
+.dataviews-filters__search-widget-listitem .dataviews-filters__search-widget-listitem-multi-selection {
+ --checkbox-size: 24px;
+ border: 1px solid #1e1e1e;
+ margin-right: 12px;
+ transition: none;
+ border-radius: 2px;
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
+ padding: 6px 8px;
+ /* Fonts smaller than 16px causes mobile safari to zoom. */
+ font-size: 16px;
+ /* Override core line-height. To be reviewed. */
+ line-height: normal;
+ box-shadow: 0 0 0 transparent;
+ border-radius: 2px;
+ border: 1px solid #949494;
+}
+@media not (prefers-reduced-motion) {
+ .dataviews-filters__search-widget-listitem .dataviews-filters__search-widget-listitem-multi-selection {
+ transition: box-shadow 0.1s linear;
+ }
+}
+@media (min-width: 600px) {
+ .dataviews-filters__search-widget-listitem .dataviews-filters__search-widget-listitem-multi-selection {
+ font-size: 13px;
+ /* Override core line-height. To be reviewed. */
+ line-height: normal;
+ }
+}
+.dataviews-filters__search-widget-listitem .dataviews-filters__search-widget-listitem-multi-selection:focus {
+ border-color: var(--wp-admin-theme-color);
+ box-shadow: 0 0 0 0.5px var(--wp-admin-theme-color);
+ outline: 2px solid transparent;
+}
+.dataviews-filters__search-widget-listitem .dataviews-filters__search-widget-listitem-multi-selection::-webkit-input-placeholder {
+ color: rgba(30, 30, 30, 0.62);
+}
+.dataviews-filters__search-widget-listitem .dataviews-filters__search-widget-listitem-multi-selection::-moz-placeholder {
+ color: rgba(30, 30, 30, 0.62);
+}
+.dataviews-filters__search-widget-listitem .dataviews-filters__search-widget-listitem-multi-selection:-ms-input-placeholder {
+ color: rgba(30, 30, 30, 0.62);
+}
+.dataviews-filters__search-widget-listitem .dataviews-filters__search-widget-listitem-multi-selection:focus {
+ box-shadow: 0 0 0 2px #fff, 0 0 0 4px var(--wp-admin-theme-color);
+ outline: 2px solid transparent;
+}
+.dataviews-filters__search-widget-listitem .dataviews-filters__search-widget-listitem-multi-selection:checked {
+ background: var(--wp-admin-theme-color);
+ border-color: var(--wp-admin-theme-color);
+}
+.dataviews-filters__search-widget-listitem .dataviews-filters__search-widget-listitem-multi-selection:checked::-ms-check {
+ opacity: 0;
+}
+.dataviews-filters__search-widget-listitem .dataviews-filters__search-widget-listitem-multi-selection:checked::before, .dataviews-filters__search-widget-listitem .dataviews-filters__search-widget-listitem-multi-selection[aria-checked=mixed]::before {
+ margin: -3px -5px;
+ color: #fff;
+}
+@media (min-width: 782px) {
+ .dataviews-filters__search-widget-listitem .dataviews-filters__search-widget-listitem-multi-selection:checked::before, .dataviews-filters__search-widget-listitem .dataviews-filters__search-widget-listitem-multi-selection[aria-checked=mixed]::before {
+ margin: -4px 0 0 -5px;
+ }
+}
+.dataviews-filters__search-widget-listitem .dataviews-filters__search-widget-listitem-multi-selection[aria-checked=mixed] {
+ background: var(--wp-admin-theme-color);
+ border-color: var(--wp-admin-theme-color);
+}
+.dataviews-filters__search-widget-listitem .dataviews-filters__search-widget-listitem-multi-selection[aria-checked=mixed]::before {
+ content: "\f460";
+ float: left;
+ display: inline-block;
+ vertical-align: middle;
+ width: 16px;
+ /* stylelint-disable-next-line font-family-no-missing-generic-family-keyword -- dashicons don't need a generic family keyword. */
+ font: normal 30px/1 dashicons;
+ speak: none;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+@media (min-width: 782px) {
+ .dataviews-filters__search-widget-listitem .dataviews-filters__search-widget-listitem-multi-selection[aria-checked=mixed]::before {
+ float: none;
+ font-size: 21px;
+ }
+}
+.dataviews-filters__search-widget-listitem .dataviews-filters__search-widget-listitem-multi-selection[aria-disabled=true], .dataviews-filters__search-widget-listitem .dataviews-filters__search-widget-listitem-multi-selection:disabled {
+ background: #f0f0f0;
+ border-color: #ddd;
+ cursor: default;
+ opacity: 1;
+}
+.dataviews-filters__search-widget-listitem .dataviews-filters__search-widget-listitem-multi-selection {
+ position: relative;
+ background: #fff;
+ color: #1e1e1e;
+ margin: 0;
+ padding: 0;
+ width: var(--checkbox-size);
+ height: var(--checkbox-size);
+}
+@media (min-width: 600px) {
+ .dataviews-filters__search-widget-listitem .dataviews-filters__search-widget-listitem-multi-selection {
+ --checkbox-size: 16px;
+ }
+}
+@media not (prefers-reduced-motion) {
+ .dataviews-filters__search-widget-listitem .dataviews-filters__search-widget-listitem-multi-selection {
+ transition: 0.1s border-color ease-in-out;
+ }
+}
+.dataviews-filters__search-widget-listitem .dataviews-filters__search-widget-listitem-multi-selection.is-selected {
+ background: var(--wp-admin-theme-color, #3858e9);
+ border-color: var(--wp-admin-theme-color, #3858e9);
+}
+.dataviews-filters__search-widget-listitem .dataviews-filters__search-widget-listitem-multi-selection.is-selected svg {
+ --checkmark-size: var(--checkbox-size);
+ fill: #fff;
+ position: absolute;
+ left: 50%;
+ top: 50%;
+ transform: translate(-50%, -50%);
+ width: var(--checkmark-size);
+ height: var(--checkmark-size);
+}
+@media (min-width: 600px) {
+ .dataviews-filters__search-widget-listitem .dataviews-filters__search-widget-listitem-multi-selection.is-selected svg {
+ --checkmark-size: calc(var(--checkbox-size) + 4px);
+ }
+}
+
+.dataviews-filters__search-widget-filter-combobox__wrapper {
+ position: relative;
+ padding: 8px;
+}
+.dataviews-filters__search-widget-filter-combobox__wrapper .dataviews-filters__search-widget-filter-combobox__input {
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
+ padding: 6px 8px;
+ /* Fonts smaller than 16px causes mobile safari to zoom. */
+ font-size: 16px;
+ /* Override core line-height. To be reviewed. */
+ line-height: normal;
+ box-shadow: 0 0 0 transparent;
+ border-radius: 2px;
+ border: 1px solid #949494;
+}
+@media not (prefers-reduced-motion) {
+ .dataviews-filters__search-widget-filter-combobox__wrapper .dataviews-filters__search-widget-filter-combobox__input {
+ transition: box-shadow 0.1s linear;
+ }
+}
+@media (min-width: 600px) {
+ .dataviews-filters__search-widget-filter-combobox__wrapper .dataviews-filters__search-widget-filter-combobox__input {
+ font-size: 13px;
+ /* Override core line-height. To be reviewed. */
+ line-height: normal;
+ }
+}
+.dataviews-filters__search-widget-filter-combobox__wrapper .dataviews-filters__search-widget-filter-combobox__input:focus {
+ border-color: var(--wp-admin-theme-color);
+ box-shadow: 0 0 0 0.5px var(--wp-admin-theme-color);
+ outline: 2px solid transparent;
+}
+.dataviews-filters__search-widget-filter-combobox__wrapper .dataviews-filters__search-widget-filter-combobox__input::-webkit-input-placeholder {
+ color: rgba(30, 30, 30, 0.62);
+}
+.dataviews-filters__search-widget-filter-combobox__wrapper .dataviews-filters__search-widget-filter-combobox__input::-moz-placeholder {
+ color: rgba(30, 30, 30, 0.62);
+}
+.dataviews-filters__search-widget-filter-combobox__wrapper .dataviews-filters__search-widget-filter-combobox__input:-ms-input-placeholder {
+ color: rgba(30, 30, 30, 0.62);
+}
+.dataviews-filters__search-widget-filter-combobox__wrapper .dataviews-filters__search-widget-filter-combobox__input {
+ display: block;
+ padding: 0 8px 0 32px;
+ width: 100%;
+ height: 32px;
+ margin-left: 0;
+ margin-right: 0;
+ /* Fonts smaller than 16px causes mobile safari to zoom. */
+ font-size: 16px;
+}
+@media (min-width: 600px) {
+ .dataviews-filters__search-widget-filter-combobox__wrapper .dataviews-filters__search-widget-filter-combobox__input {
+ font-size: 13px;
+ }
+}
+.dataviews-filters__search-widget-filter-combobox__wrapper .dataviews-filters__search-widget-filter-combobox__input:focus {
+ background: #fff;
+ box-shadow: inset 0 0 0 var(--wp-admin-border-width-focus) var(--wp-admin-theme-color);
+}
+.dataviews-filters__search-widget-filter-combobox__wrapper .dataviews-filters__search-widget-filter-combobox__input::placeholder {
+ color: #757575;
+}
+.dataviews-filters__search-widget-filter-combobox__wrapper .dataviews-filters__search-widget-filter-combobox__input::-webkit-search-decoration, .dataviews-filters__search-widget-filter-combobox__wrapper .dataviews-filters__search-widget-filter-combobox__input::-webkit-search-cancel-button, .dataviews-filters__search-widget-filter-combobox__wrapper .dataviews-filters__search-widget-filter-combobox__input::-webkit-search-results-button, .dataviews-filters__search-widget-filter-combobox__wrapper .dataviews-filters__search-widget-filter-combobox__input::-webkit-search-results-decoration {
+ -webkit-appearance: none;
+}
+.dataviews-filters__search-widget-filter-combobox__wrapper .dataviews-filters__search-widget-filter-combobox__icon {
+ position: absolute;
+ inset-inline-start: 12px;
+ top: 0;
+ bottom: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 24px;
+}
+.dataviews-filters__search-widget-filter-combobox__wrapper .dataviews-filters__search-widget-filter-combobox__icon:dir(ltr) {
+ transform: scaleX(-1);
+}
+
+.dataviews-filters__container-visibility-toggle {
+ position: relative;
+ flex-shrink: 0;
+}
+
+.dataviews-filters-toggle__count {
+ position: absolute;
+ top: 0;
+ right: 0;
+ transform: translate(50%, -50%);
+ background: var(--wp-admin-theme-color, #3858e9);
+ height: 16px;
+ min-width: 16px;
+ line-height: 16px;
+ padding: 0 4px;
+ text-align: center;
+ border-radius: 8px;
+ font-size: 11px;
+ outline: var(--wp-admin-border-width-focus) solid #fff;
+ color: #fff;
+ box-sizing: border-box;
+}
+
+.dataviews-search {
+ width: fit-content;
+}
+
+.dataviews-filters__user-input-widget {
+ padding: 16px;
+}
+.dataviews-filters__user-input-widget .components-input-control__prefix {
+ padding-left: 8px;
+}
+
+.dataviews-filters__search-widget-no-elements {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 16px;
+}
+
+.dataviews-footer {
+ position: sticky;
+ bottom: 0;
+ left: 0;
+ background-color: #fff;
+ padding: 12px 48px;
+ border-top: 1px solid #f0f0f0;
+ flex-shrink: 0;
+}
+@media not (prefers-reduced-motion) {
+ .dataviews-footer {
+ transition: padding ease-out 0.1s;
+ }
+}
+.dataviews-footer {
+ z-index: 2;
+}
+
+@container (max-width: 430px) {
+ .dataviews-footer {
+ padding: 12px 24px;
+ }
+}
+@container (max-width: 560px) {
+ .dataviews-footer {
+ flex-direction: column !important;
+ }
+ .dataviews-footer .dataviews-bulk-actions-footer__container {
+ width: 100%;
+ }
+ .dataviews-footer .dataviews-bulk-actions-footer__item-count {
+ flex-grow: 1;
+ }
+ .dataviews-footer .dataviews-pagination {
+ width: 100%;
+ justify-content: space-between;
+ }
+}
+.dataviews-pagination__page-select {
+ font-size: 11px;
+ font-weight: 499;
+ text-transform: uppercase;
+}
+@media (min-width: 600px) {
+ .dataviews-pagination__page-select .components-select-control__input {
+ font-size: 11px !important;
+ font-weight: 499;
+ }
+}
+
+.dataviews-action-modal {
+ z-index: 1000001;
+}
+
+.dataviews-picker-footer__bulk-selection {
+ align-self: flex-start;
+ height: 32px;
+}
+
+.dataviews-picker-footer__actions {
+ align-self: flex-end;
+}
+
+.dataviews-selection-checkbox {
+ --checkbox-input-size: 24px;
+}
+@media (min-width: 600px) {
+ .dataviews-selection-checkbox {
+ --checkbox-input-size: 16px;
+ }
+}
+.dataviews-selection-checkbox {
+ line-height: 0;
+ flex-shrink: 0;
+}
+.dataviews-selection-checkbox .components-checkbox-control__input-container {
+ margin: 0;
+}
+
+.dataviews-view-config {
+ width: 320px;
+ /* stylelint-disable-next-line property-no-unknown -- the linter needs to be updated to accepted the container-type property */
+ container-type: inline-size;
+ font-size: 13px;
+ line-height: 1.4;
+}
+
+.dataviews-config__popover.is-expanded .dataviews-config__popover-content-wrapper {
+ overflow-y: scroll;
+ height: 100%;
+}
+.dataviews-config__popover.is-expanded .dataviews-config__popover-content-wrapper .dataviews-view-config {
+ width: auto;
+}
+
+.dataviews-view-config__sort-direction .components-toggle-group-control-option-base {
+ text-transform: uppercase;
+}
+
+.dataviews-settings-section__title.dataviews-settings-section__title {
+ line-height: 24px;
+ font-size: 15px;
+}
+
+.dataviews-settings-section__sidebar {
+ grid-column: span 4;
+}
+
+.dataviews-settings-section__content,
+.dataviews-settings-section__content > * {
+ grid-column: span 8;
+}
+
+.dataviews-settings-section__content .is-divided-in-two {
+ display: contents;
+}
+.dataviews-settings-section__content .is-divided-in-two > * {
+ grid-column: span 4;
+}
+
+.dataviews-settings-section:has(.dataviews-settings-section__content:empty) {
+ display: none;
+}
+
+@container (max-width: 500px) {
+ .dataviews-settings-section.dataviews-settings-section {
+ grid-template-columns: repeat(2, 1fr);
+ }
+ .dataviews-settings-section.dataviews-settings-section .dataviews-settings-section__sidebar {
+ grid-column: span 2;
+ }
+ .dataviews-settings-section.dataviews-settings-section .dataviews-settings-section__content {
+ grid-column: span 2;
+ }
+}
+.dataviews-view-config__label {
+ text-wrap: nowrap;
+}
+
+.dataviews-view-grid-items {
+ margin-bottom: auto;
+ display: grid;
+ gap: 32px;
+ grid-template-rows: max-content;
+ grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
+ padding: 0 48px 24px;
+ container-type: inline-size;
+ /**
+ * Breakpoints were adjusted from media queries breakpoints to account for
+ * the sidebar width. This was done to match the existing styles we had.
+ */
+}
+@container (max-width: 430px) {
+ .dataviews-view-grid-items {
+ padding-left: 24px;
+ padding-right: 24px;
+ }
+}
+@media not (prefers-reduced-motion) {
+ .dataviews-view-grid-items {
+ transition: padding ease-out 0.1s;
+ }
+}
+
+.dataviews-view-grid .dataviews-view-grid__card {
+ height: 100%;
+ justify-content: flex-start;
+ position: relative;
+}
+.dataviews-view-grid .dataviews-view-grid__card .dataviews-view-grid__title-actions {
+ padding: 8px 0 4px;
+}
+.dataviews-view-grid .dataviews-view-grid__card .dataviews-view-grid__title-field {
+ min-height: 24px;
+ overflow: hidden;
+ align-content: center;
+ text-align: start;
+}
+.dataviews-view-grid .dataviews-view-grid__card .dataviews-view-grid__title-field--clickable {
+ width: fit-content;
+}
+.dataviews-view-grid .dataviews-view-grid__card.is-selected .dataviews-view-grid__fields .dataviews-view-grid__field .dataviews-view-grid__field-value {
+ color: #1e1e1e;
+}
+.dataviews-view-grid .dataviews-view-grid__card.is-selected .dataviews-view-grid__media::after,
+.dataviews-view-grid .dataviews-view-grid__card .dataviews-view-grid__media:focus::after {
+ background-color: rgba(var(--wp-admin-theme-color--rgb), 0.08);
+}
+.dataviews-view-grid .dataviews-view-grid__card.is-selected .dataviews-view-grid__media::after {
+ box-shadow: inset 0 0 0 1px var(--wp-admin-theme-color);
+}
+.dataviews-view-grid .dataviews-view-grid__card .dataviews-view-grid__media:focus::after {
+ box-shadow: inset 0 0 0 var(--wp-admin-border-width-focus) var(--wp-admin-theme-color);
+}
+.dataviews-view-grid .dataviews-view-grid__media {
+ width: 100%;
+ aspect-ratio: 1/1;
+ background-color: #fff;
+ border-radius: 4px;
+ overflow: hidden;
+ position: relative;
+}
+.dataviews-view-grid .dataviews-view-grid__media img {
+ object-fit: cover;
+ width: 100%;
+ height: 100%;
+}
+.dataviews-view-grid .dataviews-view-grid__media::after {
+ content: "";
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.1);
+ border-radius: 4px;
+ pointer-events: none;
+}
+.dataviews-view-grid .dataviews-view-grid__fields {
+ position: relative;
+ font-size: 12px;
+ line-height: 16px;
+}
+.dataviews-view-grid .dataviews-view-grid__fields:not(:empty) {
+ padding: 0 0 12px;
+}
+.dataviews-view-grid .dataviews-view-grid__fields .dataviews-view-grid__field-value:not(:empty) {
+ min-height: 24px;
+ line-height: 20px;
+ padding-top: 2px;
+}
+.dataviews-view-grid .dataviews-view-grid__fields .dataviews-view-grid__field {
+ min-height: 24px;
+ align-items: center;
+}
+.dataviews-view-grid .dataviews-view-grid__fields .dataviews-view-grid__field .dataviews-view-grid__field-name {
+ width: 35%;
+ color: #757575;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.dataviews-view-grid .dataviews-view-grid__fields .dataviews-view-grid__field .dataviews-view-grid__field-value {
+ width: 65%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.dataviews-view-grid .dataviews-view-grid__fields .dataviews-view-grid__field:not(:has(.dataviews-view-grid__field-value:not(:empty))) {
+ display: none;
+}
+.dataviews-view-grid .dataviews-view-grid__badge-fields:not(:empty) {
+ padding-bottom: 12px;
+}
+
+.dataviews-view-grid__field-value:empty,
+.dataviews-view-grid__field:empty {
+ display: none;
+}
+
+.dataviews-view-grid__card .dataviews-selection-checkbox {
+ position: absolute;
+ top: -9999em;
+ left: 8px;
+ z-index: 1;
+ opacity: 0;
+}
+@media not (prefers-reduced-motion) {
+ .dataviews-view-grid__card .dataviews-selection-checkbox {
+ transition: opacity 0.1s linear;
+ }
+}
+@media (hover: none) {
+ .dataviews-view-grid__card .dataviews-selection-checkbox {
+ opacity: 1;
+ top: 8px;
+ }
+}
+
+.dataviews-view-grid__card:hover .dataviews-selection-checkbox,
+.dataviews-view-grid__card:focus-within .dataviews-selection-checkbox,
+.dataviews-view-grid__card.is-selected .dataviews-selection-checkbox {
+ opacity: 1;
+ top: 8px;
+}
+
+.dataviews-view-grid__card .dataviews-view-grid__media-actions {
+ position: absolute;
+ top: 4px;
+ opacity: 0;
+ right: 4px;
+}
+.dataviews-view-grid__card .dataviews-view-grid__media-actions .dataviews-all-actions-button {
+ background-color: #fff;
+}
+@media not (prefers-reduced-motion) {
+ .dataviews-view-grid__card .dataviews-view-grid__media-actions {
+ transition: opacity 0.1s linear;
+ }
+}
+@media (hover: none) {
+ .dataviews-view-grid__card .dataviews-view-grid__media-actions {
+ opacity: 1;
+ top: 4px;
+ }
+}
+
+.dataviews-view-grid__card:hover .dataviews-view-grid__media-actions,
+.dataviews-view-grid__card:focus-within .dataviews-view-grid__media-actions,
+.dataviews-view-grid__card .dataviews-view-grid__media-actions:has(.dataviews-all-actions-button[aria-expanded=true]) {
+ opacity: 1;
+}
+
+.dataviews-view-grid__media--clickable {
+ cursor: pointer;
+}
+
+.dataviews-view-grid__group-header {
+ font-size: 15px;
+ font-weight: 499;
+ color: #1e1e1e;
+ margin: 0 0 8px 0;
+ padding: 0 48px;
+ container-type: inline-size;
+}
+@container (max-width: 430px) {
+ .dataviews-view-grid__group-header {
+ padding-left: 24px;
+ padding-right: 24px;
+ }
+}
+
+div.dataviews-view-list {
+ list-style-type: none;
+}
+
+.dataviews-view-list {
+ margin: 0 0 auto;
+}
+.dataviews-view-list div[role=row],
+.dataviews-view-list div[role=article] {
+ margin: 0;
+ border-top: 1px solid #f0f0f0;
+}
+.dataviews-view-list div[role=row] .dataviews-view-list__item-wrapper,
+.dataviews-view-list div[role=article] .dataviews-view-list__item-wrapper {
+ position: relative;
+ padding: 16px 24px;
+ box-sizing: border-box;
+}
+.dataviews-view-list div[role=row] .dataviews-view-list__item-actions,
+.dataviews-view-list div[role=article] .dataviews-view-list__item-actions {
+ display: flex;
+ width: max-content;
+ flex: 0 0 auto;
+ gap: 4px;
+}
+.dataviews-view-list div[role=row] .dataviews-view-list__item-actions .components-button,
+.dataviews-view-list div[role=article] .dataviews-view-list__item-actions .components-button {
+ position: relative;
+ z-index: 1;
+}
+.dataviews-view-list div[role=row] .dataviews-view-list__item-actions > div,
+.dataviews-view-list div[role=article] .dataviews-view-list__item-actions > div {
+ height: 24px;
+}
+.dataviews-view-list div[role=row] .dataviews-view-list__item-actions > :not(:last-child),
+.dataviews-view-list div[role=article] .dataviews-view-list__item-actions > :not(:last-child) {
+ flex: 0;
+ overflow: hidden;
+ width: 0;
+}
+.dataviews-view-list div[role=row]:where(.is-selected, .is-hovered, :focus-within) .dataviews-view-list__item-actions > :not(:last-child),
+.dataviews-view-list div[role=article]:where(.is-selected, .is-hovered, :focus-within) .dataviews-view-list__item-actions > :not(:last-child) {
+ flex-basis: min-content;
+ width: auto;
+ overflow: unset;
+}
+@media (hover: none) {
+ .dataviews-view-list div[role=row] .dataviews-view-list__item-actions > :not(:last-child),
+ .dataviews-view-list div[role=article] .dataviews-view-list__item-actions > :not(:last-child) {
+ flex-basis: min-content;
+ width: auto;
+ overflow: unset;
+ }
+}
+.dataviews-view-list div[role=row].is-selected.is-selected,
+.dataviews-view-list div[role=article].is-selected.is-selected {
+ border-top: 1px solid rgba(var(--wp-admin-theme-color--rgb), 0.12);
+}
+.dataviews-view-list div[role=row].is-selected.is-selected + div[role=row], .dataviews-view-list div[role=row].is-selected.is-selected + div[role=article],
+.dataviews-view-list div[role=article].is-selected.is-selected + div[role=row],
+.dataviews-view-list div[role=article].is-selected.is-selected + div[role=article] {
+ border-top: 1px solid rgba(var(--wp-admin-theme-color--rgb), 0.12);
+}
+.dataviews-view-list div[role=row]:not(.is-selected) .dataviews-view-list__title-field,
+.dataviews-view-list div[role=article]:not(.is-selected) .dataviews-view-list__title-field {
+ color: #1e1e1e;
+}
+.dataviews-view-list div[role=row]:not(.is-selected):hover, .dataviews-view-list div[role=row]:not(.is-selected).is-hovered, .dataviews-view-list div[role=row]:not(.is-selected):focus-within,
+.dataviews-view-list div[role=article]:not(.is-selected):hover,
+.dataviews-view-list div[role=article]:not(.is-selected).is-hovered,
+.dataviews-view-list div[role=article]:not(.is-selected):focus-within {
+ color: var(--wp-admin-theme-color);
+ background-color: #f8f8f8;
+}
+.dataviews-view-list div[role=row]:not(.is-selected):hover .dataviews-view-list__title-field,
+.dataviews-view-list div[role=row]:not(.is-selected):hover .dataviews-view-list__fields, .dataviews-view-list div[role=row]:not(.is-selected).is-hovered .dataviews-view-list__title-field,
+.dataviews-view-list div[role=row]:not(.is-selected).is-hovered .dataviews-view-list__fields, .dataviews-view-list div[role=row]:not(.is-selected):focus-within .dataviews-view-list__title-field,
+.dataviews-view-list div[role=row]:not(.is-selected):focus-within .dataviews-view-list__fields,
+.dataviews-view-list div[role=article]:not(.is-selected):hover .dataviews-view-list__title-field,
+.dataviews-view-list div[role=article]:not(.is-selected):hover .dataviews-view-list__fields,
+.dataviews-view-list div[role=article]:not(.is-selected).is-hovered .dataviews-view-list__title-field,
+.dataviews-view-list div[role=article]:not(.is-selected).is-hovered .dataviews-view-list__fields,
+.dataviews-view-list div[role=article]:not(.is-selected):focus-within .dataviews-view-list__title-field,
+.dataviews-view-list div[role=article]:not(.is-selected):focus-within .dataviews-view-list__fields {
+ color: var(--wp-admin-theme-color);
+}
+.dataviews-view-list div[role=row].is-selected .dataviews-view-list__item-wrapper,
+.dataviews-view-list div[role=row].is-selected:focus-within .dataviews-view-list__item-wrapper,
+.dataviews-view-list div[role=article].is-selected .dataviews-view-list__item-wrapper,
+.dataviews-view-list div[role=article].is-selected:focus-within .dataviews-view-list__item-wrapper {
+ background-color: rgba(var(--wp-admin-theme-color--rgb), 0.04);
+ color: #1e1e1e;
+}
+.dataviews-view-list div[role=row].is-selected .dataviews-view-list__item-wrapper .dataviews-view-list__title-field,
+.dataviews-view-list div[role=row].is-selected .dataviews-view-list__item-wrapper .dataviews-view-list__fields,
+.dataviews-view-list div[role=row].is-selected:focus-within .dataviews-view-list__item-wrapper .dataviews-view-list__title-field,
+.dataviews-view-list div[role=row].is-selected:focus-within .dataviews-view-list__item-wrapper .dataviews-view-list__fields,
+.dataviews-view-list div[role=article].is-selected .dataviews-view-list__item-wrapper .dataviews-view-list__title-field,
+.dataviews-view-list div[role=article].is-selected .dataviews-view-list__item-wrapper .dataviews-view-list__fields,
+.dataviews-view-list div[role=article].is-selected:focus-within .dataviews-view-list__item-wrapper .dataviews-view-list__title-field,
+.dataviews-view-list div[role=article].is-selected:focus-within .dataviews-view-list__item-wrapper .dataviews-view-list__fields {
+ color: var(--wp-admin-theme-color);
+}
+.dataviews-view-list .dataviews-view-list__item {
+ position: absolute;
+ z-index: 1;
+ inset: 0;
+ scroll-margin: 8px 0;
+ appearance: none;
+ border: none;
+ background: none;
+ padding: 0;
+ cursor: pointer;
+}
+.dataviews-view-list .dataviews-view-list__item:focus-visible {
+ outline: none;
+}
+.dataviews-view-list .dataviews-view-list__item:focus-visible::before {
+ position: absolute;
+ content: "";
+ inset: var(--wp-admin-border-width-focus);
+ box-shadow: inset 0 0 0 var(--wp-admin-border-width-focus) var(--wp-admin-theme-color);
+ border-radius: 2px;
+ outline: 2px solid transparent;
+}
+.dataviews-view-list .dataviews-view-list__title-field {
+ flex: 1;
+ min-height: 24px;
+ line-height: 24px;
+ overflow: hidden;
+}
+.dataviews-view-list .dataviews-view-list__title-field:has(a, button) {
+ z-index: 1;
+}
+.dataviews-view-list .dataviews-view-list__media-wrapper {
+ width: 52px;
+ height: 52px;
+ overflow: hidden;
+ position: relative;
+ flex-shrink: 0;
+ background-color: #fff;
+ border-radius: 4px;
+}
+.dataviews-view-list .dataviews-view-list__media-wrapper img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+.dataviews-view-list .dataviews-view-list__media-wrapper::after {
+ content: "";
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.1);
+ border-radius: 4px;
+}
+.dataviews-view-list .dataviews-view-list__field-wrapper {
+ min-height: 52px;
+ flex-grow: 1;
+}
+.dataviews-view-list .dataviews-view-list__fields {
+ color: #757575;
+ display: flex;
+ gap: 12px;
+ row-gap: 4px;
+ flex-wrap: wrap;
+ font-size: 12px;
+}
+.dataviews-view-list .dataviews-view-list__fields:empty {
+ display: none;
+}
+.dataviews-view-list .dataviews-view-list__fields .dataviews-view-list__field:has(.dataviews-view-list__field-value:empty) {
+ display: none;
+}
+.dataviews-view-list .dataviews-view-list__fields .dataviews-view-list__field-value {
+ min-height: 24px;
+ line-height: 20px;
+ display: flex;
+ align-items: center;
+}
+.dataviews-view-list + .dataviews-pagination {
+ justify-content: space-between;
+}
+
+.dataviews-view-list__group-header {
+ font-size: 15px;
+ font-weight: 499;
+ color: #1e1e1e;
+ margin: 0 0 8px 0;
+ padding: 0 24px;
+}
+
+.dataviews-view-table {
+ width: 100%;
+ text-indent: 0;
+ border-color: inherit;
+ border-collapse: collapse;
+ position: relative;
+ color: #757575;
+ margin-bottom: auto;
+}
+.dataviews-view-table th {
+ text-align: left;
+ color: #1e1e1e;
+ font-weight: normal;
+ font-size: 13px;
+}
+.dataviews-view-table td,
+.dataviews-view-table th {
+ padding: 12px;
+}
+.dataviews-view-table td.dataviews-view-table__actions-column,
+.dataviews-view-table th.dataviews-view-table__actions-column {
+ text-align: right;
+}
+.dataviews-view-table td.dataviews-view-table__actions-column--sticky,
+.dataviews-view-table th.dataviews-view-table__actions-column--sticky {
+ position: sticky;
+ right: 0;
+ background-color: #fff;
+}
+.dataviews-view-table td.dataviews-view-table__actions-column--stuck::after,
+.dataviews-view-table th.dataviews-view-table__actions-column--stuck::after {
+ display: block;
+ content: "";
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ width: 1px;
+ background-color: #f0f0f0;
+}
+.dataviews-view-table td.dataviews-view-table__checkbox-column,
+.dataviews-view-table th.dataviews-view-table__checkbox-column {
+ padding-right: 0;
+}
+.dataviews-view-table td.dataviews-view-table__checkbox-column .dataviews-view-table__cell-content-wrapper,
+.dataviews-view-table th.dataviews-view-table__checkbox-column .dataviews-view-table__cell-content-wrapper {
+ max-width: auto;
+ min-width: auto;
+}
+.dataviews-view-table tr {
+ border-top: 1px solid #f0f0f0;
+}
+.dataviews-view-table tr .dataviews-view-table-header-button {
+ gap: 4px;
+}
+.dataviews-view-table tr td:first-child,
+.dataviews-view-table tr th:first-child {
+ padding-left: 48px;
+}
+.dataviews-view-table tr td:first-child .dataviews-view-table-header-button,
+.dataviews-view-table tr th:first-child .dataviews-view-table-header-button {
+ margin-left: -8px;
+}
+.dataviews-view-table tr td:last-child,
+.dataviews-view-table tr th:last-child {
+ padding-right: 48px;
+}
+.dataviews-view-table tr:last-child {
+ border-bottom: 0;
+}
+.dataviews-view-table tr.is-hovered, .dataviews-view-table tr.is-hovered .dataviews-view-table__actions-column--sticky {
+ background-color: #f8f8f8;
+}
+.dataviews-view-table tr .components-checkbox-control__input.components-checkbox-control__input {
+ opacity: 0;
+}
+.dataviews-view-table tr .components-checkbox-control__input.components-checkbox-control__input:checked, .dataviews-view-table tr .components-checkbox-control__input.components-checkbox-control__input:indeterminate, .dataviews-view-table tr .components-checkbox-control__input.components-checkbox-control__input:focus {
+ opacity: 1;
+}
+.dataviews-view-table tr .dataviews-item-actions .components-button:not(.dataviews-all-actions-button) {
+ opacity: 0;
+}
+.dataviews-view-table tr:focus-within .components-checkbox-control__input,
+.dataviews-view-table tr:focus-within .dataviews-item-actions .components-button:not(.dataviews-all-actions-button), .dataviews-view-table tr.is-hovered .components-checkbox-control__input,
+.dataviews-view-table tr.is-hovered .dataviews-item-actions .components-button:not(.dataviews-all-actions-button), .dataviews-view-table tr:hover .components-checkbox-control__input,
+.dataviews-view-table tr:hover .dataviews-item-actions .components-button:not(.dataviews-all-actions-button) {
+ opacity: 1;
+}
+@media (hover: none) {
+ .dataviews-view-table tr .components-checkbox-control__input.components-checkbox-control__input,
+ .dataviews-view-table tr .dataviews-item-actions .components-button:not(.dataviews-all-actions-button) {
+ opacity: 1;
+ }
+}
+.dataviews-view-table tr.is-selected {
+ background-color: rgba(var(--wp-admin-theme-color--rgb), 0.04);
+ color: #757575;
+}
+.dataviews-view-table tr.is-selected, .dataviews-view-table tr.is-selected + tr {
+ border-top: 1px solid rgba(var(--wp-admin-theme-color--rgb), 0.12);
+}
+.dataviews-view-table tr.is-selected:hover {
+ background-color: rgba(var(--wp-admin-theme-color--rgb), 0.08);
+}
+.dataviews-view-table tr.is-selected .dataviews-view-table__actions-column--sticky {
+ background-color: color-mix(in srgb, rgb(var(--wp-admin-theme-color--rgb)) 4%, #fff);
+}
+.dataviews-view-table tr.is-selected:hover .dataviews-view-table__actions-column--sticky {
+ background-color: color-mix(in srgb, rgb(var(--wp-admin-theme-color--rgb)) 8%, #fff);
+}
+.dataviews-view-table thead {
+ position: sticky;
+ inset-block-start: 0;
+ z-index: 1;
+}
+.dataviews-view-table thead tr {
+ border: 0;
+}
+.dataviews-view-table thead tr .components-checkbox-control__input.components-checkbox-control__input {
+ opacity: 1;
+}
+.dataviews-view-table thead th {
+ background-color: #fff;
+ padding-top: 8px;
+ padding-bottom: 8px;
+ padding-left: 12px;
+ font-size: 11px;
+ text-transform: uppercase;
+ font-weight: 499;
+}
+.dataviews-view-table thead th:has(.dataviews-view-table-header-button):not(:first-child) {
+ padding-left: 4px;
+}
+.dataviews-view-table tbody td {
+ vertical-align: top;
+}
+.dataviews-view-table tbody .dataviews-view-table__cell-content-wrapper {
+ min-height: 32px;
+ display: flex;
+ align-items: center;
+ white-space: nowrap;
+}
+.dataviews-view-table tbody .dataviews-view-table__cell-content-wrapper.dataviews-view-table__cell-align-end {
+ justify-content: flex-end;
+}
+.dataviews-view-table tbody .dataviews-view-table__cell-content-wrapper.dataviews-view-table__cell-align-center {
+ justify-content: center;
+}
+.dataviews-view-table tbody .components-v-stack > .dataviews-view-table__cell-content-wrapper:not(:first-child) {
+ min-height: 0;
+}
+.dataviews-view-table .dataviews-view-table-header-button {
+ padding: 4px 8px;
+ font-size: 11px;
+ text-transform: uppercase;
+ font-weight: 499;
+}
+.dataviews-view-table .dataviews-view-table-header-button:not(:hover) {
+ color: #1e1e1e;
+}
+.dataviews-view-table .dataviews-view-table-header-button span {
+ speak: none;
+}
+.dataviews-view-table .dataviews-view-table-header-button span:empty {
+ display: none;
+}
+.dataviews-view-table .dataviews-view-table-header {
+ padding-left: 4px;
+}
+.dataviews-view-table .dataviews-view-table__actions-column {
+ width: auto;
+ white-space: nowrap;
+}
+.dataviews-view-table:has(tr.is-selected) .components-checkbox-control__input {
+ opacity: 1;
+}
+.dataviews-view-table.has-compact-density thead th:has(.dataviews-view-table-header-button):not(:first-child) {
+ padding-left: 0;
+}
+.dataviews-view-table.has-compact-density td,
+.dataviews-view-table.has-compact-density th {
+ padding: 4px 8px;
+}
+.dataviews-view-table.has-comfortable-density td,
+.dataviews-view-table.has-comfortable-density th {
+ padding: 16px 12px;
+}
+.dataviews-view-table.has-compact-density td.dataviews-view-table__checkbox-column,
+.dataviews-view-table.has-compact-density th.dataviews-view-table__checkbox-column, .dataviews-view-table.has-comfortable-density td.dataviews-view-table__checkbox-column,
+.dataviews-view-table.has-comfortable-density th.dataviews-view-table__checkbox-column {
+ padding-right: 0;
+}
+
+@container (max-width: 430px) {
+ .dataviews-view-table tr td:first-child,
+ .dataviews-view-table tr th:first-child {
+ padding-left: 24px;
+ }
+ .dataviews-view-table tr td:last-child,
+ .dataviews-view-table tr th:last-child {
+ padding-right: 24px;
+ }
+}
+.dataviews-view-table-selection-checkbox {
+ --checkbox-input-size: 24px;
+}
+@media (min-width: 600px) {
+ .dataviews-view-table-selection-checkbox {
+ --checkbox-input-size: 16px;
+ }
+}
+
+.dataviews-column-primary__media {
+ max-width: 60px;
+ overflow: hidden;
+ position: relative;
+ flex-shrink: 0;
+ background-color: #fff;
+ border-radius: 4px;
+}
+.dataviews-column-primary__media img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+.dataviews-column-primary__media::after {
+ content: "";
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.1);
+ border-radius: 4px;
+}
+
+.dataviews-view-table__cell-content-wrapper:not(.dataviews-column-primary__media),
+.dataviews-view-table__primary-column-content:not(.dataviews-column-primary__media) {
+ min-width: 15ch;
+ max-width: 80ch;
+}
+
+.dataviews-view-table__group-header-row .dataviews-view-table__group-header-cell {
+ font-weight: 499;
+ padding: 12px 48px;
+ color: #1e1e1e;
+}
+
+/* Column width intents via colgroup: make non-primary columns shrink-to-fit */
+.dataviews-view-table col[class^=dataviews-view-table__col-]:not(.dataviews-view-table__col-primary) {
+ width: 1%;
+}
+
+.dataviews-view-picker-grid .dataviews-view-picker-grid__card {
+ height: 100%;
+ justify-content: flex-start;
+ position: relative;
+}
+.dataviews-view-picker-grid .dataviews-view-picker-grid__card .dataviews-view-picker-grid__title-actions {
+ padding: 8px 0 4px;
+}
+.dataviews-view-picker-grid .dataviews-view-picker-grid__card .dataviews-view-picker-grid__title-field {
+ min-height: 24px;
+ overflow: hidden;
+ align-content: center;
+ text-align: start;
+}
+.dataviews-view-picker-grid .dataviews-view-picker-grid__card .dataviews-view-picker-grid__title-field--clickable {
+ width: fit-content;
+}
+.dataviews-view-picker-grid .dataviews-view-picker-grid__card.is-selected .dataviews-view-picker-grid__fields .dataviews-view-picker-grid__field .dataviews-view-picker-grid__field-value {
+ color: #1e1e1e;
+}
+.dataviews-view-picker-grid .dataviews-view-picker-grid__card.is-selected .dataviews-view-picker-grid__media::after,
+.dataviews-view-picker-grid .dataviews-view-picker-grid__card .dataviews-view-picker-grid__media:focus::after {
+ background-color: rgba(var(--wp-admin-theme-color--rgb), 0.08);
+}
+.dataviews-view-picker-grid .dataviews-view-picker-grid__card.is-selected .dataviews-view-picker-grid__media::after {
+ box-shadow: inset 0 0 0 1px var(--wp-admin-theme-color);
+}
+.dataviews-view-picker-grid .dataviews-view-picker-grid__card .dataviews-view-picker-grid__media:focus::after {
+ box-shadow: inset 0 0 0 var(--wp-admin-border-width-focus) var(--wp-admin-theme-color);
+}
+.dataviews-view-picker-grid:focus-visible[aria-activedescendant] {
+ outline: none;
+}
+.dataviews-view-picker-grid:focus-visible [data-active-item=true] {
+ outline: 2px solid var(--wp-admin-theme-color);
+}
+.dataviews-view-picker-grid .dataviews-selection-checkbox {
+ top: 8px !important;
+}
+.dataviews-view-picker-grid .dataviews-selection-checkbox input {
+ pointer-events: none;
+}
+.dataviews-view-picker-grid .dataviews-view-picker-grid__media {
+ width: 100%;
+ aspect-ratio: 1/1;
+ background-color: #fff;
+ border-radius: 4px;
+ position: relative;
+}
+.dataviews-view-picker-grid .dataviews-view-picker-grid__media img {
+ object-fit: cover;
+ width: 100%;
+ height: 100%;
+}
+.dataviews-view-picker-grid .dataviews-view-picker-grid__media::after {
+ content: "";
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.1);
+ border-radius: 4px;
+ pointer-events: none;
+}
+.dataviews-view-picker-grid .dataviews-view-picker-grid__fields {
+ position: relative;
+ font-size: 12px;
+ line-height: 16px;
+}
+.dataviews-view-picker-grid .dataviews-view-picker-grid__fields:not(:empty) {
+ padding: 0 0 12px;
+}
+.dataviews-view-picker-grid .dataviews-view-picker-grid__fields .dataviews-view-picker-grid__field-value:not(:empty) {
+ min-height: 24px;
+ line-height: 20px;
+ padding-top: 2px;
+}
+.dataviews-view-picker-grid .dataviews-view-picker-grid__fields .dataviews-view-picker-grid__field {
+ min-height: 24px;
+ align-items: center;
+}
+.dataviews-view-picker-grid .dataviews-view-picker-grid__fields .dataviews-view-picker-grid__field .dataviews-view-picker-grid__field-name {
+ width: 35%;
+ color: #757575;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.dataviews-view-picker-grid .dataviews-view-picker-grid__fields .dataviews-view-picker-grid__field .dataviews-view-picker-grid__field-value {
+ width: 65%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.dataviews-view-picker-grid .dataviews-view-picker-grid__fields .dataviews-view-picker-grid__field:not(:has(.dataviews-view-picker-grid__field-value:not(:empty))) {
+ display: none;
+}
+.dataviews-view-picker-grid .dataviews-view-picker-grid__badge-fields:not(:empty) {
+ padding-bottom: 12px;
+}
+
+.dataviews-view-picker-grid__field-value:empty,
+.dataviews-view-picker-grid__field:empty {
+ display: none;
+}
+
+.dataviews-view-picker-grid__card .dataviews-selection-checkbox {
+ position: absolute;
+ top: -9999em;
+ left: 8px;
+ z-index: 1;
+}
+@media (hover: none) {
+ .dataviews-view-picker-grid__card .dataviews-selection-checkbox {
+ top: 8px;
+ }
+}
+
+.dataviews-view-picker-grid__card:hover .dataviews-selection-checkbox,
+.dataviews-view-picker-grid__card:focus-within .dataviews-selection-checkbox,
+.dataviews-view-picker-grid__card.is-selected .dataviews-selection-checkbox {
+ top: 8px;
+}
+
+.dataviews-view-picker-grid__media--clickable {
+ cursor: pointer;
+}
+
+.dataviews-view-picker-grid-group__header {
+ font-size: 15px;
+ font-weight: 499;
+ color: #1e1e1e;
+ margin: 0 0 8px 0;
+ padding: 0 48px;
+}
+
+.dataviews-view-picker-table tbody:focus-visible[aria-activedescendant] {
+ outline: none;
+}
+.dataviews-view-picker-table tbody:focus-visible [data-active-item=true] {
+ outline: 2px solid var(--wp-admin-theme-color);
+}
+.dataviews-view-picker-table .dataviews-selection-checkbox .components-checkbox-control__input.components-checkbox-control__input {
+ pointer-events: none;
+ opacity: 1;
+}
+.dataviews-view-picker-table .dataviews-view-table__row {
+ cursor: pointer;
+}
+.dataviews-view-picker-table .dataviews-view-table__row.is-selected {
+ background-color: rgba(var(--wp-admin-theme-color--rgb), 0.04);
+}
+.dataviews-view-picker-table .dataviews-view-table__row.is-hovered {
+ background-color: rgba(var(--wp-admin-theme-color--rgb), 0.08);
+}
+.dataviews-view-picker-table .dataviews-view-table__row.is-selected.is-hovered {
+ background-color: rgba(var(--wp-admin-theme-color--rgb), 0.12);
+}
+
+.dataviews-controls__datetime {
+ border: none;
+ padding: 0;
+}
+
+.dataviews-controls__relative-date-number,
+.dataviews-controls__relative-date-unit {
+ flex: 1 1 50%;
+}
+
+.dataviews-controls__date input[type=date]::-webkit-inner-spin-button,
+.dataviews-controls__date input[type=date]::-webkit-calendar-picker-indicator {
+ display: none;
+ -webkit-appearance: none;
+}
+
+.dataviews-controls__date-preset {
+ border: 1px solid #ddd;
+}
+.dataviews-controls__date-preset:active {
+ background-color: #000;
+}
+
+.dataforms-layouts-panel__field {
+ width: 100%;
+ min-height: 32px;
+ justify-content: flex-start !important;
+ align-items: flex-start !important;
+}
+
+.dataforms-layouts-panel__field-label {
+ width: 38%;
+ flex-shrink: 0;
+ min-height: 32px;
+ display: flex;
+ align-items: center;
+ line-height: 20px;
+ hyphens: auto;
+}
+.dataforms-layouts-panel__field-label--label-position-side {
+ align-self: center;
+}
+
+.dataforms-layouts-panel__field-control {
+ flex-grow: 1;
+ min-height: 32px;
+ display: flex;
+ align-items: center;
+}
+.dataforms-layouts-panel__field-control .components-button {
+ max-width: 100%;
+ text-align: left;
+ white-space: normal;
+ text-wrap: balance;
+ text-wrap: pretty;
+ min-height: 32px;
+}
+.dataforms-layouts-panel__field-control.components-button.is-link[aria-disabled=true] {
+ text-decoration: none;
+}
+.dataforms-layouts-panel__field-control .components-dropdown {
+ max-width: 100%;
+}
+
+.dataforms-layouts-panel__field-dropdown .components-popover__content {
+ min-width: 320px;
+ padding: 16px;
+}
+
+.dataforms-layouts-panel__dropdown-header {
+ margin-bottom: 16px;
+}
+
+.dataforms-layouts-panel__modal-footer {
+ margin-top: 16px;
+}
+
+.components-popover.components-dropdown__content.dataforms-layouts-panel__field-dropdown {
+ z-index: 159990;
+}
+
+.dataforms-layouts-regular__field {
+ width: 100%;
+ min-height: 32px;
+ justify-content: flex-start !important;
+ align-items: flex-start !important;
+}
+
+.dataforms-layouts-regular__field-label {
+ width: 38%;
+ flex-shrink: 0;
+ min-height: 32px;
+ display: flex;
+ align-items: center;
+ line-height: 20px;
+ hyphens: auto;
+}
+.dataforms-layouts-regular__field-label--label-position-side {
+ align-self: center;
+}
+
+.dataforms-layouts-regular__field-control {
+ flex-grow: 1;
+ min-height: 32px;
+ display: flex;
+ align-items: center;
+}
+
+.dataforms-layouts-card__field-header-label {
+ font-family: -apple-system, "system-ui", "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
+ font-weight: 499;
+ font-size: 15px;
+ line-height: 20px;
+}
+
+.dataforms-layouts-card__field {
+ width: 100%;
+}
+
+.dataforms-layouts-card__field-description {
+ color: #757575;
+ display: block;
+ font-size: 13px;
+ margin-bottom: 16px;
+}
+
+.dataforms-layouts-card__field-summary {
+ display: flex;
+ flex-direction: row;
+ gap: 16px;
+}
+
+.dataforms-layouts-details__content {
+ padding-top: 12px;
+}
+
+.dataforms-layouts-row__field-control {
+ width: 100%;
+}
+
+.dataforms-layouts__wrapper {
+ font-family: -apple-system, "system-ui", "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
+ font-weight: 400;
+ font-size: 13px;
+ line-height: 20px;
+}
\ No newline at end of file
diff --git a/src/admin/ai-request-logs/components/HeaderPeriodSelector.tsx b/src/admin/ai-request-logs/components/HeaderPeriodSelector.tsx
new file mode 100644
index 00000000..873af393
--- /dev/null
+++ b/src/admin/ai-request-logs/components/HeaderPeriodSelector.tsx
@@ -0,0 +1,53 @@
+/**
+ * WordPress dependencies
+ */
+import { SelectControl } from '@wordpress/components';
+import { __ } from '@wordpress/i18n';
+/**
+ * External dependencies
+ */
+import React from 'react';
+import { createPortal } from 'react-dom';
+
+type Period = 'minute' | 'hour' | 'day' | 'week' | 'month' | 'all';
+
+interface HeaderPeriodSelectorProps {
+ period: Period;
+ onPeriodChange: ( period: Period ) => void;
+ loading: boolean;
+}
+
+const HeaderPeriodSelector: React.FC< HeaderPeriodSelectorProps > = ( {
+ period,
+ onPeriodChange,
+ loading,
+} ) => {
+ const container = document.getElementById(
+ 'ai-request-logs-header-period'
+ );
+
+ if ( ! container ) {
+ return null;
+ }
+
+ return createPortal(
+ onPeriodChange( value as Period ) }
+ disabled={ loading }
+ __nextHasNoMarginBottom
+ __next40pxDefaultSize
+ />,
+ container
+ );
+};
+
+export default HeaderPeriodSelector;
diff --git a/src/admin/ai-request-logs/components/LogDetailModal.tsx b/src/admin/ai-request-logs/components/LogDetailModal.tsx
new file mode 100644
index 00000000..f0c6a28b
--- /dev/null
+++ b/src/admin/ai-request-logs/components/LogDetailModal.tsx
@@ -0,0 +1,352 @@
+/**
+ * WordPress dependencies
+ */
+import { Button, Modal } from '@wordpress/components';
+import { __, sprintf } from '@wordpress/i18n';
+
+/**
+ * External dependencies
+ */
+import React from 'react';
+
+/**
+ * Internal dependencies
+ */
+import type { LogEntry } from '../types';
+
+interface LogDetailModalProps {
+ log: LogEntry;
+ onClose: () => void;
+}
+
+const formatTimestamp = ( timestamp: string ): string => {
+ const date = new Date( timestamp + 'Z' );
+ return date.toLocaleString( undefined, {
+ dateStyle: 'full',
+ timeStyle: 'medium',
+ } );
+};
+
+const formatCost = ( cost: number | null ): string => {
+ if ( cost === null ) {
+ return '-';
+ }
+ if ( cost < 0.01 ) {
+ return '$' + cost.toFixed( 6 );
+ }
+ return '$' + cost.toFixed( 4 );
+};
+
+const formatKindLabel = ( value: string ): string =>
+ value
+ .split( '_' )
+ .map( ( part ) => part.charAt( 0 ).toUpperCase() + part.slice( 1 ) )
+ .join( ' ' );
+
+const formatTokensPerSecond = ( value: number | null ): string => {
+ if ( value === null ) {
+ return '-';
+ }
+ if ( value >= 1000 ) {
+ return ( value / 1000 ).toFixed( 1 ) + 'K';
+ }
+ return value.toFixed( 1 );
+};
+
+const LogDetailModal: React.FC< LogDetailModalProps > = ( {
+ log,
+ onClose,
+} ) => {
+ const inputPreview =
+ typeof log.context?.input_preview === 'string'
+ ? log.context.input_preview
+ : null;
+ const outputPreview =
+ typeof log.context?.output_preview === 'string'
+ ? log.context.output_preview
+ : null;
+ const requestKind =
+ typeof log.context?.request_kind === 'string'
+ ? log.context.request_kind
+ : null;
+
+ const imageUrlValue = log.context?.image_urls;
+ const imageUrls = Array.isArray( imageUrlValue )
+ ? imageUrlValue.filter(
+ ( url ): url is string =>
+ typeof url === 'string' && url.length > 0
+ )
+ : [];
+
+ const base64Value = log.context?.image_base64_samples;
+ const base64Images = Array.isArray( base64Value )
+ ? base64Value
+ .map( ( sample ) =>
+ typeof sample === 'object' && sample !== null
+ ? sample
+ : null
+ )
+ .filter(
+ (
+ sample
+ ): sample is { data: string; mime?: string } =>
+ Boolean(
+ sample &&
+ typeof sample === 'object' &&
+ typeof ( sample as { data?: unknown } ).data ===
+ 'string' &&
+ ( sample as { data: string } ).data.length > 0
+ )
+ )
+ : [];
+
+ const handleCopyId = async () => {
+ try {
+ await navigator.clipboard.writeText( log.id );
+ } catch ( e ) {
+ // Fallback for older browsers
+ const textarea = document.createElement( 'textarea' );
+ textarea.value = log.id;
+ document.body.appendChild( textarea );
+ textarea.select();
+ document.execCommand( 'copy' );
+ document.body.removeChild( textarea );
+ }
+ };
+
+ const getStatusIcon = ( status: string ): string => {
+ switch ( status ) {
+ case 'success':
+ return '\u2713'; // Checkmark
+ case 'error':
+ return '\u2717'; // X mark
+ case 'timeout':
+ return '\u23F1'; // Stopwatch
+ default:
+ return '';
+ }
+ };
+
+ return (
+
+
+
+
+ { log.operation }
+
+
+ { getStatusIcon( log.status ) } { log.status }
+
+
+
+
+
{ __( 'General', 'ai' ) }
+
+
+
+ | { __( 'Timestamp', 'ai' ) } |
+ { formatTimestamp( log.timestamp ) } |
+
+
+ | { __( 'Duration', 'ai' ) } |
+
+ { log.duration_ms !== null
+ ? sprintf(
+ /* translators: %d: request duration in milliseconds. */
+ __( '%d ms', 'ai' ),
+ log.duration_ms
+ )
+ : '-' }
+ |
+
+
+ | { __( 'Type', 'ai' ) } |
+ { log.type } |
+
+ { requestKind && (
+
+ | { __( 'Request Kind', 'ai' ) } |
+ { formatKindLabel( requestKind ) } |
+
+ ) }
+ { log.user_id && (
+
+ | { __( 'User ID', 'ai' ) } |
+ { log.user_id } |
+
+ ) }
+
+
+
+
+ { ( log.provider || log.model ) && (
+
+
{ __( 'Provider & Model', 'ai' ) }
+
+
+ { log.provider && (
+
+ | { __( 'Provider', 'ai' ) } |
+ { log.provider } |
+
+ ) }
+ { log.model && (
+
+ | { __( 'Model', 'ai' ) } |
+ { log.model } |
+
+ ) }
+
+
+
+ ) }
+
+ { ( log.tokens_input !== null ||
+ log.tokens_output !== null ) && (
+
+
{ __( 'Token Usage', 'ai' ) }
+
+
+
+ | { __( 'Input Tokens', 'ai' ) } |
+
+ { log.tokens_input?.toLocaleString() ??
+ '-' }
+ |
+
+
+ | { __( 'Output Tokens', 'ai' ) } |
+
+ { log.tokens_output?.toLocaleString() ??
+ '-' }
+ |
+
+
+ | { __( 'Total Tokens', 'ai' ) } |
+
+ { log.tokens_total?.toLocaleString() ??
+ '-' }
+ |
+
+
+ | { __( 'Tokens per Second', 'ai' ) } |
+
+ { formatTokensPerSecond(
+ log.tokens_per_second ?? null
+ ) }
+ |
+
+
+ | { __( 'Estimated Cost', 'ai' ) } |
+ { formatCost( log.cost_estimate ) } |
+
+
+
+
+ ) }
+
+ { log.error_message && (
+
+
{ __( 'Error', 'ai' ) }
+
+ { log.error_message }
+
+
+ ) }
+
+ { inputPreview && (
+
+
{ __( 'Input Preview', 'ai' ) }
+
+ { inputPreview }
+
+
+ ) }
+
+ { outputPreview && (
+
+
{ __( 'Output Preview', 'ai' ) }
+
+ { outputPreview }
+
+
+ ) }
+
+ { ( imageUrls.length > 0 || base64Images.length > 0 ) && (
+
+
{ __( 'Generated Images', 'ai' ) }
+
+ { imageUrls.map( ( url, index ) => (
+
+
+
+ { sprintf(
+ /* translators: %d: image index */
+ __( 'Image %d', 'ai' ),
+ index + 1
+ ) }
+
+
+ ) ) }
+ { base64Images.map( ( sample, index ) => (
+
+
+
+ { sprintf(
+ /* translators: %d: image index */
+ __( 'Image %d', 'ai' ),
+ imageUrls.length + index + 1
+ ) }
+
+
+ ) ) }
+
+
+ ) }
+
+ { log.context && Object.keys( log.context ).length > 0 && (
+
+
{ __( 'Context', 'ai' ) }
+
+ { JSON.stringify( log.context, null, 2 ) }
+
+
+ ) }
+
+
+
+ { log.id }
+
+
+
+
+
+ );
+};
+
+export default LogDetailModal;
diff --git a/src/admin/ai-request-logs/components/LogsTable.tsx b/src/admin/ai-request-logs/components/LogsTable.tsx
new file mode 100644
index 00000000..02f6aed4
--- /dev/null
+++ b/src/admin/ai-request-logs/components/LogsTable.tsx
@@ -0,0 +1,561 @@
+/**
+ * WordPress dependencies
+ */
+import {
+ Button,
+ Card,
+ CardBody,
+ CardHeader,
+ Popover,
+} from '@wordpress/components';
+import { DataViews } from '@wordpress/dataviews/wp';
+import type { DataViewField, View, Filter } from '@wordpress/dataviews';
+import { __, sprintf } from '@wordpress/i18n';
+
+/**
+ * External dependencies
+ */
+import React, { useCallback, useMemo, useState } from 'react';
+
+/**
+ * Internal dependencies
+ */
+import { getProviderIconComponent } from '../../components/provider-icons';
+import type { FilterOptions, LogEntry } from '../types';
+import type { ProviderMetadata } from '../../types/providers';
+
+interface LogsTableProps {
+ logs: LogEntry[];
+ filterOptions: FilterOptions;
+ onViewLog: ( log: LogEntry ) => void;
+ loading: boolean;
+ totalPages: number;
+ total: number;
+ view: View;
+ setView: ( next: View | ( ( prev: View ) => View ) ) => void;
+ providerMetadata: Record< string, ProviderMetadata >;
+}
+
+const formatTimestamp = ( timestamp: string ): string => {
+ const date = new Date( timestamp + 'Z' );
+ return date.toLocaleString();
+};
+
+const formatDuration = ( ms: number | null ): string => {
+ if ( ms === null ) {
+ return '-';
+ }
+ if ( ms < 1000 ) {
+ return ms + 'ms';
+ }
+ return ( ms / 1000 ).toFixed( 1 ) + 's';
+};
+
+const formatTokens = ( tokens: number | null ): string => {
+ if ( tokens === null ) {
+ return '-';
+ }
+ if ( tokens >= 1000 ) {
+ return ( tokens / 1000 ).toFixed( 1 ) + 'K';
+ }
+ return tokens.toLocaleString();
+};
+
+const formatTokensPerSecond = ( value: number | null ): string => {
+ if ( value === null ) {
+ return '-';
+ }
+ if ( value >= 1000 ) {
+ return ( value / 1000 ).toFixed( 1 ) + 'K';
+ }
+ return value.toFixed( 1 );
+};
+
+const getStatusClass = ( status: string ): string => {
+ switch ( status ) {
+ case 'success':
+ return 'ai-request-logs__status--success';
+ case 'error':
+ return 'ai-request-logs__status--error';
+ case 'timeout':
+ return 'ai-request-logs__status--timeout';
+ default:
+ return '';
+ }
+};
+
+const formatSelectLabel = ( value: string ): string =>
+ value
+ .split( '_' )
+ .map( ( part ) => part.charAt( 0 ).toUpperCase() + part.slice( 1 ) )
+ .join( ' ' );
+
+const getRequestKind = ( entry: LogEntry ): string => {
+ const raw = entry.context?.request_kind;
+ return typeof raw === 'string' ? raw : 'text';
+};
+
+const LogsTable: React.FC< LogsTableProps > = ( {
+ logs,
+ filterOptions,
+ onViewLog,
+ loading,
+ totalPages,
+ total,
+ view,
+ setView,
+ providerMetadata,
+} ) => {
+ const typeElements = useMemo(
+ () =>
+ filterOptions.types.map( ( value ) => ( {
+ label: formatSelectLabel( value ),
+ value,
+ } ) ),
+ [ filterOptions.types ]
+ );
+
+ const statusElements = useMemo(
+ () =>
+ filterOptions.statuses.map( ( value ) => ( {
+ label: formatSelectLabel( value ),
+ value,
+ } ) ),
+ [ filterOptions.statuses ]
+ );
+
+ const providerElements = useMemo(
+ () =>
+ filterOptions.providers.map( ( value ) => ( {
+ label: value,
+ value,
+ } ) ),
+ [ filterOptions.providers ]
+ );
+
+ const operationElements = useMemo(
+ () =>
+ ( filterOptions.operations ?? [] ).map( ( value ) => ( {
+ label: formatSelectLabel( value ),
+ value,
+ } ) ),
+ [ filterOptions.operations ]
+ );
+
+ // Token filter elements with useful ranges
+ const tokenFilterElements = useMemo(
+ () => [
+ {
+ label: __( 'Has Tokens (> 0)', 'ai' ),
+ value: 'gt:0',
+ },
+ {
+ label: __( '< 100 tokens', 'ai' ),
+ value: 'lt:100',
+ },
+ {
+ label: __( '< 500 tokens', 'ai' ),
+ value: 'lt:500',
+ },
+ {
+ label: __( '< 1K tokens', 'ai' ),
+ value: 'lt:1000',
+ },
+ {
+ label: __( '< 5K tokens', 'ai' ),
+ value: 'lt:5000',
+ },
+ {
+ label: __( '> 1K tokens', 'ai' ),
+ value: 'gt:1000',
+ },
+ {
+ label: __( '> 5K tokens', 'ai' ),
+ value: 'gt:5000',
+ },
+ {
+ label: __( '> 10K tokens', 'ai' ),
+ value: 'gt:10000',
+ },
+ {
+ label: __( 'No Tokens', 'ai' ),
+ value: 'none',
+ },
+ ],
+ []
+ );
+
+ const requestKindElements = useMemo( () => {
+ const kinds = new Set();
+ logs.forEach( ( entry ) => kinds.add( getRequestKind( entry ) ) );
+ return Array.from( kinds ).map( ( value ) => ( {
+ label: formatSelectLabel( value ),
+ value,
+ } ) );
+ }, [ logs ] );
+
+ const fields = useMemo< DataViewField< LogEntry >[] >(
+ () => [
+ {
+ id: 'timestamp',
+ label: __( 'Time', 'ai' ),
+ type: 'datetime',
+ getValue: ( { item } ) => item.timestamp,
+ enableSorting: false,
+ render: ( { item } ) => (
+
+ { formatTimestamp( item.timestamp ) }
+
+ ),
+ },
+ {
+ id: 'operation',
+ label: __( 'Operation', 'ai' ),
+ type: 'text',
+ getValue: ( { item } ) => item.operation,
+ elements: operationElements,
+ filterBy:
+ operationElements.length > 0
+ ? { operators: [ 'isAny' ] }
+ : false,
+ render: ( { item } ) => (
+
+
{ item.operation }
+ { item.error_message && (
+
+ { item.error_message.substring( 0, 50 ) }
+ { item.error_message.length > 50 ? '…' : '' }
+
+ ) }
+
+ ),
+ },
+ {
+ id: 'operation_pattern',
+ label: __( 'Request Type', 'ai' ),
+ type: 'text',
+ getValue: ( { item } ) => getRequestKind( item ),
+ elements: requestKindElements,
+ filterBy:
+ requestKindElements.length > 0
+ ? { operators: [ 'is' ] }
+ : false,
+ enableHiding: false,
+ render: ( { item } ) => (
+
+ { formatSelectLabel( getRequestKind( item ) ) }
+
+ ),
+ },
+ {
+ id: 'type',
+ label: __( 'Type', 'ai' ),
+ type: 'text',
+ getValue: ( { item } ) => item.type,
+ elements: typeElements,
+ filterBy:
+ typeElements.length > 0 ? { operators: [ 'is' ] } : false,
+ enableHiding: false,
+ isVisible: () => false,
+ },
+ {
+ id: 'provider',
+ label: __( 'Provider / Model', 'ai' ),
+ type: 'text',
+ getValue: ( { item } ) => item.provider ?? '',
+ elements: providerElements,
+ filterBy:
+ providerElements.length > 0
+ ? { operators: [ 'is' ] }
+ : false,
+ render: ( { item } ) => (
+
+ ),
+ },
+ {
+ id: 'tokens_total',
+ label: __( 'Tokens', 'ai' ),
+ type: 'text',
+ getValue: ( { item } ) => {
+ // Return filter value for matching
+ const tokens = item.tokens_total ?? 0;
+ if ( tokens === 0 || item.tokens_total === null ) {
+ return 'none';
+ }
+ if ( tokens > 10000 ) return 'gt:10000';
+ if ( tokens > 5000 ) return 'gt:5000';
+ if ( tokens > 1000 ) return 'gt:1000';
+ return 'gt:0';
+ },
+ elements: tokenFilterElements,
+ filterBy: { operators: [ 'is' ] },
+ render: ( { item } ) => formatTokens( item.tokens_total ),
+ },
+ {
+ id: 'duration_ms',
+ label: __( 'Duration', 'ai' ),
+ type: 'number',
+ getValue: ( { item } ) => item.duration_ms ?? 0,
+ render: ( { item } ) => formatDuration( item.duration_ms ),
+ },
+ {
+ id: 'tokens_per_second',
+ label: __( 'Tokens/s', 'ai' ),
+ type: 'number',
+ getValue: ( { item } ) => item.tokens_per_second ?? 0,
+ render: ( { item } ) =>
+ formatTokensPerSecond( item.tokens_per_second ),
+ },
+ {
+ id: 'status',
+ label: __( 'Status', 'ai' ),
+ type: 'text',
+ getValue: ( { item } ) => item.status,
+ elements: statusElements,
+ filterBy:
+ statusElements.length > 0 ? { operators: [ 'is' ] } : false,
+ render: ( { item } ) => (
+
+ { formatSelectLabel( item.status ) }
+
+ ),
+ },
+ {
+ id: 'actions',
+ label: __( 'Details', 'ai' ),
+ type: 'text',
+ enableSorting: false,
+ enableHiding: false,
+ filterBy: false,
+ render: ( { item } ) => (
+
+ ),
+ },
+ ],
+ [
+ onViewLog,
+ operationElements,
+ providerElements,
+ providerMetadata,
+ statusElements,
+ tokenFilterElements,
+ typeElements,
+ requestKindElements,
+ ]
+ );
+
+ const handleViewChange = useCallback(
+ ( nextView: View ) => {
+ setView( ( previous ) => {
+ // Deduplicate filters by field - keep only the last filter for each field
+ const filters = nextView.filters ?? [];
+ const deduplicatedFilters = filters.reduce(
+ ( acc: Filter[], filter ) => {
+ const existingIndex = acc.findIndex(
+ ( f ) => f.field === filter.field
+ );
+ if ( existingIndex >= 0 ) {
+ acc[ existingIndex ] = filter;
+ } else {
+ acc.push( filter );
+ }
+ return acc;
+ },
+ []
+ );
+
+ return {
+ ...previous,
+ ...nextView,
+ filters: deduplicatedFilters,
+ layout: nextView.layout ?? previous.layout,
+ };
+ } );
+ },
+ [ setView ]
+ );
+
+ const hasActiveFilters = Boolean(
+ view.search || ( view.filters && view.filters.length > 0 )
+ );
+
+ return (
+
+
+ { __( 'Request Logs', 'ai' ) }
+ { total > 0 && (
+
+ { sprintf(
+ /* translators: %d: total number of logged requests. */
+ __( '%d total', 'ai' ),
+ total
+ ) }
+
+ ) }
+
+
+ item.id }
+ defaultLayouts={ {
+ table: {
+ layout: {
+ density: 'comfortable',
+ enableMoving: false,
+ },
+ },
+ } }
+ isLoading={ loading }
+ paginationInfo={ {
+ totalItems: total,
+ totalPages,
+ } }
+ config={ { perPageSizes: [ 25 ] } }
+ empty={
+
+ { hasActiveFilters
+ ? __( 'No logs match your filters.', 'ai' )
+ : __(
+ 'No AI requests have been logged yet.',
+ 'ai'
+ ) }
+
+ }
+ searchLabel={ __( 'Search logs', 'ai' ) }
+ />
+
+
+ );
+};
+
+interface ProviderCellProps {
+ provider: string | null;
+ model: string | null;
+ metadata?: ProviderMetadata;
+}
+
+const ProviderCell: React.FC< ProviderCellProps > = ( {
+ provider,
+ model,
+ metadata,
+} ) => {
+ const [ isPopoverVisible, setIsPopoverVisible ] = useState( false );
+
+ if ( ! provider && ! model ) {
+ return -;
+ }
+
+ if ( ! metadata ) {
+ return (
+
+
+ { provider && (
+
+ { provider }
+
+ ) }
+
+ { model && (
+
{ model }
+ ) }
+
+ );
+ }
+
+ const IconComponent = getProviderIconComponent(
+ metadata.icon || metadata.id,
+ provider || undefined
+ );
+
+ return (
+ setIsPopoverVisible( true ) }
+ onMouseLeave={ () => setIsPopoverVisible( false ) }
+ >
+
+
+
+
+
+ { metadata.name }
+
+
+ { model && (
+
{ model }
+ ) }
+ { isPopoverVisible && (
+
+
+
+
+
+
+
+ { metadata.name }
+
+
+ { metadata.type === 'client'
+ ? __( 'Local', 'ai' )
+ : __( 'Cloud', 'ai' ) }
+
+
+ { model && (
+
+ { model }
+
+ ) }
+
+
+
+ ) }
+
+ );
+};
+
+export default LogsTable;
diff --git a/src/admin/ai-request-logs/components/SettingsPanel.tsx b/src/admin/ai-request-logs/components/SettingsPanel.tsx
new file mode 100644
index 00000000..0d1b7ad6
--- /dev/null
+++ b/src/admin/ai-request-logs/components/SettingsPanel.tsx
@@ -0,0 +1,129 @@
+/**
+ * WordPress dependencies
+ */
+import {
+ Button,
+ Card,
+ CardBody,
+ CardHeader,
+ RangeControl,
+ ToggleControl,
+} from '@wordpress/components';
+import { __, sprintf } from '@wordpress/i18n';
+/**
+ * External dependencies
+ */
+import React, { useState } from 'react';
+
+interface SettingsPanelProps {
+ enabled: boolean;
+ retentionDays: number;
+ onToggleEnabled: ( enabled: boolean ) => void;
+ onRetentionChange: ( days: number ) => void;
+ onPurgeLogs: () => void;
+ saving: boolean;
+ purging: boolean;
+}
+
+const SettingsPanel: React.FC< SettingsPanelProps > = ( {
+ enabled,
+ retentionDays,
+ onToggleEnabled,
+ onRetentionChange,
+ onPurgeLogs,
+ saving,
+ purging,
+} ) => {
+ const [ showPurgeConfirm, setShowPurgeConfirm ] = useState( false );
+
+ const handlePurge = () => {
+ if ( showPurgeConfirm ) {
+ onPurgeLogs();
+ setShowPurgeConfirm( false );
+ } else {
+ setShowPurgeConfirm( true );
+ }
+ };
+
+ return (
+
+
+ { __( 'Settings', 'ai' ) }
+
+
+
+
+ onRetentionChange( value ?? 30 ) }
+ min={ 1 }
+ max={ 365 }
+ disabled={ saving }
+ __nextHasNoMarginBottom
+ __next40pxDefaultSize
+ />
+
+
+
{ __( 'Danger Zone', 'ai' ) }
+
+ { __(
+ 'Permanently delete all logged requests. This action cannot be undone.',
+ 'ai'
+ ) }
+
+ { showPurgeConfirm ? (
+
+ { __( 'Are you sure?', 'ai' ) }
+
+
+
+ ) : (
+
+ ) }
+
+
+
+ );
+};
+
+export default SettingsPanel;
diff --git a/src/admin/ai-request-logs/components/SummaryCards.tsx b/src/admin/ai-request-logs/components/SummaryCards.tsx
new file mode 100644
index 00000000..067f0830
--- /dev/null
+++ b/src/admin/ai-request-logs/components/SummaryCards.tsx
@@ -0,0 +1,108 @@
+/**
+ * WordPress dependencies
+ */
+import { Card, CardBody } from '@wordpress/components';
+import { __ } from '@wordpress/i18n';
+/**
+ * External dependencies
+ */
+import React from 'react';
+
+/**
+ * Internal dependencies
+ */
+import type { LogSummary } from '../types';
+
+interface SummaryCardsProps {
+ summary: LogSummary;
+ loading: boolean;
+}
+
+const formatNumber = ( num: number ): string => {
+ if ( num >= 1000000 ) {
+ return ( num / 1000000 ).toFixed( 1 ) + 'M';
+ }
+ if ( num >= 1000 ) {
+ return ( num / 1000 ).toFixed( 1 ) + 'K';
+ }
+ return num.toLocaleString();
+};
+
+const formatCost = ( cost: number ): string => {
+ if ( cost < 0.01 ) {
+ return '$' + cost.toFixed( 4 );
+ }
+ return '$' + cost.toFixed( 2 );
+};
+
+const formatDuration = ( ms: number ): string => {
+ if ( ms < 1000 ) {
+ return ms.toFixed( 0 ) + 'ms';
+ }
+ return ( ms / 1000 ).toFixed( 1 ) + 's';
+};
+
+const SummaryCards: React.FC< SummaryCardsProps > = ( { summary } ) => {
+ return (
+
+
+
+
+
+ { formatNumber( summary.total_requests ) }
+
+
+ { __( 'Requests', 'ai' ) }
+
+
+
+
+
+
+
+ { formatNumber( summary.total_tokens ) }
+
+
+ { __( 'Tokens', 'ai' ) }
+
+
+
+
+
+
+
+ { formatDuration( summary.avg_duration_ms ) }
+
+
+ { __( 'Avg Time', 'ai' ) }
+
+
+
+
+
+
+
+ { formatCost( summary.total_cost ) }
+
+
+ { __( 'Est. Cost', 'ai' ) }
+
+
+
+
+
+
+
+ { summary.success_rate.toFixed( 1 ) }%
+
+
+ { __( 'Success Rate', 'ai' ) }
+
+
+
+
+
+ );
+};
+
+export default SummaryCards;
diff --git a/src/admin/ai-request-logs/index.tsx b/src/admin/ai-request-logs/index.tsx
new file mode 100644
index 00000000..a652e0a4
--- /dev/null
+++ b/src/admin/ai-request-logs/index.tsx
@@ -0,0 +1,419 @@
+/**
+ * Internal dependencies
+ */
+import './style.scss';
+
+/**
+ * WordPress dependencies
+ */
+import apiFetch from '@wordpress/api-fetch';
+import { Notice } from '@wordpress/components';
+import { dispatch } from '@wordpress/data';
+import type { Filter, View } from '@wordpress/dataviews';
+import { store as noticesStore } from '@wordpress/notices';
+import { __ } from '@wordpress/i18n';
+
+/**
+ * External dependencies
+ */
+import React, { useCallback, useEffect, useState } from 'react';
+import { createRoot } from 'react-dom/client';
+
+/**
+ * Internal dependencies
+ */
+import { usePersistedView } from '../hooks/usePersistedView';
+
+import HeaderPeriodSelector from './components/HeaderPeriodSelector';
+import LogDetailModal from './components/LogDetailModal';
+import LogsTable from './components/LogsTable';
+import SettingsPanel from './components/SettingsPanel';
+import SummaryCards from './components/SummaryCards';
+import type {
+ FilterOptions,
+ LocalizedSettings,
+ LogEntry,
+ LogSummary,
+} from './types';
+
+const settings: LocalizedSettings =
+ window.aiAiRequestLogsSettings ??
+ window.AiRequestLogsSettings ??
+ ( () => {
+ throw new Error( 'AiRequestLogsSettings is not defined.' );
+ } )();
+
+const providerMetadata = settings.providerMetadata ?? {};
+
+apiFetch.use( apiFetch.createNonceMiddleware( settings.rest.nonce ) );
+apiFetch.use( apiFetch.createRootURLMiddleware( settings.rest.root ) );
+
+const showNotice = (
+ status: 'success' | 'error' | 'warning',
+ message: string
+) =>
+ dispatch( noticesStore ).createNotice( status, message, {
+ type: 'snackbar',
+ } );
+
+const getErrorMessage = ( error: unknown ): string => {
+ if ( typeof error === 'string' ) {
+ return error;
+ }
+ if ( error && typeof error === 'object' && 'message' in error ) {
+ return String( ( error as { message: string } ).message );
+ }
+ return __( 'Something went wrong. Please try again.', 'ai' );
+};
+
+const defaultView: View = {
+ type: 'table',
+ perPage: 25,
+ page: 1,
+ search: '',
+ filters: [],
+ fields: [
+ 'timestamp',
+ 'operation',
+ 'provider',
+ 'tokens_total',
+ 'duration_ms',
+ 'status',
+ 'actions',
+ ],
+ sort: {
+ field: 'timestamp',
+ direction: 'desc',
+ },
+ layout: {
+ density: 'comfortable',
+ },
+};
+
+const normalizeFilterValue = ( raw: unknown ): string => {
+ if ( Array.isArray( raw ) ) {
+ return normalizeFilterValue( raw[ 0 ] );
+ }
+ if (
+ raw &&
+ typeof raw === 'object' &&
+ 'value' in ( raw as Record< string, unknown > )
+ ) {
+ return normalizeFilterValue( ( raw as { value: unknown } ).value );
+ }
+ if ( typeof raw === 'string' ) {
+ return raw;
+ }
+ return '';
+};
+
+const normalizeFilterArrayValue = ( raw: unknown ): string[] => {
+ if ( Array.isArray( raw ) ) {
+ return raw.map( ( item ) => {
+ if ( typeof item === 'string' ) {
+ return item;
+ }
+ if ( item && typeof item === 'object' && 'value' in item ) {
+ return String( ( item as { value: unknown } ).value );
+ }
+ return String( item );
+ } );
+ }
+ if ( typeof raw === 'string' && raw ) {
+ return [ raw ];
+ }
+ return [];
+};
+
+const extractFilterValue = (
+ filters: Filter[] | undefined,
+ field: string
+): string => {
+ const match = filters?.find( ( entry ) => entry.field === field );
+ return normalizeFilterValue( match?.value ?? '' );
+};
+
+const extractFilterArrayValue = (
+ filters: Filter[] | undefined,
+ field: string
+): string[] => {
+ const match = filters?.find( ( entry ) => entry.field === field );
+ return normalizeFilterArrayValue( match?.value ?? [] );
+};
+
+const App: React.FC = () => {
+ // Settings state
+ const [ enabled, setEnabled ] = useState( settings.initialState.enabled );
+ const [ retentionDays, setRetentionDays ] = useState(
+ settings.initialState.retentionDays
+ );
+
+ // Summary state
+ const [ summary, setSummary ] = useState< LogSummary >(
+ settings.initialState.summary
+ );
+ const [ summaryPeriod, setSummaryPeriod ] = useState<
+ 'minute' | 'hour' | 'day' | 'week' | 'month' | 'all'
+ >( 'day' );
+ const [ summaryLoading, setSummaryLoading ] = useState( false );
+
+ // Logs state
+ const [ logs, setLogs ] = useState< LogEntry[] >( [] );
+ const [ logsLoading, setLogsLoading ] = useState( true );
+ const [ totalPages, setTotalPages ] = useState( 1 );
+ const [ total, setTotal ] = useState( 0 );
+
+ const { view, setView } = usePersistedView< View >(
+ 'ai-request-logs',
+ defaultView
+ );
+ const [ filterOptions, setFilterOptions ] = useState< FilterOptions >(
+ settings.initialState.filters
+ );
+
+ // UI state
+ const [ selectedLog, setSelectedLog ] = useState< LogEntry | null >( null );
+ const [ error, setError ] = useState< string | null >( null );
+ const [ saving, setSaving ] = useState( false );
+ const [ purging, setPurging ] = useState( false );
+
+ // Fetch summary
+ const fetchSummary = useCallback( async ( period: string ) => {
+ setSummaryLoading( true );
+ try {
+ const response = await apiFetch< LogSummary >( {
+ path: `${ settings.rest.routes.summary }?period=${ period }`,
+ } );
+ setSummary( response );
+ } catch ( apiError ) {
+ showNotice( 'error', __( 'Unable to load summary data.', 'ai' ) );
+ } finally {
+ setSummaryLoading( false );
+ }
+ }, [] );
+
+ // Fetch logs
+ const fetchLogs = useCallback( async () => {
+ setLogsLoading( true );
+ try {
+ const params = new URLSearchParams( {
+ page: String( view.page ?? 1 ),
+ per_page: '25',
+ } );
+ const typeFilter = extractFilterValue( view.filters, 'type' );
+ const statusFilter = extractFilterValue( view.filters, 'status' );
+ const providerFilter = extractFilterValue(
+ view.filters,
+ 'provider'
+ );
+ const operationFilter = extractFilterArrayValue(
+ view.filters,
+ 'operation'
+ );
+ const operationPatternFilter = extractFilterValue(
+ view.filters,
+ 'operation_pattern'
+ );
+ const tokensFilterValue = extractFilterValue(
+ view.filters,
+ 'tokens_total'
+ );
+ const searchTerm = view.search ?? '';
+
+ if ( typeFilter ) {
+ params.append( 'type', typeFilter );
+ }
+ if ( statusFilter ) {
+ params.append( 'status', statusFilter );
+ }
+ if ( providerFilter ) {
+ params.append( 'provider', providerFilter );
+ }
+ if ( operationFilter.length > 0 ) {
+ params.append( 'operation', operationFilter.join( ',' ) );
+ }
+ if ( operationPatternFilter ) {
+ params.append( 'operation_pattern', operationPatternFilter );
+ }
+ if ( tokensFilterValue ) {
+ params.append( 'tokens_filter', tokensFilterValue );
+ }
+ if ( searchTerm ) {
+ params.append( 'search', searchTerm );
+ }
+
+ const response = ( await apiFetch< LogEntry[] >( {
+ path: `${ settings.rest.routes.logs }?${ params.toString() }`,
+ parse: false,
+ } ) ) as Response;
+
+ const data = await response.json();
+ setLogs( data );
+ setTotal(
+ parseInt( response.headers.get( 'X-WP-Total' ) || '0', 10 )
+ );
+ setTotalPages(
+ parseInt( response.headers.get( 'X-WP-TotalPages' ) || '1', 10 )
+ );
+ setError( null );
+ } catch ( apiError ) {
+ setError( getErrorMessage( apiError ) );
+ } finally {
+ setLogsLoading( false );
+ }
+ }, [ view.filters, view.page, view.search ] );
+
+ // Fetch filter options
+ const fetchFilters = useCallback( async () => {
+ try {
+ const response = await apiFetch< FilterOptions >( {
+ path: settings.rest.routes.filters,
+ } );
+ // Ensure operations array exists (fallback for older backends)
+ setFilterOptions( {
+ ...response,
+ operations: response.operations ?? [],
+ } );
+ } catch ( apiError ) {
+ showNotice(
+ 'error',
+ __( 'Unable to load filter metadata.', 'ai' )
+ );
+ }
+ }, [] );
+
+ // Initial load
+ useEffect( () => {
+ fetchLogs();
+ fetchFilters();
+ }, [ fetchLogs, fetchFilters ] );
+
+ // Handle period change
+ const handlePeriodChange = (
+ period: 'minute' | 'hour' | 'day' | 'week' | 'month' | 'all'
+ ) => {
+ setSummaryPeriod( period );
+ fetchSummary( period );
+ };
+
+ // Handle settings update
+ const handleSettingsUpdate = async (
+ newEnabled?: boolean,
+ newRetention?: number
+ ) => {
+ setSaving( true );
+ try {
+ const data: Record< string, unknown > = {};
+ if ( newEnabled !== undefined ) {
+ data.enabled = newEnabled;
+ }
+ if ( newRetention !== undefined ) {
+ data.retention_days = newRetention;
+ }
+
+ await apiFetch( {
+ path: settings.rest.routes.logs,
+ method: 'POST',
+ data,
+ } );
+
+ if ( newEnabled !== undefined ) {
+ setEnabled( newEnabled );
+ }
+ if ( newRetention !== undefined ) {
+ setRetentionDays( newRetention );
+ }
+
+ showNotice( 'success', __( 'Settings saved.', 'ai' ) );
+ } catch ( apiError ) {
+ showNotice( 'error', getErrorMessage( apiError ) );
+ } finally {
+ setSaving( false );
+ }
+ };
+
+ // Handle purge
+ const handlePurge = async () => {
+ setPurging( true );
+ try {
+ await apiFetch( {
+ path: settings.rest.routes.logs,
+ method: 'DELETE',
+ } );
+
+ // Clear logs immediately for instant UI feedback
+ setLogs( [] );
+ setTotal( 0 );
+ setTotalPages( 1 );
+ setView( ( prev ) => ( { ...prev, page: 1 } ) );
+
+ showNotice( 'success', __( 'All logs have been purged.', 'ai' ) );
+ fetchSummary( summaryPeriod );
+ fetchFilters();
+ } catch ( apiError ) {
+ showNotice( 'error', getErrorMessage( apiError ) );
+ } finally {
+ setPurging( false );
+ }
+ };
+
+ return (
+
+
+
+ { error && (
+
setError( null ) }>
+ { error }
+
+ ) }
+
+
+
+
+
+
+
+ handleSettingsUpdate( value, undefined )
+ }
+ onRetentionChange={ ( value ) =>
+ handleSettingsUpdate( undefined, value )
+ }
+ onPurgeLogs={ handlePurge }
+ saving={ saving }
+ purging={ purging }
+ />
+
+
+ { selectedLog && (
+
setSelectedLog( null ) }
+ />
+ ) }
+
+ );
+};
+
+const mountNode = document.getElementById( 'ai-request-logs-root' );
+
+if ( mountNode ) {
+ const root = createRoot( mountNode );
+ root.render( );
+}
diff --git a/src/admin/ai-request-logs/style.scss b/src/admin/ai-request-logs/style.scss
new file mode 100644
index 00000000..b6243c39
--- /dev/null
+++ b/src/admin/ai-request-logs/style.scss
@@ -0,0 +1,469 @@
+@use '../dataviews';
+@use '../common';
+
+.ai-request-logs__app {
+ max-width: 1400px;
+
+ .components-notice {
+ margin: 0 0 20px;
+ }
+}
+
+// Summary Cards
+.ai-request-logs__summary {
+ margin-bottom: 24px;
+}
+
+.ai-request-logs__summary-cards {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
+ gap: 16px;
+}
+
+.ai-request-logs__stat-card {
+ text-align: center;
+
+ .components-card__body {
+ padding: 20px 16px;
+ }
+}
+
+.ai-request-logs__stat-value {
+ font-size: 28px;
+ font-weight: 600;
+ color: #1e1e1e;
+ line-height: 1.2;
+}
+
+.ai-request-logs__stat-label {
+ font-size: 13px;
+ color: #757575;
+ margin-top: 4px;
+}
+
+// Main layout
+.ai-request-logs__main {
+ display: grid;
+ grid-template-columns: 1fr 320px;
+ gap: 24px;
+ align-items: start;
+
+ @media (max-width: 1200px) {
+ grid-template-columns: 1fr;
+ }
+}
+
+// Cards
+.ai-request-logs__card {
+ .components-card__header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+
+ h2 {
+ margin: 0;
+ font-size: 14px;
+ font-weight: 600;
+ }
+ }
+}
+
+.ai-request-logs__count {
+ font-size: 13px;
+ color: #757575;
+}
+
+// Filters
+.ai-request-logs__dataviews {
+ margin-top: 16px;
+}
+
+.ai-request-logs__cell--time {
+ white-space: nowrap;
+ color: #757575;
+ font-size: 12px;
+}
+
+.ai-request-logs__operation {
+ code {
+ font-size: 12px;
+ background: #f0f0f0;
+ padding: 2px 6px;
+ border-radius: 3px;
+ }
+}
+
+.ai-request-logs__error-preview {
+ font-size: 11px;
+ color: #d63638;
+ margin-top: 4px;
+}
+
+// Provider cell - two row layout
+.ai-request-logs__provider-cell {
+ position: relative;
+ cursor: default;
+}
+
+.ai-request-logs__provider-row {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.ai-request-logs__provider-icon {
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ color: #757575;
+
+ svg {
+ width: 16px;
+ height: 16px;
+ }
+}
+
+.ai-request-logs__provider-name {
+ font-size: 13px;
+ font-weight: 400;
+ color: #757575;
+}
+
+.ai-request-logs__model-row {
+ font-size: 11px;
+ color: #757575;
+ margin-top: 2px;
+ padding-left: 22px; // align with text after icon
+}
+
+.ai-request-logs__cell-muted {
+ color: #757575;
+}
+
+// White popover styling
+.ai-request-logs__provider-popover {
+ .components-popover__content {
+ background: #fff;
+ border: 1px solid #e0e0e0;
+ border-radius: 4px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
+ }
+}
+
+.ai-request-logs__popover-content {
+ padding: 12px;
+ min-width: 200px;
+}
+
+.ai-request-logs__popover-header {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 8px;
+}
+
+.ai-request-logs__popover-icon {
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ color: #1e1e1e;
+
+ svg {
+ width: 20px;
+ height: 20px;
+ }
+}
+
+.ai-request-logs__popover-title {
+ font-size: 14px;
+ font-weight: 600;
+ color: #1e1e1e;
+}
+
+.ai-request-logs__popover-badge {
+ font-size: 10px;
+ font-weight: 500;
+ text-transform: uppercase;
+ padding: 2px 6px;
+ border-radius: 3px;
+ background: #f0f0f0;
+ color: #757575;
+}
+
+.ai-request-logs__popover-model {
+ font-size: 12px;
+ color: #757575;
+ padding: 8px 0;
+ border-bottom: 1px solid #f0f0f0;
+ margin-bottom: 8px;
+}
+
+.ai-request-logs__popover-links {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.ai-request-logs__popover-link {
+ font-size: 12px;
+ color: #2271b1;
+ text-decoration: none;
+
+ &:hover {
+ color: #135e96;
+ text-decoration: underline;
+ }
+}
+
+// Status badges
+.ai-request-logs__status {
+ display: inline-block;
+ padding: 2px 8px;
+ border-radius: 10px;
+ font-size: 11px;
+ font-weight: 500;
+ text-transform: uppercase;
+}
+
+.ai-request-logs__status--success {
+ background: #d4edda;
+ color: #155724;
+}
+
+.ai-request-logs__status--error {
+ background: #f8d7da;
+ color: #721c24;
+}
+
+.ai-request-logs__status--timeout {
+ background: #fff3cd;
+ color: #856404;
+}
+
+.ai-request-logs__kind {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 2px 8px;
+ border-radius: 999px;
+ font-size: 11px;
+ font-weight: 500;
+ text-transform: capitalize;
+ background: #f6f7f7;
+ color: #1d2327;
+}
+
+.ai-request-logs__kind--image {
+ background: #fff7ed;
+ color: #c2410c;
+}
+
+.ai-request-logs__kind--embeddings {
+ background: #eef2ff;
+ color: #4338ca;
+}
+
+.ai-request-logs__image-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
+ gap: 12px;
+}
+
+.ai-request-logs__image-grid figure {
+ margin: 0;
+}
+
+.ai-request-logs__image-grid img {
+ width: 100%;
+ height: auto;
+ border-radius: 4px;
+ background: #f6f7f7;
+ object-fit: cover;
+}
+
+.ai-request-logs__image-grid figcaption {
+ margin-top: 4px;
+ font-size: 11px;
+ color: var(--wp-components-color-foreground-muted, #5c5f62);
+}
+
+.ai-request-logs__empty {
+ text-align: center;
+ color: #757575;
+ padding: 40px;
+}
+
+.ai-provider-tooltip {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ max-width: 280px;
+}
+
+.ai-provider-tooltip__section-title {
+ font-size: 12px;
+ text-transform: uppercase;
+ color: var(--wp-components-color-foreground-muted, #50575e);
+}
+
+.ai-provider-tooltip__models ul {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.ai-provider-tooltip__models li {
+ font-size: 12px;
+}
+
+.ai-provider-tooltip__capabilities {
+ display: block;
+ font-size: 11px;
+ color: var(--wp-components-color-foreground-muted, #50575e);
+}
+
+.ai-provider-tooltip__link,
+.ai-provider-tooltip__hint,
+.ai-provider-tooltip__type,
+.ai-provider-tooltip__model {
+ font-size: 11px;
+ color: var(--wp-components-color-foreground-muted, #50575e);
+}
+
+// Settings panel
+.ai-request-logs__settings {
+ .components-toggle-control {
+ margin-bottom: 24px;
+ }
+
+ .components-range-control {
+ margin-bottom: 24px;
+ }
+}
+
+.ai-request-logs__settings-danger {
+ margin-top: 24px;
+ padding-top: 24px;
+ border-top: 1px solid #e0e0e0;
+
+ h3 {
+ color: #d63638;
+ font-size: 14px;
+ margin: 0 0 8px;
+ }
+
+ .description {
+ margin-bottom: 12px;
+ }
+}
+
+.ai-request-logs__purge-confirm {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+
+ span {
+ font-weight: 500;
+ }
+}
+
+// Modal
+.ai-request-logs__modal {
+ .components-modal__content {
+ width: 100%;
+ max-width: none;
+ }
+}
+
+.ai-request-logs__detail {
+ font-size: 13px;
+}
+
+.ai-request-logs__detail-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 20px;
+ padding-bottom: 16px;
+ border-bottom: 1px solid #e0e0e0;
+}
+
+.ai-request-logs__detail-operation {
+ font-size: 16px;
+ background: #f0f0f0;
+ padding: 4px 12px;
+ border-radius: 4px;
+}
+
+.ai-request-logs__detail-section {
+ margin-bottom: 20px;
+
+ h3 {
+ font-size: 13px;
+ font-weight: 600;
+ margin: 0 0 12px;
+ color: #1e1e1e;
+ }
+}
+
+.ai-request-logs__detail-section--error {
+ h3 {
+ color: #d63638;
+ }
+}
+
+.ai-request-logs__detail-table {
+ width: 100%;
+
+ th,
+ td {
+ padding: 8px 0;
+ border-bottom: 1px solid #f0f0f0;
+ }
+
+ th {
+ width: 140px;
+ font-weight: 500;
+ color: #757575;
+ text-align: left;
+ }
+
+ td {
+ color: #1e1e1e;
+ }
+}
+
+.ai-request-logs__detail-error,
+.ai-request-logs__detail-context {
+ background: #f6f7f7;
+ padding: 12px;
+ border-radius: 4px;
+ overflow-x: auto;
+ font-size: 12px;
+ font-family: monospace;
+ white-space: pre-wrap;
+ word-break: break-word;
+ margin: 0;
+}
+
+.ai-request-logs__detail-error {
+ background: #fcf0f1;
+ color: #8a1f1f;
+}
+
+.ai-request-logs__detail-footer {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-top: 20px;
+ padding-top: 16px;
+ border-top: 1px solid #e0e0e0;
+}
+
+.ai-request-logs__detail-id {
+ font-size: 11px;
+ color: #757575;
+}
diff --git a/src/admin/ai-request-logs/types.ts b/src/admin/ai-request-logs/types.ts
new file mode 100644
index 00000000..ef73911d
--- /dev/null
+++ b/src/admin/ai-request-logs/types.ts
@@ -0,0 +1,85 @@
+import type { ProviderMetadataMap } from '../types/providers';
+
+export interface LogEntry {
+ id: string;
+ timestamp: string;
+ type: 'ai_client' | 'mcp_tool' | 'ability';
+ operation: string;
+ provider: string | null;
+ model: string | null;
+ duration_ms: number | null;
+ tokens_input: number | null;
+ tokens_output: number | null;
+ tokens_total: number | null;
+ tokens_per_second: number | null;
+ cost_estimate: number | null;
+ status: 'success' | 'error' | 'timeout';
+ error_message: string | null;
+ user_id: number | null;
+ context: Record< string, unknown > | null;
+}
+
+export interface LogSummary {
+ total_requests: number;
+ total_tokens: number;
+ total_cost: number;
+ avg_duration_ms: number;
+ success_rate: number;
+ by_type: Record< string, number >;
+ by_provider: Record< string, number >;
+ by_status: Record< string, number >;
+}
+
+export interface OperationPatternFilter {
+ label: string;
+ pattern: string;
+ description?: string;
+}
+
+export interface FilterOptions {
+ types: string[];
+ providers: string[];
+ statuses: string[];
+ operations: string[];
+ operationPatterns?: OperationPatternFilter[];
+}
+
+export interface LogFilters {
+ type: string;
+ status: string;
+ provider: string;
+ operation: string[];
+ search: string;
+ period: 'day' | 'week' | 'month' | 'all';
+}
+
+export interface LogSettings {
+ enabled: boolean;
+ retentionDays: number;
+}
+
+export interface LocalizedSettings {
+ rest: {
+ nonce: string;
+ root: string;
+ routes: {
+ logs: string;
+ summary: string;
+ filters: string;
+ };
+ };
+ initialState: {
+ enabled: boolean;
+ retentionDays: number;
+ summary: LogSummary;
+ filters: FilterOptions;
+ };
+ providerMetadata: ProviderMetadataMap;
+}
+
+declare global {
+ interface Window {
+ AiRequestLogsSettings?: LocalizedSettings;
+ aiAiRequestLogsSettings?: LocalizedSettings;
+ }
+}
diff --git a/src/admin/components/ProviderTooltipContent.tsx b/src/admin/components/ProviderTooltipContent.tsx
new file mode 100644
index 00000000..49f48a9e
--- /dev/null
+++ b/src/admin/components/ProviderTooltipContent.tsx
@@ -0,0 +1,81 @@
+/**
+ * WordPress dependencies
+ */
+import { __, sprintf } from '@wordpress/i18n';
+
+/**
+ * Internal dependencies
+ */
+import type { ProviderMetadata } from '../types/providers';
+
+interface ProviderTooltipContentProps {
+ metadata: ProviderMetadata;
+ activeModel?: string | null;
+}
+
+const ProviderTooltipContent: React.FC< ProviderTooltipContentProps > = ( {
+ metadata,
+ activeModel,
+} ) => {
+ const topModels = metadata.models?.slice( 0, 4 ) ?? [];
+
+ return (
+
+
+
+ { metadata.name }
+
+ { metadata.type === 'client'
+ ? __( 'Local', 'ai' )
+ : __( 'Cloud', 'ai' ) }
+
+
+ { activeModel && (
+
+ { sprintf(
+ /* translators: %s: AI model name. */
+ __( 'Requested model: %s', 'ai' ),
+ activeModel
+ ) }
+
+ ) }
+ { metadata.tooltip && (
+
{ metadata.tooltip }
+ ) }
+ { topModels.length > 0 && (
+
+
+ { __( 'Available models', 'ai' ) }
+
+
+ { topModels.map( ( model ) => (
+ -
+ { model.name }
+ { model.capabilities?.length > 0 && (
+
+ { model.capabilities.join( ', ' ) }
+
+ ) }
+
+ ) ) }
+
+
+ ) }
+
+ { metadata.url && (
+
+ ) }
+
+ );
+};
+
+export default ProviderTooltipContent;
diff --git a/src/admin/components/icons/AiIcon.tsx b/src/admin/components/icons/AiIcon.tsx
new file mode 100644
index 00000000..f1daf850
--- /dev/null
+++ b/src/admin/components/icons/AiIcon.tsx
@@ -0,0 +1,26 @@
+/**
+ * External dependencies
+ */
+import type { SVGProps } from 'react';
+
+/**
+ * AI Icon - Cauldron with sparkles design used for AI features.
+ */
+const AiIcon = ( props: SVGProps< SVGSVGElement > ) => (
+
+);
+
+export default AiIcon;
diff --git a/src/admin/components/icons/AnthropicIcon.tsx b/src/admin/components/icons/AnthropicIcon.tsx
new file mode 100644
index 00000000..d64f80bd
--- /dev/null
+++ b/src/admin/components/icons/AnthropicIcon.tsx
@@ -0,0 +1,22 @@
+/**
+ * External dependencies
+ */
+import type { SVGProps } from 'react';
+
+/**
+ * Anthropic (Claude) Icon
+ */
+const AnthropicIcon = ( props: SVGProps< SVGSVGElement > ) => (
+
+);
+
+export default AnthropicIcon;
diff --git a/src/admin/components/icons/CloudflareIcon.tsx b/src/admin/components/icons/CloudflareIcon.tsx
new file mode 100644
index 00000000..804c6573
--- /dev/null
+++ b/src/admin/components/icons/CloudflareIcon.tsx
@@ -0,0 +1,29 @@
+/**
+ * External dependencies
+ */
+import type { SVGProps } from 'react';
+
+/**
+ * Cloudflare Icon
+ */
+const CloudflareIcon = ( props: SVGProps< SVGSVGElement > ) => (
+
+);
+
+export default CloudflareIcon;
diff --git a/src/admin/components/icons/DeepSeekIcon.tsx b/src/admin/components/icons/DeepSeekIcon.tsx
new file mode 100644
index 00000000..effcfe19
--- /dev/null
+++ b/src/admin/components/icons/DeepSeekIcon.tsx
@@ -0,0 +1,22 @@
+/**
+ * External dependencies
+ */
+import type { SVGProps } from 'react';
+
+/**
+ * DeepSeek Icon
+ */
+const DeepSeekIcon = ( props: SVGProps< SVGSVGElement > ) => (
+
+);
+
+export default DeepSeekIcon;
diff --git a/src/admin/components/icons/FalIcon.tsx b/src/admin/components/icons/FalIcon.tsx
new file mode 100644
index 00000000..e5f43adb
--- /dev/null
+++ b/src/admin/components/icons/FalIcon.tsx
@@ -0,0 +1,26 @@
+/**
+ * External dependencies
+ */
+import type { SVGProps } from 'react';
+
+/**
+ * Fal.ai Icon
+ */
+const FalIcon = ( props: SVGProps< SVGSVGElement > ) => (
+
+);
+
+export default FalIcon;
diff --git a/src/admin/components/icons/GoogleIcon.tsx b/src/admin/components/icons/GoogleIcon.tsx
new file mode 100644
index 00000000..5ec6b0ee
--- /dev/null
+++ b/src/admin/components/icons/GoogleIcon.tsx
@@ -0,0 +1,22 @@
+/**
+ * External dependencies
+ */
+import type { SVGProps } from 'react';
+
+/**
+ * Google Icon
+ */
+const GoogleIcon = ( props: SVGProps< SVGSVGElement > ) => (
+
+);
+
+export default GoogleIcon;
diff --git a/src/admin/components/icons/GrokIcon.tsx b/src/admin/components/icons/GrokIcon.tsx
new file mode 100644
index 00000000..e6fcb836
--- /dev/null
+++ b/src/admin/components/icons/GrokIcon.tsx
@@ -0,0 +1,22 @@
+/**
+ * External dependencies
+ */
+import type { SVGProps } from 'react';
+
+/**
+ * Grok Icon
+ */
+const GrokIcon = ( props: SVGProps< SVGSVGElement > ) => (
+
+);
+
+export default GrokIcon;
diff --git a/src/admin/components/icons/GroqIcon.tsx b/src/admin/components/icons/GroqIcon.tsx
new file mode 100644
index 00000000..4f87cfd2
--- /dev/null
+++ b/src/admin/components/icons/GroqIcon.tsx
@@ -0,0 +1,22 @@
+/**
+ * External dependencies
+ */
+import type { SVGProps } from 'react';
+
+/**
+ * Groq Icon
+ */
+const GroqIcon = ( props: SVGProps< SVGSVGElement > ) => (
+
+);
+
+export default GroqIcon;
diff --git a/src/admin/components/icons/HuggingFaceIcon.tsx b/src/admin/components/icons/HuggingFaceIcon.tsx
new file mode 100644
index 00000000..ba89ab6d
--- /dev/null
+++ b/src/admin/components/icons/HuggingFaceIcon.tsx
@@ -0,0 +1,45 @@
+/**
+ * External dependencies
+ */
+import type { SVGProps } from 'react';
+
+/**
+ * Hugging Face Icon
+ */
+const HuggingFaceIcon = ( props: SVGProps< SVGSVGElement > ) => (
+
+);
+
+export default HuggingFaceIcon;
diff --git a/src/admin/components/icons/McpIcon.tsx b/src/admin/components/icons/McpIcon.tsx
new file mode 100644
index 00000000..abe8496a
--- /dev/null
+++ b/src/admin/components/icons/McpIcon.tsx
@@ -0,0 +1,25 @@
+/**
+ * External dependencies
+ */
+import type { SVGProps } from 'react';
+
+/**
+ * MCP Icon - Model Context Protocol logo.
+ */
+const McpIcon = ( props: SVGProps< SVGSVGElement > ) => (
+
+);
+
+export default McpIcon;
diff --git a/src/admin/components/icons/OllamaIcon.tsx b/src/admin/components/icons/OllamaIcon.tsx
new file mode 100644
index 00000000..eb7211df
--- /dev/null
+++ b/src/admin/components/icons/OllamaIcon.tsx
@@ -0,0 +1,25 @@
+/**
+ * External dependencies
+ */
+import type { SVGProps } from 'react';
+
+/**
+ * Ollama Icon
+ */
+const OllamaIcon = ( props: SVGProps< SVGSVGElement > ) => (
+
+);
+
+export default OllamaIcon;
diff --git a/src/admin/components/icons/OpenAiIcon.tsx b/src/admin/components/icons/OpenAiIcon.tsx
new file mode 100644
index 00000000..6d4803c3
--- /dev/null
+++ b/src/admin/components/icons/OpenAiIcon.tsx
@@ -0,0 +1,22 @@
+/**
+ * External dependencies
+ */
+import type { SVGProps } from 'react';
+
+/**
+ * OpenAI Icon
+ */
+const OpenAiIcon = ( props: SVGProps< SVGSVGElement > ) => (
+
+);
+
+export default OpenAiIcon;
diff --git a/src/admin/components/icons/OpenRouterIcon.tsx b/src/admin/components/icons/OpenRouterIcon.tsx
new file mode 100644
index 00000000..1d769c21
--- /dev/null
+++ b/src/admin/components/icons/OpenRouterIcon.tsx
@@ -0,0 +1,25 @@
+/**
+ * External dependencies
+ */
+import type { SVGProps } from 'react';
+
+/**
+ * OpenRouter Icon
+ */
+const OpenRouterIcon = ( props: SVGProps< SVGSVGElement > ) => (
+
+);
+
+export default OpenRouterIcon;
diff --git a/src/admin/components/icons/XaiIcon.tsx b/src/admin/components/icons/XaiIcon.tsx
new file mode 100644
index 00000000..aeca554c
--- /dev/null
+++ b/src/admin/components/icons/XaiIcon.tsx
@@ -0,0 +1,22 @@
+/**
+ * External dependencies
+ */
+import type { SVGProps } from 'react';
+
+/**
+ * xAI Icon
+ */
+const XaiIcon = ( props: SVGProps< SVGSVGElement > ) => (
+
+);
+
+export default XaiIcon;
diff --git a/src/admin/components/icons/index.ts b/src/admin/components/icons/index.ts
new file mode 100644
index 00000000..a950307d
--- /dev/null
+++ b/src/admin/components/icons/index.ts
@@ -0,0 +1,14 @@
+export { default as AiIcon } from './AiIcon';
+export { default as AnthropicIcon } from './AnthropicIcon';
+export { default as CloudflareIcon } from './CloudflareIcon';
+export { default as DeepSeekIcon } from './DeepSeekIcon';
+export { default as FalIcon } from './FalIcon';
+export { default as GoogleIcon } from './GoogleIcon';
+export { default as GroqIcon } from './GroqIcon';
+export { default as GrokIcon } from './GrokIcon';
+export { default as HuggingFaceIcon } from './HuggingFaceIcon';
+export { default as McpIcon } from './McpIcon';
+export { default as OpenAiIcon } from './OpenAiIcon';
+export { default as OpenRouterIcon } from './OpenRouterIcon';
+export { default as OllamaIcon } from './OllamaIcon';
+export { default as XaiIcon } from './XaiIcon';
diff --git a/src/admin/components/provider-icons.tsx b/src/admin/components/provider-icons.tsx
new file mode 100644
index 00000000..c7580348
--- /dev/null
+++ b/src/admin/components/provider-icons.tsx
@@ -0,0 +1,56 @@
+/**
+ * External dependencies
+ */
+import type { ComponentType, SVGProps } from 'react';
+
+/**
+ * Internal dependencies
+ */
+import {
+ AiIcon,
+ AnthropicIcon,
+ CloudflareIcon,
+ DeepSeekIcon,
+ FalIcon,
+ GoogleIcon,
+ GrokIcon,
+ GroqIcon,
+ HuggingFaceIcon,
+ OllamaIcon,
+ OpenAiIcon,
+ OpenRouterIcon,
+ XaiIcon,
+} from './icons';
+
+const ICON_COMPONENTS: Record< string, ComponentType< SVGProps< SVGSVGElement > > > =
+ Object.freeze( {
+ anthropic: AnthropicIcon,
+ openai: OpenAiIcon,
+ google: GoogleIcon,
+ fal: FalIcon,
+ 'fal-ai': FalIcon,
+ deepseek: DeepSeekIcon,
+ cloudflare: CloudflareIcon,
+ huggingface: HuggingFaceIcon,
+ ollama: OllamaIcon,
+ openrouter: OpenRouterIcon,
+ groq: GroqIcon,
+ grok: GrokIcon,
+ xai: XaiIcon,
+ default: AiIcon,
+ } );
+
+export const getProviderIconComponent = (
+ iconKey?: string,
+ fallbackKey?: string
+): ComponentType< SVGProps< SVGSVGElement > > => {
+ const normalized =
+ ( iconKey || fallbackKey || '' ).toLowerCase().replace( /\s+/g, '' );
+
+ return (
+ ICON_COMPONENTS[ normalized ] ||
+ ICON_COMPONENTS[ iconKey || '' ] ||
+ ICON_COMPONENTS[ fallbackKey || '' ] ||
+ ICON_COMPONENTS.default
+ );
+};
diff --git a/src/admin/hooks/usePersistedView.ts b/src/admin/hooks/usePersistedView.ts
new file mode 100644
index 00000000..7305648f
--- /dev/null
+++ b/src/admin/hooks/usePersistedView.ts
@@ -0,0 +1,99 @@
+/**
+ * WordPress dependencies
+ */
+import type { View } from '@wordpress/dataviews';
+import { useView } from '@wordpress/views';
+
+/**
+ * External dependencies
+ */
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+
+interface PersistedViewReturn< T extends View > {
+ view: T;
+ setView: ( next: T | ( ( prev: T ) => T ) ) => void;
+ resetView: () => void;
+ isModified: boolean;
+}
+
+const VIEW_KIND = 'root';
+const VIEW_NAME = 'ai-admin';
+
+/**
+ * Thin wrapper around `@wordpress/views` that mimics the previous
+ * usePersistedView signature so existing DataViews components can persist
+ * their view configuration using WordPress preferences (available in 6.9+).
+ */
+export function usePersistedView< T extends View >(
+ slug: string,
+ defaultView: T
+): PersistedViewReturn< T > {
+ const defaultQuery = useMemo(
+ () => ( {
+ page: defaultView.page ?? 1,
+ search: defaultView.search ?? '',
+ } ),
+ [ defaultView.page, defaultView.search ]
+ );
+
+ const [ queryParams, setQueryParams ] = useState( defaultQuery );
+
+ // Keep query params in sync if defaults change (should be rare).
+ useEffect( () => {
+ setQueryParams( ( previous ) => ( {
+ page: previous.page ?? defaultQuery.page,
+ search:
+ typeof previous.search === 'string'
+ ? previous.search
+ : defaultQuery.search,
+ } ) );
+ }, [ defaultQuery.page, defaultQuery.search ] );
+
+ const { view, updateView, resetToDefault, isModified } = useView( {
+ kind: VIEW_KIND,
+ name: VIEW_NAME,
+ slug,
+ defaultView,
+ queryParams,
+ onChangeQueryParams: ( params ) => {
+ setQueryParams( {
+ page:
+ typeof params.page === 'number'
+ ? params.page
+ : defaultQuery.page,
+ search:
+ typeof params.search === 'string'
+ ? params.search
+ : defaultQuery.search,
+ } );
+ },
+ } );
+
+ const latestViewRef = useRef< View >( view );
+ useEffect( () => {
+ latestViewRef.current = view;
+ }, [ view ] );
+
+ const setView = useCallback(
+ ( next: T | ( ( prev: T ) => T ) ) => {
+ updateView(
+ typeof next === 'function'
+ ? ( next as ( prev: T ) => T )( latestViewRef.current as T )
+ : next
+ );
+ },
+ [ updateView ]
+ );
+
+ const resetView = useCallback( () => {
+ resetToDefault();
+ setQueryParams( defaultQuery );
+ }, [ resetToDefault, defaultQuery ] );
+
+ return {
+ view: view as T,
+ setView,
+ resetView,
+ isModified,
+ };
+}
diff --git a/tests/Integration/Includes/Logging/AI_Request_Log_ManagerTest.php b/tests/Integration/Includes/Logging/AI_Request_Log_ManagerTest.php
new file mode 100644
index 00000000..fb01d2a3
--- /dev/null
+++ b/tests/Integration/Includes/Logging/AI_Request_Log_ManagerTest.php
@@ -0,0 +1,148 @@
+manager = new AI_Request_Log_Manager();
+ $this->manager->init();
+
+ global $wpdb;
+ $table = $wpdb->prefix . 'ai_request_logs';
+ $wpdb->query( "TRUNCATE TABLE {$table}" ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
+
+ delete_option( AI_Request_Log_Manager::OPTION_LOGGING_ENABLED );
+ }
+
+ public function test_log_returns_false_when_logging_disabled(): void {
+ $this->manager->set_logging_enabled( false );
+
+ $result = $this->manager->log(
+ array(
+ 'type' => 'ui',
+ 'operation' => 'completion',
+ 'status' => 'success',
+ )
+ );
+
+ $this->assertFalse( $result );
+
+ global $wpdb;
+ $table = $wpdb->prefix . 'ai_request_logs';
+ $count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table}" ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
+ $this->assertSame( 0, $count );
+ }
+
+ public function test_log_persists_entry_when_enabled(): void {
+ $this->manager->set_logging_enabled( true );
+
+ $log_id = $this->manager->log(
+ array(
+ 'type' => 'ui',
+ 'operation' => 'completion',
+ 'provider' => 'openai',
+ 'model' => 'gpt-5-nano',
+ 'duration_ms' => 120,
+ 'tokens_input' => 200,
+ 'tokens_output' => 50,
+ 'status' => 'success',
+ 'user_id' => get_current_user_id(),
+ 'context' => array( 'ability' => 'ai/example' ),
+ )
+ );
+
+ $this->assertNotFalse( $log_id );
+ $this->assertIsString( $log_id );
+
+ global $wpdb;
+ $table = $wpdb->prefix . 'ai_request_logs';
+ $row = $wpdb->get_row( // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
+ $wpdb->prepare(
+ "SELECT operation, status, tokens_total, cost_estimate FROM {$table} WHERE log_id = %s",
+ $log_id
+ ),
+ ARRAY_A
+ );
+
+ $this->assertIsArray( $row );
+ $this->assertSame( 'completion', $row['operation'] );
+ $this->assertSame( 'success', $row['status'] );
+ $this->assertSame( 250, (int) $row['tokens_total'] );
+ $this->assertGreaterThan( 0, (float) $row['cost_estimate'] );
+ }
+
+ public function test_get_logs_search_matches_request_preview(): void {
+ $this->manager->set_logging_enabled( true );
+
+ $matching_id = $this->manager->log(
+ array(
+ 'type' => 'ai_client',
+ 'operation' => 'openai:images/generations',
+ 'status' => 'success',
+ 'context' => array(
+ 'input_preview' => 'Prompt: A llama sitting on a mountain',
+ ),
+ )
+ );
+
+ // Non matching entry to ensure search filters correctly.
+ $this->manager->log(
+ array(
+ 'type' => 'ai_client',
+ 'operation' => 'openai:images/generations',
+ 'status' => 'success',
+ 'context' => array(
+ 'input_preview' => 'Prompt: Sunset over the ocean',
+ ),
+ )
+ );
+
+ $result = $this->manager->get_logs(
+ array(
+ 'search' => 'llama',
+ )
+ );
+
+ $this->assertSame( 1, $result['total'] );
+ $this->assertSame( $matching_id, $result['items'][0]['id'] );
+ }
+
+ public function test_get_logs_search_falls_back_for_short_terms(): void {
+ $this->manager->set_logging_enabled( true );
+
+ $matching_id = $this->manager->log(
+ array(
+ 'type' => 'ai_client',
+ 'operation' => 'openai:responses',
+ 'status' => 'success',
+ 'context' => array(
+ 'input_preview' => 'AI prompt about colors',
+ ),
+ )
+ );
+
+ $result = $this->manager->get_logs(
+ array(
+ 'search' => 'AI',
+ )
+ );
+
+ $this->assertSame( 1, $result['total'] );
+ $this->assertSame( $matching_id, $result['items'][0]['id'] );
+ }
+}
diff --git a/webpack.config.js b/webpack.config.js
index 49a296e9..4e81f2c9 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -19,6 +19,11 @@ module.exports = {
'src/admin/settings',
'index.scss'
),
+ 'admin/ai-request-logs': path.resolve(
+ process.cwd(),
+ 'src/admin/ai-request-logs',
+ 'index.tsx'
+ ),
'experiments/example-experiment': path.resolve(
process.cwd(),
'src/experiments/example-experiment',
From 22384c7556ff6f50b80bb3d2b897323b135cf54d Mon Sep 17 00:00:00 2001
From: James LePage <36246732+Jameswlepage@users.noreply.github.com>
Date: Thu, 18 Dec 2025 18:20:23 -0500
Subject: [PATCH 2/8] Add Provider_Metadata_Registry dependency for log page
---
includes/Admin/Provider_Metadata_Registry.php | 289 ++++++++++++++++++
1 file changed, 289 insertions(+)
create mode 100644 includes/Admin/Provider_Metadata_Registry.php
diff --git a/includes/Admin/Provider_Metadata_Registry.php b/includes/Admin/Provider_Metadata_Registry.php
new file mode 100644
index 00000000..68bdd324
--- /dev/null
+++ b/includes/Admin/Provider_Metadata_Registry.php
@@ -0,0 +1,289 @@
+>
+ */
+ public static function get_metadata(): array {
+ $registry = AiClient::defaultRegistry();
+ $providers = array();
+ $overrides = self::get_branding_overrides();
+ $credentials = get_option( 'wp_ai_client_provider_credentials', array() );
+
+ foreach ( $registry->getRegisteredProviderIds() as $provider_id ) {
+ $class_name = $registry->getProviderClassName( $provider_id );
+
+ if ( ! method_exists( $class_name, 'metadata' ) ) {
+ continue;
+ }
+
+ /** @var ProviderMetadata $metadata */
+ $metadata = $class_name::metadata();
+ $brand = $overrides[ $metadata->getId() ] ?? array();
+
+ $providers[ $metadata->getId() ] = array(
+ 'id' => $metadata->getId(),
+ 'name' => $metadata->getName(),
+ 'type' => $metadata->getType()->value,
+ 'icon' => $brand['icon'] ?? $metadata->getId(),
+ 'initials' => $brand['initials'] ?? self::get_initials( $metadata->getName() ),
+ 'color' => $brand['color'] ?? '#1d2327',
+ 'url' => $brand['url'] ?? '',
+ 'tooltip' => $brand['tooltip'] ?? '',
+ 'keepDescription' => ! empty( $brand['keepDescription'] ),
+ 'isConfigured' => self::has_credentials( $metadata->getId(), $credentials ),
+ 'models' => self::get_models_for_provider( $class_name, $metadata->getId(), $credentials ),
+ );
+ }
+
+ return $providers;
+ }
+
+ /**
+ * Builds a fallback initials string for providers without a brand override.
+ *
+ * @param string $name Provider display name.
+ * @return string
+ */
+ private static function get_initials( string $name ): string {
+ $parts = preg_split( '/\s+/', trim( $name ) );
+ if ( empty( $parts ) ) {
+ return strtoupper( substr( $name, 0, 2 ) );
+ }
+
+ $initials = '';
+ foreach ( $parts as $part ) {
+ $initials .= strtoupper( substr( $part, 0, 1 ) );
+ if ( strlen( $initials ) >= 2 ) {
+ break;
+ }
+ }
+
+ return substr( $initials, 0, 2 );
+ }
+
+ /**
+ * Retrieves model metadata for a provider.
+ *
+ * @param string $provider_class Provider class name.
+ * @return array>
+ */
+ private static function get_models_for_provider( string $provider_class, string $provider_id, array $credentials ): array {
+ if ( ! method_exists( $provider_class, 'modelMetadataDirectory' ) ) {
+ return array();
+ }
+
+ $cache_key = self::get_models_cache_key( $provider_id, $credentials[ $provider_id ] ?? '' );
+ if ( $cache_key ) {
+ $cached = get_transient( $cache_key );
+ if ( false !== $cached ) {
+ return $cached;
+ }
+ }
+
+ try {
+ $directory = $provider_class::modelMetadataDirectory();
+ $metadata = $directory->listModelMetadata();
+ } catch ( \Throwable $error ) {
+ return array();
+ }
+
+ $models = array();
+
+ foreach ( $metadata as $model_metadata ) {
+ if ( ! $model_metadata instanceof ModelMetadata ) {
+ continue;
+ }
+
+ $models[] = array(
+ 'id' => $model_metadata->getId(),
+ 'name' => $model_metadata->getName(),
+ 'capabilities' => array_map(
+ static function ( CapabilityEnum $capability ): string {
+ return $capability->value;
+ },
+ $model_metadata->getSupportedCapabilities()
+ ),
+ );
+ }
+
+ if ( $cache_key ) {
+ set_transient( $cache_key, $models, self::MODEL_CACHE_TTL );
+ }
+
+ return $models;
+ }
+
+ /**
+ * Determines whether stored credentials exist for a provider.
+ *
+ * @param string $provider_id Provider identifier.
+ * @param array $credentials Raw credentials map.
+ * @return bool
+ */
+ private static function has_credentials( string $provider_id, array $credentials ): bool {
+ if ( 'ollama' === $provider_id ) {
+ return true;
+ }
+
+ if ( ! isset( $credentials[ $provider_id ] ) ) {
+ return false;
+ }
+
+ $value = $credentials[ $provider_id ];
+ if ( is_array( $value ) ) {
+ $value = wp_json_encode( $value );
+ }
+
+ return is_string( $value ) && '' !== trim( $value );
+ }
+
+ /**
+ * Builds a cache key for provider models.
+ *
+ * @param string $provider_id Provider identifier.
+ * @param string|array $credential Credential value.
+ * @return string|null
+ */
+ private static function get_models_cache_key( string $provider_id, $credential ): ?string {
+ if ( '' === $provider_id ) {
+ return null;
+ }
+
+ if ( is_array( $credential ) ) {
+ $credential = wp_json_encode( $credential );
+ }
+
+ return 'ai_provider_models_' . md5( $provider_id . '|' . (string) $credential );
+ }
+
+ /**
+ * Defines manual branding overrides per provider ID.
+ *
+ * @return array>
+ */
+ private static function get_branding_overrides(): array {
+ $link_template = esc_html__( 'Create and manage your %s API keys in these account settings.', 'ai' );
+
+ return array(
+ 'anthropic' => array(
+ 'icon' => 'anthropic',
+ 'initials' => 'An',
+ 'color' => '#111111',
+ 'url' => 'https://console.anthropic.com/settings/keys',
+ 'tooltip' => sprintf( $link_template, 'Anthropic' ),
+ ),
+ 'cohere' => array(
+ 'color' => '#6f2cff',
+ 'url' => 'https://dashboard.cohere.com/api-keys',
+ 'tooltip' => sprintf( $link_template, 'Cohere' ),
+ ),
+ 'cloudflare' => array(
+ 'icon' => 'cloudflare',
+ 'color' => '#f3801a',
+ 'url' => 'https://dash.cloudflare.com/profile/api-tokens',
+ 'tooltip' => sprintf( $link_template, 'Cloudflare Workers AI' ),
+ ),
+ 'deepseek' => array(
+ 'icon' => 'deepseek',
+ 'color' => '#0f172a',
+ 'url' => 'https://platform.deepseek.com/api_keys',
+ 'tooltip' => sprintf( $link_template, 'DeepSeek' ),
+ ),
+ 'fal' => array(
+ 'icon' => 'fal',
+ 'color' => '#0ea5e9',
+ 'url' => 'https://fal.ai/dashboard/keys',
+ 'tooltip' => sprintf( $link_template, 'Fal.ai' ),
+ ),
+ 'fal-ai' => array(
+ 'icon' => 'fal-ai',
+ 'color' => '#0ea5e9',
+ 'url' => 'https://fal.ai/dashboard/keys',
+ 'tooltip' => sprintf( $link_template, 'Fal.ai' ),
+ ),
+ 'grok' => array(
+ 'icon' => 'grok',
+ 'color' => '#ff6f00',
+ 'url' => 'https://console.x.ai/api-keys',
+ 'tooltip' => sprintf( $link_template, 'Grok' ),
+ ),
+ 'groq' => array(
+ 'icon' => 'groq',
+ 'color' => '#f43f5e',
+ 'url' => 'https://console.groq.com/keys',
+ 'tooltip' => sprintf( $link_template, 'Groq' ),
+ ),
+ 'google' => array(
+ 'icon' => 'google',
+ 'color' => '#4285f4',
+ 'url' => 'https://aistudio.google.com/app/api-keys',
+ 'tooltip' => sprintf( $link_template, 'Google' ),
+ ),
+ 'huggingface' => array(
+ 'icon' => 'huggingface',
+ 'color' => '#ffbe3c',
+ 'url' => 'https://huggingface.co/settings/tokens',
+ 'tooltip' => sprintf( $link_template, 'Hugging Face' ),
+ ),
+ 'openai' => array(
+ 'icon' => 'openai',
+ 'color' => '#10a37f',
+ 'url' => 'https://platform.openai.com/api-keys',
+ 'tooltip' => sprintf( $link_template, 'OpenAI' ),
+ ),
+ 'openrouter' => array(
+ 'icon' => 'openrouter',
+ 'color' => '#0f172a',
+ 'url' => 'https://openrouter.ai/settings/keys',
+ 'tooltip' => sprintf( $link_template, 'OpenRouter' ),
+ ),
+ 'ollama' => array(
+ 'icon' => 'ollama',
+ 'color' => '#111111',
+ 'tooltip' => esc_html__( 'Local Ollama instances at http://localhost:11434 do not require an API key. If you are calling https://ollama.com/api, create a key from your ollama.com account (for example via the dashboard or the `ollama signin` command) and paste it here.', 'ai' ),
+ 'keepDescription' => true,
+ ),
+ 'xai' => array(
+ 'icon' => 'xai',
+ 'color' => '#000000',
+ 'url' => 'https://console.x.ai/api-keys',
+ 'tooltip' => sprintf( $link_template, 'xAI' ),
+ ),
+ );
+ }
+}
From f18326c749394feca94378ba51566723bf357dea Mon Sep 17 00:00:00 2001
From: James LePage <36246732+Jameswlepage@users.noreply.github.com>
Date: Thu, 18 Dec 2025 18:21:52 -0500
Subject: [PATCH 3/8] Add get_ai_icon_svg helper and icon asset
---
assets/images/ai-icon.svg | 7 ++
includes/helpers.php | 191 ++++++++++++++++++++++++++++++++++----
2 files changed, 182 insertions(+), 16 deletions(-)
create mode 100644 assets/images/ai-icon.svg
diff --git a/assets/images/ai-icon.svg b/assets/images/ai-icon.svg
new file mode 100644
index 00000000..0c97519f
--- /dev/null
+++ b/assets/images/ai-icon.svg
@@ -0,0 +1,7 @@
+
diff --git a/includes/helpers.php b/includes/helpers.php
index e9a146d7..2e28f71a 100644
--- a/includes/helpers.php
+++ b/includes/helpers.php
@@ -9,8 +9,8 @@
namespace WordPress\AI;
-use Throwable;
-use WordPress\AI_Client\AI_Client;
+use WordPress\AI\Logging\AI_Request_Log_Manager;
+use WordPress\AI\Settings\Settings_Registration;
/**
* Normalizes the content by cleaning it and removing unwanted HTML tags.
@@ -119,6 +119,44 @@ function get_post_context( int $post_id ): array {
return $context;
}
+/**
+ * Extends HTTP timeout for OpenAI requests.
+ *
+ * The default WordPress timeout (5 seconds) is too short for some AI
+ * completions and results in cURL error 28. Increase it to 20 seconds
+ * whenever we call known AI REST endpoints, guarding against direct CLI
+ * execution where WordPress functions are unavailable.
+ *
+ * @since 0.1.0
+ *
+ * @param array $args HTTP request args.
+ * @param string $url Request URL.
+ * @return array
+ */
+if ( function_exists( 'add_filter' ) ) {
+ add_filter(
+ 'http_request_args',
+ static function ( array $args, string $url ): array {
+ $ai_hosts = array(
+ 'api.openai.com',
+ 'api.anthropic.com',
+ 'generativelanguage.googleapis.com',
+ );
+
+ foreach ( $ai_hosts as $host ) {
+ if ( false !== strpos( $url, $host ) ) {
+ $args['timeout'] = max( (float) ( $args['timeout'] ?? 5 ), 20 );
+ break;
+ }
+ }
+
+ return $args;
+ },
+ 10,
+ 2
+ );
+}
+
/**
* Returns the preferred models.
*
@@ -130,11 +168,11 @@ function get_preferred_models(): array {
$preferred_models = array(
array(
'anthropic',
- 'claude-haiku-4-5',
+ 'claude-3-5-sonnet-20240620',
),
array(
- 'google',
- 'gemini-2.5-flash',
+ 'anthropic',
+ 'claude-3-haiku-20240307',
),
array(
'openai',
@@ -142,7 +180,11 @@ function get_preferred_models(): array {
),
array(
'openai',
- 'gpt-4.1',
+ 'gpt-4o',
+ ),
+ array(
+ 'google',
+ 'gemini-1.5-flash',
),
);
@@ -154,7 +196,36 @@ function get_preferred_models(): array {
* @param array $preferred_models The preferred models.
* @return array The filtered preferred models.
*/
- return (array) apply_filters( 'ai_experiments_preferred_models', $preferred_models );
+ $preferred_models = (array) apply_filters( 'ai_experiments_preferred_models', $preferred_models );
+
+ $grouped = array(
+ 'anthropic' => array(),
+ 'openai' => array(),
+ 'google' => array(),
+ 'other' => array(),
+ );
+
+ foreach ( $preferred_models as $model ) {
+ if ( ! is_array( $model ) || count( $model ) < 2 ) {
+ continue;
+ }
+
+ $provider = strtolower( (string) $model[0] );
+
+ if ( isset( $grouped[ $provider ] ) ) {
+ $grouped[ $provider ][] = $model;
+ continue;
+ }
+
+ $grouped['other'][] = $model;
+ }
+
+ return array_merge(
+ $grouped['anthropic'],
+ $grouped['openai'],
+ $grouped['google'],
+ $grouped['other']
+ );
}
/**
@@ -253,27 +324,115 @@ function has_valid_ai_credentials(): bool {
return (bool) $valid;
}
- // See if we have credentials that give us access to generate text.
- try {
- return AI_Client::prompt( 'Test' )->is_supported_for_text_generation();
- } catch ( Throwable $t ) {
+ return true;
+}
+
+/**
+ * Checks if a specific experiment is enabled via global + per-experiment toggles.
+ *
+ * Mirrors {@see Abstract_Experiment::is_enabled()} so infrastructure that runs
+ * before experiments register can honor user settings.
+ *
+ * @since 0.1.0
+ *
+ * @param string $experiment_id Experiment identifier (e.g. 'ai-request-logging').
+ * @return bool
+ */
+function is_experiment_enabled( string $experiment_id ): bool {
+ $global_enabled = (bool) get_option( Settings_Registration::GLOBAL_OPTION, false );
+ if ( ! $global_enabled ) {
+ return false;
+ }
+
+ $experiment_enabled = (bool) get_option( "ai_experiment_{$experiment_id}_enabled", false );
+
+ /**
+ * Filters the enabled status for a specific experiment.
+ *
+ * @since 0.1.0
+ *
+ * @param bool $experiment_enabled Default enabled state from the option.
+ */
+ $is_enabled = (bool) apply_filters( "ai_experiment_{$experiment_id}_enabled", $experiment_enabled );
+
+ if ( ! has_valid_ai_credentials() ) {
return false;
}
+
+ return $is_enabled;
}
/**
- * Returns the request log manager singleton instance.
+ * Get the shared AI request log manager instance.
*
* @since 0.1.0
*
- * @return \WordPress\AI\Logging\AI_Request_Log_Manager|null The log manager instance, or null if not available.
+ * @return AI_Request_Log_Manager|null
*/
-function get_request_log_manager(): ?Logging\AI_Request_Log_Manager {
+function get_request_log_manager(): ?AI_Request_Log_Manager {
static $log_manager = null;
- if ( null === $log_manager && class_exists( Logging\AI_Request_Log_Manager::class ) ) {
- $log_manager = new Logging\AI_Request_Log_Manager();
+ if ( null === $log_manager && class_exists( AI_Request_Log_Manager::class ) ) {
+ $log_manager = new AI_Request_Log_Manager();
}
return $log_manager;
}
+
+/**
+ * Returns the AI icon SVG markup for inline use.
+ *
+ * Reads the icon from assets/images/ai-icon.svg and adds width/height attributes.
+ *
+ * @since 0.1.0
+ *
+ * @param string $width Optional. Width of the icon. Default '1em'.
+ * @param string $height Optional. Height of the icon. Default '1em'.
+ * @return string The SVG markup for the AI icon.
+ */
+function get_ai_icon_svg( string $width = '1em', string $height = '1em' ): string {
+ static $svg_content = null;
+
+ if ( null === $svg_content ) {
+ $svg_path = dirname( __DIR__ ) . '/assets/images/ai-icon.svg';
+ $svg_content = file_exists( $svg_path ) ? file_get_contents( $svg_path ) : '';
+ }
+
+ if ( empty( $svg_content ) ) {
+ return '';
+ }
+
+ // Add width and height attributes, and fill="currentColor" for theme compatibility.
+ return preg_replace(
+ '/