array( Security::class, 'rest_permission' ) ); register_rest_route( $ns, '/dashboard/overview', array_merge( $permission, array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( __CLASS__, 'dashboard_overview' ), ) ) ); register_rest_route( $ns, '/operations', array_merge( $permission, array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( __CLASS__, 'get_operations' ), 'args' => array( 'type' => array( 'type' => 'string', 'sanitize_callback' => 'sanitize_key', ), 'status' => array( 'type' => 'string', 'sanitize_callback' => 'sanitize_key', ), 'page' => array( 'type' => 'integer', 'default' => 1, 'minimum' => 1, ), 'per_page' => array( 'type' => 'integer', 'default' => 50, 'minimum' => 1, 'maximum' => 100, ), ), ) ) ); register_rest_route( $ns, '/operations/(?P[a-zA-Z0-9\-]+)', array_merge( $permission, array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( __CLASS__, 'get_operation' ), ) ) ); register_rest_route( $ns, '/audit-log', array_merge( $permission, array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( __CLASS__, 'get_audit_log' ), 'args' => array( 'user_id' => array( 'type' => 'integer', 'minimum' => 1, ), 'operation_type' => array( 'type' => 'string', 'sanitize_callback' => 'sanitize_key', ), 'status' => array( 'type' => 'string', 'sanitize_callback' => 'sanitize_key', ), 'date_from' => array( 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', ), 'page' => array( 'type' => 'integer', 'default' => 1, 'minimum' => 1, ), ), ) ) ); } /** * Build the dashboard overview: queue counts, storage usage, health and recent operations. * * @return array */ public static function dashboard_overview(): array { $queue = new Job_Queue(); $audit = new Audit_Logger(); $counts = $queue->count_by_status(); $storage_dir = Helpers::storage_dir(); $size = Helpers::directory_size( $storage_dir ); $limit = (int) apply_filters( 'spinxqm_storage_limit', 100 * 1024 * 1024 * 1024 ); $last = self::get_last_operation_summary( $audit ); $recent_raw = $audit->get_logs( array(), 1, 40 ); $recent = array_values( array_filter( $recent_raw['logs'], static function ( $row ) { return is_array( $row ) && ! empty( $row['action'] ) && in_array( $row['action'], array( 'completed', 'failed' ), true ); } ) ); $recent = array_slice( $recent, 0, 10 ); // Normalize timestamps to UTC ISO 8601 so the browser computes "x ago" // correctly regardless of the site's timezone — the raw DB values from // current_time('mysql') are timezone-naive site-local strings, which // Date.parse() would otherwise read in the browser's own timezone. $recent = array_map( static function ( $row ) { if ( is_array( $row ) ) { $row['created_at'] = self::to_utc_iso( $row['created_at'] ?? '' ); $row['completed_at'] = self::to_utc_iso( $row['completed_at'] ?? '' ); } return $row; }, $recent ); return array( 'stats' => array( 'active_jobs' => $counts['running'], 'queued_jobs' => $counts['queued'], 'storage_used' => $size, 'storage_total' => $limit, 'last_operation' => $last, ), 'health' => self::get_system_health( $size, $limit ), 'recent_operations' => $recent, ); } /** * List queue operations with optional type/status filters and pagination. * * @param WP_REST_Request $request REST request. * @return array|WP_Error */ public static function get_operations( WP_REST_Request $request ) { $queue = new Job_Queue(); if ( ! $queue->table_ready() ) { return new WP_Error( 'spinxqm_no_queue', __( 'Database tables are not ready yet. Reload after activation.', 'spinx-quick-migrate' ), array( 'status' => 503 ) ); } // get_param() returns the values validated/sanitized by the route's args. $filters = array( 'type' => sanitize_key( (string) $request->get_param( 'type' ) ), 'status' => sanitize_key( (string) $request->get_param( 'status' ) ), ); $page = max( 1, absint( $request->get_param( 'page' ) ) ); $per_page = min( 100, max( 1, absint( $request->get_param( 'per_page' ) ) ) ); return $queue->list_jobs( $filters, $page, $per_page ); } /** * Fetch a single queue operation by job ID, with server paths and raw state removed. * * @param WP_REST_Request $request REST request. * @return array|WP_Error */ public static function get_operation( WP_REST_Request $request ) { global $wpdb; $id = sanitize_text_field( (string) $request['id'] ); $table = $wpdb->prefix . 'spinxqm_queue'; $row = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM %i WHERE job_id = %s', $table, $id ), ARRAY_A ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Fetch live job row from the custom queue table; prepared (%i/%s). if ( empty( $row ) ) { return new WP_Error( 'spinxqm_not_found', __( 'Operation not found.', 'spinx-quick-migrate' ), array( 'status' => 404 ) ); } // Never expose the raw job state (checkpoint) or absolute server paths. unset( $row['payload'] ); $result = is_string( $row['result'] ?? null ) ? json_decode( $row['result'], true ) : null; if ( is_array( $result ) ) { unset( $result['file'] ); if ( is_array( $result['backup'] ?? null ) ) { unset( $result['backup']['file'] ); } $row['result'] = wp_json_encode( $result ); } return $row; } /** * Fetch a filtered page of audit log entries. * * @param WP_REST_Request $request REST request. * @return array|WP_Error */ public static function get_audit_log( WP_REST_Request $request ) { $logger = new Audit_Logger(); if ( ! $logger->table_ready() ) { return new WP_Error( 'spinxqm_no_audit', __( 'Audit log is not available yet.', 'spinx-quick-migrate' ), array( 'status' => 503 ) ); } // get_param() returns the values validated/sanitized by the route's args. $page = max( 1, absint( $request->get_param( 'page' ) ) ); $filters = array_filter( array( 'user_id' => null !== $request->get_param( 'user_id' ) ? absint( $request->get_param( 'user_id' ) ) : null, 'operation_type' => null !== $request->get_param( 'operation_type' ) ? sanitize_key( (string) $request->get_param( 'operation_type' ) ) : null, 'status' => null !== $request->get_param( 'status' ) ? sanitize_key( (string) $request->get_param( 'status' ) ) : null, 'date_from' => null !== $request->get_param( 'date_from' ) ? sanitize_text_field( (string) $request->get_param( 'date_from' ) ) : null, ), static function ( $v ) { return null !== $v && '' !== $v; } ); return $logger->get_logs( $filters, $page, 50 ); } /** * Convert a site-local "Y-m-d H:i:s" timestamp (as written by * current_time('mysql')) to an unambiguous UTC ISO 8601 string, so the browser * computes relative time ("2m ago") correctly no matter its own timezone. * Empty / zero input returns ''. * * @param mixed $local Site-local "Y-m-d H:i:s" timestamp. * @return string */ private static function to_utc_iso( $local ): string { $local = trim( (string) $local ); if ( '' === $local || '0000-00-00 00:00:00' === $local ) { return ''; } return get_gmt_from_date( $local, 'c' ); } /** * Summarize the most recent completed or failed operation for the dashboard. * * @param Audit_Logger $logger Audit logger to read from. * @return array */ private static function get_last_operation_summary( Audit_Logger $logger ): array { $data = $logger->get_logs( array(), 1, 30 ); $log = null; foreach ( $data['logs'] as $row ) { if ( is_array( $row ) && ! empty( $row['action'] ) && in_array( $row['action'], array( 'completed', 'failed' ), true ) ) { $log = $row; break; } } if ( is_array( $log ) ) { $name = (string) ( $log['operation_type'] ?? '' ); if ( '' === $name ) { $name = 'operation'; } $st = 'failed' === ( $log['action'] ?? '' ) ? 'failed' : self::map_log_status( (string) ( $log['status'] ?? '' ) ); return array( 'name' => $name, 'timestamp' => self::to_utc_iso( (string) ( $log['completed_at'] ?? $log['created_at'] ?? '' ) ), 'status' => $st, ); } $fallback = get_option( 'spinxqm_last_operation', array() ); if ( is_array( $fallback ) && ! empty( $fallback['type'] ) ) { return array( 'name' => (string) $fallback['type'], 'timestamp' => ! empty( $fallback['timestamp'] ) ? gmdate( 'c', (int) $fallback['timestamp'] ) : '', 'status' => 'success', ); } return array( 'name' => '', 'timestamp' => '', 'status' => 'success', ); } /** * Map an audit log status to a dashboard status (success, failed or running). * * @param string $status Audit log status. * @return string */ private static function map_log_status( string $status ): string { if ( 'success' === $status ) { return 'success'; } if ( 'error' === $status || 'failed' === $status ) { return 'failed'; } if ( 'running' === $status ) { return 'running'; } return 'success'; } /** * Report system health warnings for storage usage and the database connection. * * @param int $storage_used Bytes used in plugin storage. * @param int $storage_limit Storage limit in bytes (0 = unlimited). * @return array */ private static function get_system_health( int $storage_used, int $storage_limit ): array { $warnings = array(); if ( $storage_limit > 0 && $storage_used > ( $storage_limit * 0.9 ) ) { $warnings[] = __( 'Storage usage is above 90% of the configured limit.', 'spinx-quick-migrate' ); } global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching if ( method_exists( $wpdb, 'check_connection' ) && ! $wpdb->check_connection( false ) ) { $warnings[] = __( 'Database connection could not be verified.', 'spinx-quick-migrate' ); } $status = empty( $warnings ) ? 'healthy' : ( count( $warnings ) > 2 ? 'critical' : 'warning' ); return array( 'status' => $status, 'warnings' => $warnings, ); } }