handleSubmission(); // Self-heal: a queue row can be lost to a database restore or a manual // edit, and a retention policy that has quietly stopped running is worse // than not having one, because nobody is watching for it. LogPurger::schedule(); $filters = $this->filters(); $page = isset( $_GET['paged'] ) ? max( 1, absint( wp_unslash( $_GET['paged'] ) ) ) : 1; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only pagination. $total = LogRepository::count( $filters ); $rows = LogRepository::page( $filters, $page ); Ui::pageOpen( __( 'گزارش‌ها', 'rahkar-toolkit' ), '', Help::link( 'logs', __( 'گزارش‌ها', 'rahkar-toolkit' ) ) ); Diagnostics::render( self::SLUG ); if ( '' !== $notice ) { echo wp_kses( Ui::notice( 'success', $notice ), Ui::allowedHtml() ); } $this->renderSummary(); $this->renderFilters( $filters ); $this->renderTable( $rows ); $this->renderPagination( $total, $page, $filters ); $this->renderRetention(); Ui::confirmModal(); Ui::enqueueConfirm(); Ui::pageClose(); wp_enqueue_script( 'rahkar-wp-logs', RAHKAR_WP_URL . 'assets/dist/admin-logs.js', [], RAHKAR_WP_VERSION, true ); } /** * The strip of counters above the table. */ private function renderSummary(): void { $total = LogRepository::total(); $counts = LogRepository::countsByLevel(); $size = LogRepository::sizeOnDisk(); $max = LogPurger::maxRows(); $errors = ( $counts[ Logger::EMERGENCY ] ?? 0 ) + ( $counts[ Logger::ERROR ] ?? 0 ); Ui::statsOpen(); echo wp_kses( Ui::stat( number_format_i18n( $total ), __( 'کل رکوردها', 'rahkar-toolkit' ) ), Ui::allowedHtml() ); echo wp_kses( Ui::stat( number_format_i18n( $errors ), __( 'خطا', 'rahkar-toolkit' ), '', $errors > 0 ? 'error' : '' ), Ui::allowedHtml() ); echo wp_kses( Ui::stat( size_format( $size ), __( 'حجم روی دیسک', 'rahkar-toolkit' ) ), Ui::allowedHtml() ); if ( $max > 0 ) { $percent = (int) round( $total / $max * 100 ); echo wp_kses( Ui::stat( number_format_i18n( $percent ) . '٪', __( 'از سقف مجاز', 'rahkar-toolkit' ), '', $total > ( $max * 0.9 ) ? 'warning' : '' ), Ui::allowedHtml() ); } Ui::statsClose(); } /** * The filter bar. * * @param array $filters Active filters. */ private function renderFilters( array $filters ): void { Ui::formOpen( 'get' ); printf( '', esc_attr( self::SLUG ) ); Ui::toolbarOpen(); $channels = [ '' => __( 'همه بخش‌ها', 'rahkar-toolkit' ) ] + LogRepository::channels(); echo wp_kses( Ui::select( 'channel', $channels, (string) $filters['channel'], [], true ), Ui::allowedHtml() ); echo wp_kses( Ui::select( 'level', [ '' => __( 'همه سطوح', 'rahkar-toolkit' ), (string) Logger::ERROR => __( 'فقط خطاها', 'rahkar-toolkit' ), (string) Logger::WARNING => __( 'خطا و هشدار', 'rahkar-toolkit' ), ], (string) $filters['level'], [], true ), Ui::allowedHtml() ); // With the Jalali module on, these take the plugin's own Persian picker: // a text field it turns into a Jalali one, writing the Gregorian date // into a hidden field beside it, which is what the query still reads. // Without the module they stay the browser's native date field. $jalali = Manifest::isEnabled( 'jalali' ); $type = $jalali ? 'text' : 'date'; // The class DatePicker binds to, spelled out rather than read from the // class: naming the constant would load the picker on every log screen. $attrs = $jalali ? [ 'class' => 'rahkar-jalali-date', 'placeholder' => __( 'از تاریخ', 'rahkar-toolkit' ) ] : []; echo wp_kses( Ui::input( $type, 'from', (string) $filters['from'], $attrs, true ), Ui::allowedHtml() ); if ( $jalali ) { $attrs['placeholder'] = __( 'تا تاریخ', 'rahkar-toolkit' ); } echo wp_kses( Ui::input( $type, 'to', (string) $filters['to'], $attrs, true ), Ui::allowedHtml() ); echo wp_kses( Ui::input( 'search', 's', (string) $filters['search'], [ 'placeholder' => __( 'جستجو در پیام…', 'rahkar-toolkit' ) ] ), Ui::allowedHtml() ); echo wp_kses( Ui::button( __( 'فیلتر', 'rahkar-toolkit' ), 'primary', [ 'type' => 'submit' ], '', 'search' ), Ui::allowedHtml() ); echo wp_kses( Ui::toolbarSpacer(), Ui::allowedHtml() ); echo wp_kses( Ui::button( __( 'پاک کردن فیلترها', 'rahkar-toolkit' ), 'secondary', [], menu_page_url( self::SLUG, false ), 'close' ), Ui::allowedHtml() ); Ui::toolbarClose(); Ui::formClose(); } /** * The log table. * * @param list> $rows Log rows. */ private function renderTable( array $rows ): void { Ui::tableOpen( [ __( 'زمان', 'rahkar-toolkit' ), __( 'سطح', 'rahkar-toolkit' ), __( 'بخش', 'rahkar-toolkit' ), __( 'پیام', 'rahkar-toolkit' ), ], true ); if ( [] === $rows ) { echo wp_kses( Ui::tableEmptyRow( 4, __( 'رکوردی یافت نشد.', 'rahkar-toolkit' ) ), Ui::allowedHtml() ); } foreach ( $rows as $row ) { $this->renderRow( $row ); } Ui::tableClose(); } /** * One log row. * * @param array $row Log row. */ private function renderRow( array $row ): void { $level = (int) $row['level']; $context = (string) ( $row['context'] ?? '' ); $detail = ''; if ( '' !== $context && '[]' !== $context ) { $detail = sprintf( '%s', Ui::button( __( 'جزئیات', 'rahkar-toolkit' ), 'secondary', [ 'type' => 'button', 'class' => 'rahkar-logs__more', 'aria-expanded' => 'false' ] ), esc_html( $this->prettyContext( $context ) ) ); } printf( '%s%s%s%s %s', // Stored in UTC; shown in the site's timezone, because an admin // comparing a log line to a customer's complaint thinks in local time. wp_kses( Ui::badge( wp_date( 'Y/m/d H:i:s', (int) strtotime( (string) $row['created_at'] . ' UTC' ) ), 'quiet', true ), Ui::allowedHtml() ), wp_kses( Ui::badge( $this->levelLabel( $level ), $this->levelVariant( $level ) ), Ui::allowedHtml() ), esc_html( LogRepository::channels()[ $row['channel'] ] ?? (string) $row['channel'] ), esc_html( (string) $row['message'] ), wp_kses( $detail, Ui::allowedHtml() ) ); } /** * Badge colour for a log level. * * @param int $level Logger level constant. */ private function levelVariant( int $level ): string { if ( $level <= Logger::ERROR ) { return 'error'; } if ( Logger::WARNING === $level ) { return 'warning'; } return 'quiet'; } /** * Page links. * * @param int $total Matching rows. * @param int $page Current page. * @param array $filters Active filters. */ private function renderPagination( int $total, int $page, array $filters ): void { $pages = (int) ceil( $total / LogRepository::PER_PAGE ); echo wp_kses( Ui::pagination( $page, $pages, static function ( int $number ) use ( $filters ): string { $args = array_filter( [ 'page' => self::SLUG, 'channel' => $filters['channel'], 'level' => $filters['level'], 'from' => $filters['from'], 'to' => $filters['to'], 's' => $filters['search'], 'paged' => $number, ], static fn( $value ): bool => '' !== (string) $value ); return add_query_arg( $args, admin_url( 'admin.php' ) ); }, sprintf( /* translators: %s: number of matching log rows */ __( '%s رکورد یافت شد', 'rahkar-toolkit' ), number_format_i18n( $total ) ) ), Ui::allowedHtml() ); } /** * Retention policy and the manual purges. */ private function renderRetention(): void { Ui::panelOpen( __( 'نگهداری و پاکسازی', 'rahkar-toolkit' ), __( 'گزارش‌ها به‌طور خودکار و روزانه پاکسازی می‌شوند. پاکسازی روی صف خود افزونه اجرا می‌شود، پس به کرون وردپرس نیازی ندارد.', 'rahkar-toolkit' ), 'trash' ); Ui::formOpen(); wp_nonce_field( self::NONCE ); echo ''; Ui::rowOpen( __( 'نگهداری خطاها (روز)', 'rahkar-toolkit' ), 'rahkar-ret-error', __( 'خطاها را بیشتر نگه دارید — وقتی مشتری شکایت می‌کند همین‌ها لازم می‌شوند.', 'rahkar-toolkit' ) ); echo wp_kses( Ui::input( 'number', 'retention_error', (string) LogPurger::errorDays(), [ 'id' => 'rahkar-ret-error', 'min' => '1', 'max' => '365' ], true ), Ui::allowedHtml() ); Ui::rowClose(); Ui::rowOpen( __( 'نگهداری بقیه (روز)', 'rahkar-toolkit' ), 'rahkar-ret-info' ); echo wp_kses( Ui::input( 'number', 'retention_info', (string) LogPurger::infoDays(), [ 'id' => 'rahkar-ret-info', 'min' => '1', 'max' => '365' ], true ), Ui::allowedHtml() ); Ui::rowClose(); Ui::rowOpen( __( 'حداکثر تعداد رکورد', 'rahkar-toolkit' ), 'rahkar-max-rows', __( 'سقف مطلق، فارغ از تاریخ. این همان چیزی است که در برابر انفجار ناگهانی محافظت می‌کند: یک حلقه معیوب می‌تواند در یک ساعت صدهزار رکورد بنویسد، خیلی قبل از آنکه پاکسازی روزانه برسد. صفر یعنی بدون سقف.', 'rahkar-toolkit' ) ); echo wp_kses( Ui::input( 'number', 'max_rows', (string) LogPurger::maxRows(), [ 'id' => 'rahkar-max-rows', 'min' => '0', 'step' => '1000' ], true ), Ui::allowedHtml() ); Ui::rowClose(); Ui::rowOpen( __( 'سطح ثبت', 'rahkar-toolkit' ), 'rahkar-log-level' ); echo wp_kses( Ui::select( 'log_level', [ (string) Logger::ERROR => __( 'فقط خطاها — کمترین حجم', 'rahkar-toolkit' ), (string) Logger::INFO => __( 'عادی — شامل ارسال‌های موفق', 'rahkar-toolkit' ), (string) Logger::DEBUG => __( 'کامل — فقط برای عیب‌یابی', 'rahkar-toolkit' ), ], (string) $this->logLevel(), [ 'id' => 'rahkar-log-level' ] ), Ui::allowedHtml() ); Ui::rowClose(); echo wp_kses( Ui::button( __( 'ذخیره', 'rahkar-toolkit' ), 'primary', [ 'type' => 'submit' ], '', 'check' ), Ui::allowedHtml() ); Ui::formClose(); $this->renderPurges(); Ui::panelClose(); } /** * The two manual purges. * * Separate forms from the settings above, and from each other: each posts a * different action, and the destructive one carries its own confirmation. */ private function renderPurges(): void { Ui::toolbarOpen( false ); printf( '
', wp_kses( Ui::confirmAttrs( __( 'حذف رکوردهای قدیمی', 'rahkar-toolkit' ), __( 'گزارش‌های قدیمی‌تر از بازه‌ای که انتخاب کرده‌اید حذف می‌شوند. گزارش‌های تازه‌تر دست‌نخورده می‌مانند.', 'rahkar-toolkit' ), __( 'پاک‌سازی', 'rahkar-toolkit' ), 'warning' ), Ui::allowedHtml() ) ); wp_nonce_field( self::NONCE ); echo ''; echo wp_kses( Ui::toolbarLabel( __( 'حذف رکوردهای قدیمی‌تر از', 'rahkar-toolkit' ) ), Ui::allowedHtml() ); echo wp_kses( Ui::input( 'number', 'days', '30', [ 'min' => '0', 'max' => '365' ], true ), Ui::allowedHtml() ); echo wp_kses( Ui::toolbarLabel( __( 'روز', 'rahkar-toolkit' ) ), Ui::allowedHtml() ); echo wp_kses( Ui::button( __( 'حذف', 'rahkar-toolkit' ), 'secondary', [ 'type' => 'submit' ], '', 'trash' ), Ui::allowedHtml() ); echo '
'; echo wp_kses( Ui::toolbarSpacer(), Ui::allowedHtml() ); // The only consequence line on this screen. If every dialog carried one, // the one that matters would read like the others. printf( '
', wp_kses( Ui::confirmAttrs( __( 'حذف همه گزارش‌ها', 'rahkar-toolkit' ), __( 'همه گزارش‌های ثبت‌شده پاک می‌شوند، شامل خطاهای ارسال پیامک و ایمیل.', 'rahkar-toolkit' ), __( 'حذف همیشگی', 'rahkar-toolkit' ), 'danger', __( 'این کار برگشت‌پذیر نیست و تاریخچه بازیابی نمی‌شود.', 'rahkar-toolkit' ) ), Ui::allowedHtml() ) ); wp_nonce_field( self::NONCE ); echo ''; echo wp_kses( Ui::button( __( 'حذف همه گزارش‌ها', 'rahkar-toolkit' ), 'destructive', [ 'type' => 'submit' ], '', 'trash' ), Ui::allowedHtml() ); echo '
'; Ui::toolbarClose(); } /** * Apply a settings save or a purge. * * @return string Notice text, or an empty string. */ private function handleSubmission(): string { if ( ! isset( $_POST['rahkar_logs_action'] ) ) { return ''; } check_admin_referer( self::NONCE ); $action = sanitize_key( wp_unslash( $_POST['rahkar_logs_action'] ) ); if ( 'save' === $action ) { $core = Settings::all( 'core' ); $core['log_retention_error'] = max( 1, min( 365, absint( wp_unslash( $_POST['retention_error'] ?? LogPurger::DEFAULT_ERROR_DAYS ) ) ) ); $core['log_retention_info'] = max( 1, min( 365, absint( wp_unslash( $_POST['retention_info'] ?? LogPurger::DEFAULT_INFO_DAYS ) ) ) ); $core['log_max_rows'] = max( 0, absint( wp_unslash( $_POST['max_rows'] ?? LogPurger::DEFAULT_MAX_ROWS ) ) ); $core['log_level'] = min( Logger::DEBUG, max( Logger::ERROR, absint( wp_unslash( $_POST['log_level'] ?? Logger::INFO ) ) ) ); Settings::save( 'core', $core ); return __( 'تنظیمات ذخیره شد.', 'rahkar-toolkit' ); } if ( 'purge_old' === $action ) { $days = max( 0, min( 365, absint( wp_unslash( $_POST['days'] ?? 30 ) ) ) ); $removed = LogPurger::purgeOlderThan( $days ); return sprintf( /* translators: %s: number of rows */ __( '%s رکورد حذف شد.', 'rahkar-toolkit' ), number_format_i18n( $removed ) ); } if ( 'purge_all' === $action ) { $removed = LogPurger::purgeAll(); return sprintf( /* translators: %s: number of rows */ __( 'همه گزارش‌ها حذف شدند (%s رکورد).', 'rahkar-toolkit' ), number_format_i18n( $removed ) ); } return ''; } /** * Filters from the query string. * * @return array{channel:string, level:string, search:string, from:string, to:string} */ private function filters(): array { // phpcs:disable WordPress.Security.NonceVerification.Recommended -- Read-only filtering. return [ 'channel' => isset( $_GET['channel'] ) ? sanitize_key( wp_unslash( $_GET['channel'] ) ) : '', 'level' => isset( $_GET['level'] ) && '' !== $_GET['level'] ? (string) (int) $_GET['level'] : '', 'search' => isset( $_GET['s'] ) ? sanitize_text_field( wp_unslash( $_GET['s'] ) ) : '', 'from' => isset( $_GET['from'] ) ? sanitize_text_field( wp_unslash( $_GET['from'] ) ) : '', 'to' => isset( $_GET['to'] ) ? sanitize_text_field( wp_unslash( $_GET['to'] ) ) : '', ]; // phpcs:enable } /** * The configured log level. */ private function logLevel(): int { $configured = Settings::get( 'core.log_level' ); return null !== $configured ? (int) $configured : Logger::INFO; } /** * Readable level name. * * @param int $level Level constant. */ private function levelLabel( int $level ): string { return [ Logger::EMERGENCY => __( 'بحرانی', 'rahkar-toolkit' ), Logger::ERROR => __( 'خطا', 'rahkar-toolkit' ), Logger::WARNING => __( 'هشدار', 'rahkar-toolkit' ), Logger::INFO => __( 'اطلاع', 'rahkar-toolkit' ), Logger::DEBUG => __( 'دیباگ', 'rahkar-toolkit' ), ][ $level ] ?? (string) $level; } /** * Re-indent stored JSON context for display. * * @param string $context Stored JSON. */ private function prettyContext( string $context ): string { $decoded = json_decode( $context, true ); if ( ! is_array( $decoded ) ) { return $context; } return (string) wp_json_encode( $decoded, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ); } }