Activity: what people did in the plugin and what the plugin did on its own, * with every change Backfill made, and a CSV download of what the filters show. */ final class ActivityPage { public const SLUG = 'aipcf-activity'; private const PER_PAGE = 50; /** Changes shown under one entry; the CSV has them all. */ private const MAX_CHANGES = 100; public static function boot(): void { // After Search insights (21), so the menu reads Settings, Link assistant, Search insights, Activity. add_action('admin_post_aipcf_activity_export', [self::class, 'handleExport']); add_action('admin_post_aipcf_activity_retention', [self::class, 'handleRetention']); add_action('admin_post_aipcf_activity_clear', [self::class, 'handleClear']); add_action('wp_ajax_aipcf_activity_list', [self::class, 'ajaxList']); } /** * @param array $args */ public static function pageUrl(array $args = []): string { return add_query_arg(array_filter($args, static fn($v): bool => $v !== '' && $v !== 0 && $v !== null), admin_url('admin.php?page=' . self::SLUG)); } /** * The filters in the request. * * @return array{who: string, tool: string, failed: int, since: string, until: string} */ public static function filters(): array { $who = self::query('who'); return [ 'who' => in_array($who, ActivityLog::ACTORS, true) ? $who : '', 'tool' => sanitize_key(self::query('tool')), 'failed' => self::query('failed') === '' ? 0 : 1, 'since' => ActivityLog::day(self::query('since')), 'until' => ActivityLog::day(self::query('until')), ]; } /** * One filter from the query string, as text ('' when missing or not a string). */ private static function query(string $key): string { // phpcs:disable WordPress.Security.NonceVerification.Recommended -- filters of a read-only list $value = isset($_GET[$key]) && is_string($_GET[$key]) ? sanitize_text_field(wp_unslash($_GET[$key])) : ''; // phpcs:enable WordPress.Security.NonceVerification.Recommended return $value; } private static function paged(): int { // phpcs:disable WordPress.Security.NonceVerification.Recommended -- a page number of a read-only list $paged = isset($_GET['paged']) && is_string($_GET['paged']) ? absint($_GET['paged']) : 1; // phpcs:enable WordPress.Security.NonceVerification.Recommended return max(1, $paged); } public static function render(): void { if (!current_user_can('manage_options')) { wp_die(esc_html__('You do not have permission to do this.', 'aipcf-ai-provider-for-cloudflare'), 403); } $filters = self::filters(); echo '
'; SettingsPage::header( 'dashicons-list-view', __('Activity', 'aipcf-ai-provider-for-cloudflare'), __('What users did in the plugin, and what the plugin did on its own.', 'aipcf-ai-provider-for-cloudflare') ); echo '
'; echo '

' . esc_html__('Filter entries', 'aipcf-ai-provider-for-cloudflare') . '

'; self::filterForm($filters); echo '
'; // The filters reload only this panel (settings.js); without JavaScript the form reloads the page. echo '
'; self::results($filters, self::paged()); echo '
'; self::storagePanel(); echo '
'; } /** * The entry count, Download CSV, the list and its pages, for these filters. * * @param array{who: string, tool: string, failed: int, since: string, until: string} $filters */ private static function results(array $filters, int $paged): void { $total = ActivityLog::count($filters); $entries = ActivityLog::entries($filters, self::PER_PAGE, ($paged - 1) * self::PER_PAGE); $items = ActivityLog::items(array_column($entries, 'id')); $export = wp_nonce_url(add_query_arg(array_filter($filters), admin_url('admin-post.php?action=aipcf_activity_export')), 'aipcf_activity_export'); echo '

' . esc_html(sprintf( /* translators: %s: number of entries */ _n('%s entry', '%s entries', $total, 'aipcf-ai-provider-for-cloudflare'), number_format_i18n($total) )) . '

'; if ($total > 0) { printf('%s', esc_url($export), esc_html__('Download CSV', 'aipcf-ai-provider-for-cloudflare')); } echo '
'; if ($entries === []) { printf( '

%s

%s

', esc_html__('Nothing yet', 'aipcf-ai-provider-for-cloudflare'), esc_html(array_filter($filters) !== [] ? __('No entries match these filters.', 'aipcf-ai-provider-for-cloudflare') : __('Actions in the plugin, Backfill batches and Embeddings runs appear here as they happen.', 'aipcf-ai-provider-for-cloudflare')) ); return; } self::table($entries, $items); self::pagination($total, $paged, $filters); } /** * The results panel for the filters in the request, for the filter form's AJAX reload. */ public static function ajaxList(): void { if (!current_user_can('manage_options') || !check_ajax_referer('aipcf_activity_list', 'nonce', false)) { wp_send_json_error(['message' => __('You do not have permission to do this.', 'aipcf-ai-provider-for-cloudflare')], 403); } $filters = self::filters(); ob_start(); self::results($filters, self::paged()); wp_send_json_success(['html' => (string) ob_get_clean()]); } /** * @param array{who: string, tool: string, failed: int, since: string, until: string} $f */ private static function filterForm(array $f): void { $tools = array_values(array_unique(array_merge(ActivityLog::tools(), $f['tool'] !== '' ? [$f['tool']] : []))); printf( '
', esc_attr(wp_create_nonce('aipcf_activity_list')), esc_attr(self::SLUG) ); echo ''; echo ''; printf('', esc_html__('From', 'aipcf-ai-provider-for-cloudflare'), esc_attr($f['since'])); printf('', esc_html__('To', 'aipcf-ai-provider-for-cloudflare'), esc_attr($f['until'])); printf('', checked($f['failed'], 1, false), esc_html__('Failures only', 'aipcf-ai-provider-for-cloudflare')); echo ''; if (array_filter($f) !== []) { printf(' %s', esc_url(self::pageUrl()), esc_html__('Clear filters', 'aipcf-ai-provider-for-cloudflare')); } echo '
'; } /** * @param list> $entries * @param array>> $items */ private static function table(array $entries, array $items): void { $format = get_option('date_format') . ' ' . get_option('time_format'); $cols = [ 'when' => __('When', 'aipcf-ai-provider-for-cloudflare'), 'who' => __('Who', 'aipcf-ai-provider-for-cloudflare'), 'tool' => __('Tool', 'aipcf-ai-provider-for-cloudflare'), 'what' => __('What happened', 'aipcf-ai-provider-for-cloudflare'), 'result' => __('Result', 'aipcf-ai-provider-for-cloudflare'), ]; echo '
'; foreach ($cols as $th) { echo ''; } echo ''; foreach ($entries as $e) { $time = (int) strtotime($e['ts'] . ' UTC'); $tag = ['ok' => 'is-ok', 'warning' => 'is-warn', 'failed' => 'is-bad'][$e['status']] ?? ''; echo ''; printf('', esc_attr($cols['when']), esc_attr(gmdate('c', $time)), esc_html((string) wp_date($format, $time))); printf('', esc_attr($cols['who']), esc_html(Labels::who($e))); printf('', esc_attr($cols['tool']), esc_html(Labels::tool((string) $e['tool']))); printf(''; printf('', esc_attr($cols['result']), esc_attr($tag), esc_html(Labels::status((string) $e['status']))); echo ''; } echo '
' . esc_html($th) . '
%s%s%s', esc_attr($cols['what']), esc_html(Labels::action((string) $e['action']))); if ($e['message'] !== '') { echo '
' . esc_html((string) $e['message']); } if ($e['neurons'] > 0) { /* translators: %s: number of neurons */ echo '
' . esc_html(sprintf(__('%s neurons', 'aipcf-ai-provider-for-cloudflare'), number_format_i18n($e['neurons'], $e['neurons'] < 10 ? 2 : 0))) . ''; } $list = $items[$e['id']] ?? []; if ($list !== []) { self::changes($list); } echo '
%s
'; } /** * The changes an entry made, folded away until opened. * * @param list> $list */ private static function changes(array $list): void { $total = count($list); $cols = [ 'change' => __('Change', 'aipcf-ai-provider-for-cloudflare'), 'result' => __('Result', 'aipcf-ai-provider-for-cloudflare'), ]; echo '
' . esc_html(sprintf( /* translators: %s: number of items */ _n('%s item', '%s items', $total, 'aipcf-ai-provider-for-cloudflare'), number_format_i18n($total) )) . ''; foreach ($cols as $th) { echo ''; } echo ''; foreach (array_slice($list, 0, self::MAX_CHANGES) as $i) { echo ''; printf('', esc_attr($cols['change']), self::changeCell($i)); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped in changeCell() printf(''; } echo '
' . esc_html($th) . '
%s%s', esc_attr($cols['result']), esc_html(Labels::result((string) $i['result']))); if ($i['reason'] !== '') { echo '
' . esc_html((string) $i['reason']) . ''; } echo '
'; if ($total > self::MAX_CHANGES) { echo '

' . esc_html(sprintf( /* translators: %s: number of items not shown */ __('And %s more: Download CSV lists every item.', 'aipcf-ai-provider-for-cloudflare'), number_format_i18n($total - self::MAX_CHANGES) )) . '

'; } echo '
'; } /** * One item: what it is and which field, then the value before and after; or "Not changed" * when nothing was written (a skipped item: the AI was not asked). * * @param array $i */ private static function changeCell(array $i): string { $html = '' . self::itemLink($i) . ''; if ((string) $i['field'] !== '') { $html .= ' ' . self::fieldName((string) $i['field']) . ''; } $old = (string) $i['old_value']; $new = (string) $i['new_value']; if ($i['result'] === 'secret') { return $html . '
' . esc_html__('The value is hidden.', 'aipcf-ai-provider-for-cloudflare') . '
'; } if ($old === $new) { return $html . '
' . esc_html__('Not changed.', 'aipcf-ai-provider-for-cloudflare') . '
'; } return $html . sprintf( '
%s
%s
%s
%s
', esc_html__('Before', 'aipcf-ai-provider-for-cloudflare'), self::valueCell($old), esc_html__('After', 'aipcf-ai-provider-for-cloudflare'), self::valueCell($new) ); } /** * The field's name; the stored key (rank_math_description, backfill_batch) shows on hover * and is in the CSV. */ private static function fieldName(string $key): string { $label = Labels::field($key); return $key !== '' && $label !== $key ? '' . esc_html($label) . '' : esc_html($label); } private static function valueCell(string $value): string { $value = Labels::value($value); return $value !== '' ? esc_html($value) : '' . esc_html__('(empty)', 'aipcf-ai-provider-for-cloudflare') . ''; } /** * @param array $i */ private static function itemLink(array $i): string { $id = (int) $i['object_id']; $type = (string) $i['object_type']; if ($type === 'setting') { return esc_html__('Setting', 'aipcf-ai-provider-for-cloudflare'); } if ($id === 0) { return '—'; } $title = get_post($id) instanceof \WP_Post ? get_the_title($id) : ''; /* translators: %d: post or image ID */ $label = $title !== '' ? $title : sprintf(__('#%d (deleted)', 'aipcf-ai-provider-for-cloudflare'), $id); $edit = (string) get_edit_post_link($id, 'raw'); return $edit !== '' ? '' . esc_html($label) . '' : esc_html($label); } /** * @param array{who: string, tool: string, failed: int, since: string, until: string} $filters */ private static function pagination(int $total, int $paged, array $filters): void { $pages = (int) ceil($total / self::PER_PAGE); if ($pages < 2) { return; } $links = paginate_links([ 'base' => add_query_arg('paged', '%#%', self::pageUrl($filters)), 'format' => '', 'current' => $paged, 'total' => $pages, 'prev_text' => __('Newer', 'aipcf-ai-provider-for-cloudflare'), 'next_text' => __('Older', 'aipcf-ai-provider-for-cloudflare'), ]); if (is_string($links)) { echo ''; } } /** * How much room the log takes, how long it keeps entries, and Clear log. */ private static function storagePanel(): void { $days = ActivityLog::retentionDays(); $size = ActivityLog::size(); $choices = [ 30 => __('30 days', 'aipcf-ai-provider-for-cloudflare'), 90 => __('90 days', 'aipcf-ai-provider-for-cloudflare'), 365 => __('1 year', 'aipcf-ai-provider-for-cloudflare'), 0 => __('Forever', 'aipcf-ai-provider-for-cloudflare'), ]; echo '

' . esc_html__('Storage', 'aipcf-ai-provider-for-cloudflare') . '

'; if ($size !== null) { echo '

' . esc_html(sprintf( /* translators: %s: size on disk, like "2 MB" */ __('The log takes %s in the database.', 'aipcf-ai-provider-for-cloudflare'), size_format($size, $size < 1048576 ? 0 : 1) ?: '0 B' )) . '

'; } echo '
'; wp_nonce_field('aipcf_activity_retention'); echo '
'; echo '

' . esc_html__('Older entries, and the items they list, are deleted once a day. Reset and deleting the plugin delete the whole log.', 'aipcf-ai-provider-for-cloudflare') . '

'; printf( '

%s

', esc_url(wp_nonce_url(admin_url('admin-post.php?action=aipcf_activity_clear'), 'aipcf_activity_clear')), esc_attr__('Delete every entry in the activity log now? This cannot be undone. Download CSV first to keep a copy.', 'aipcf-ai-provider-for-cloudflare'), esc_attr__('Clearing...', 'aipcf-ai-provider-for-cloudflare'), esc_html__('Clear log', 'aipcf-ai-provider-for-cloudflare') ); echo '
'; } public static function handleClear(): void { if (!current_user_can('manage_options')) { wp_die(esc_html__('You do not have permission to do this.', 'aipcf-ai-provider-for-cloudflare'), 403); } check_admin_referer('aipcf_activity_clear'); $n = ActivityLog::clear(); // The clearing itself is the first entry of the empty log (Recorder writes it on shutdown). set_transient('aipcf_notice_' . get_current_user_id(), [ 'type' => 'success', 'message' => sprintf( /* translators: %s: number of entries */ _n('Log cleared: %s entry deleted.', 'Log cleared: %s entries deleted.', $n, 'aipcf-ai-provider-for-cloudflare'), number_format_i18n($n) ), ], 60); wp_safe_redirect(self::pageUrl()); exit; } public static function handleRetention(): void { if (!current_user_can('manage_options')) { wp_die(esc_html__('You do not have permission to do this.', 'aipcf-ai-provider-for-cloudflare'), 403); } check_admin_referer('aipcf_activity_retention'); $old = ActivityLog::retentionDays(); $days = isset($_POST['days']) && is_string($_POST['days']) ? absint($_POST['days']) : ActivityLog::DEFAULT_RETENTION; ActivityLog::setRetentionDays($days); $new = ActivityLog::retentionDays(); if ($new !== $old) { Recorder::record(['tool' => 'activity', 'action' => 'retention'], [[ 'object_type' => 'setting', 'field' => 'activity_retention_days', 'old' => $old, 'new' => $new, 'result' => 'changed', ]]); ActivityLog::prune(); } set_transient('aipcf_notice_' . get_current_user_id(), ['type' => 'success', 'message' => __('Saved.', 'aipcf-ai-provider-for-cloudflare')], 60); wp_safe_redirect(self::pageUrl() . '#aipcf-activity-keep'); exit; } public static function handleExport(): void { if (!current_user_can('manage_options')) { wp_die(esc_html__('You do not have permission to do this.', 'aipcf-ai-provider-for-cloudflare'), 403); } check_admin_referer('aipcf_activity_export'); $filters = self::filters(); $name = 'aipcf-activity-' . gmdate('Y-m-d-His') . '.csv'; wp_raise_memory_limit('admin'); nocache_headers(); header('Content-Type: text/csv; charset=utf-8'); header('Content-Disposition: attachment; filename="' . $name . '"'); header('X-Content-Type-Options: nosniff'); $out = fopen('php://output', 'w'); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- streaming the download, not a file on disk if ($out !== false) { Export::write($out, $filters); fclose($out); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose } exit; } }