self::check_ssl(), 'php' => self::check_php(), 'updates' => self::check_updates(), 'backups' => self::check_backups(), 'errors' => self::check_recent_errors(), 'paused' => self::check_paused(), ); /** * Add or override check signals. * * Each signal is an array with at least `level` * ('ok' | 'warn' | 'critical' | 'unknown'). * * @since 0.1.1 * * @param array $checks Signals (ssl, php, updates, backups, errors). */ $checks = apply_filters( 'quietadmin_status_checks', $checks ); $result = array( 'checked_at' => time(), 'checks' => $checks, ); update_option( self::RESULT_OPTION, $result, false ); self::touch_last_check(); self::$cache = null; return $result; } /** * Stored result of the last check. * * @since 0.1.1 * * @return array|null Null if no check has run yet. */ public static function get_result() { $result = get_option( self::RESULT_OPTION, null ); return is_array( $result ) ? $result : null; } /** * Set an external status (e.g. from a monitoring tool). * * The external status overrides the local check result until it * expires or is removed. * * @since 0.1.1 * * @param string $level 'green' | 'yellow' | 'red'. * @param string $message Optional reason (shown to the client). * @param int $ttl Validity in seconds (default 24 hours). * @return bool */ public static function set_external_status( $level, $message = '', $ttl = DAY_IN_SECONDS ) { if ( ! in_array( $level, array( 'green', 'yellow', 'red' ), true ) ) { return false; } /** * Validity period of externally reported statuses. * * @since 0.1.1 * * @param int $seconds Seconds. */ $ttl = max( 60, (int) apply_filters( 'quietadmin_external_status_ttl', (int) $ttl ) ); update_option( self::EXTERNAL_OPTION, array( 'level' => $level, 'message' => sanitize_text_field( (string) $message ), 'expires' => time() + $ttl, ), false ); self::$cache = null; return true; } /** * Remove the external status. * * @since 0.1.1 * * @return void */ public static function clear_external_status() { delete_option( self::EXTERNAL_OPTION ); self::$cache = null; } /** * Current valid external status. * * @since 0.1.1 * * @return array|null {level,message,expires} or null. */ public static function get_external_status() { $external = get_option( self::EXTERNAL_OPTION, null ); if ( ! is_array( $external ) || empty( $external['level'] ) ) { return null; } if ( isset( $external['expires'] ) && (int) $external['expires'] < time() ) { self::clear_external_status(); return null; } return $external; } /** * Calculates the current status. * * @since 0.1.0 * * @return array{level:string,label:string,message:string,last_check:int,last_check_human:string,recent_events:int,checks:array,external:bool} */ public static function get_status() { if ( null !== self::$cache ) { return self::$cache; } $last_check = self::last_check(); $result = self::get_result(); $external = self::get_external_status(); $checks = is_array( $result ) && isset( $result['checks'] ) ? (array) $result['checks'] : array(); $recent_errors = self::error_count_from_checks( $checks ); if ( $external ) { // External monitoring takes precedence (fresh, real signal). $level = $external['level']; } elseif ( ! is_array( $result ) ) { // No check has run yet: neutral instead of green. $level = 'unknown'; } else { $level = self::level_from_checks( $checks ); // Outdated check: cron is probably not running. $age_hours = $last_check ? ( time() - $last_check ) / HOUR_IN_SECONDS : PHP_INT_MAX; /** * Threshold in hours after which a missing check * raises the status to "yellow". * * @since 0.1.0 * * @param int $hours Hours. */ $warn_after = (int) apply_filters( 'quietadmin_status_warn_after_hours', 72 ); if ( 'green' === $level && $age_hours > $warn_after ) { $level = 'yellow'; } } /** * Override the status level (e.g. connection to an external monitoring). * * @since 0.1.0 * * @param string $level 'green' | 'yellow' | 'red' | 'unknown'. * @param array $checks Latest check signals. */ $level = apply_filters( 'quietadmin_status_level', $level, $checks ); if ( ! in_array( $level, array( 'green', 'yellow', 'red', 'unknown' ), true ) ) { $level = 'unknown'; } $texts = self::level_texts(); if ( $external && ! empty( $external['message'] ) ) { $texts[ $level ]['message'] = $external['message']; } $status = array( 'level' => $level, 'label' => $texts[ $level ]['label'], 'message' => $texts[ $level ]['message'], 'last_check' => $last_check, 'last_check_human' => $last_check ? sprintf( /* translators: %s: human readable time diff. */ __( 'Last check: %s', 'oms-quietadmin' ), self::human_time( $last_check ) ) : __( 'Last check: none yet', 'oms-quietadmin' ), 'recent_events' => (int) $recent_errors, 'checks' => $checks, 'checked' => self::checked_labels( $checks ), 'external' => (bool) $external, ); /** * Filter the complete status array. * * @since 0.1.0 * * @param array $status Status data. */ self::$cache = apply_filters( 'quietadmin_status', $status ); return self::$cache; } /** * Label and message per level (honest, calm wording). * * @since 0.1.1 * * @return array */ protected static function level_texts() { return array( 'green' => array( 'label' => __( 'All systems green', 'oms-quietadmin' ), 'message' => __( 'The last automatic system check found no issues.', 'oms-quietadmin' ), ), 'yellow' => array( 'label' => __( 'Maintenance items open', 'oms-quietadmin' ), 'message' => __( 'The last check found maintenance items, such as pending updates or an older PHP version. These items concern the technical maintenance – no action is needed on your part.', 'oms-quietadmin' ), ), 'red' => array( 'label' => __( 'Action required', 'oms-quietadmin' ), 'message' => __( 'The last check found a technical problem (e.g. missing SSL encryption, an outdated PHP version or overdue backups). Please contact your maintenance provider.', 'oms-quietadmin' ), ), 'unknown' => array( 'label' => __( 'No check yet', 'oms-quietadmin' ), 'message' => __( 'There are no check results yet. The automatic check runs daily; the status appears after the first run.', 'oms-quietadmin' ), ), ); } /** * Derives the traffic-light level from the check signals. * * @since 0.1.1 * * @param array $checks Signals from run_checks(). * @return string 'green' | 'yellow' | 'red' | 'unknown'. */ protected static function level_from_checks( $checks ) { $has_signal = false; $level = 'green'; foreach ( (array) $checks as $check ) { if ( ! is_array( $check ) || empty( $check['level'] ) ) { continue; } switch ( $check['level'] ) { case 'critical': return 'red'; case 'warn': $level = 'yellow'; $has_signal = true; break; case 'ok': $has_signal = true; break; case 'unknown': default: // Neutral signals (e.g. no backup ever logged) // neither raise nor lower the rating. break; } } return $has_signal ? $level : 'unknown'; } /** * Names of the signals that actually produced a result in the last run. * * Signals with the level "unknown" (e.g. update status not yet * retrieved, no backup ever detected) do not count; otherwise the * display would suggest more checks than actually took place. * * @since 0.1.2 * * @param array $checks Signals from run_checks(). * @return string[] Display names of the checked signals. */ protected static function checked_labels( $checks ) { $names = array( 'ssl' => __( 'SSL', 'oms-quietadmin' ), 'php' => __( 'PHP version', 'oms-quietadmin' ), 'updates' => __( 'Updates', 'oms-quietadmin' ), 'backups' => __( 'Backups', 'oms-quietadmin' ), 'errors' => __( 'Failed maintenance tasks', 'oms-quietadmin' ), 'paused' => __( 'Plugin/theme errors', 'oms-quietadmin' ), ); $labels = array(); foreach ( $names as $key => $label ) { if ( ! isset( $checks[ $key ]['level'] ) || 'unknown' === $checks[ $key ]['level'] ) { continue; } $labels[] = $label; } return $labels; } /** * Error count from the check signals (for `recent_events`). * * @since 0.1.1 * * @param array $checks Signals. * @return int */ protected static function error_count_from_checks( $checks ) { if ( isset( $checks['errors']['count'] ) ) { return (int) $checks['errors']['count']; } return 0; } /** * Signal: SSL encryption. * * @since 0.1.1 * * @return array{level:string,https:bool} */ protected static function check_ssl() { $scheme = wp_parse_url( home_url(), PHP_URL_SCHEME ); $https = 'https' === strtolower( (string) $scheme ); return array( 'level' => $https ? 'ok' : 'critical', 'https' => $https, ); } /** * Signal: PHP version. * * @since 0.1.1 * * @return array{level:string,version:string} */ protected static function check_php() { /** * PHP thresholds: [minimum, recommended]. * * Below `minimum` = critical, below `recommended` = warn. * * @since 0.1.1 * * @param array $versions [minimum, recommended]. */ $versions = apply_filters( 'quietadmin_php_versions', array( '8.2', '8.4' ) ); $min = isset( $versions[0] ) ? $versions[0] : '8.2'; $recommended = isset( $versions[1] ) ? $versions[1] : '8.4'; $level = 'ok'; if ( version_compare( PHP_VERSION, $min, '<' ) ) { $level = 'critical'; } elseif ( version_compare( PHP_VERSION, $recommended, '<' ) ) { $level = 'warn'; } return array( 'level' => $level, 'version' => PHP_VERSION, ); } /** * Signal: pending updates (core, plugins, themes). * * Uses the update transients. If these are not (yet) populated, the * signal counts as "unknown" and does not affect the traffic light; it is then * explicitly NOT claimed that no updates are pending. * * @since 0.1.1 * * @return array{level:string,core:int,plugins:int,themes:int,known:bool} */ protected static function check_updates() { global $wp_version; $core_pending = 0; $core_known = false; $core = get_site_transient( 'update_core' ); if ( is_object( $core ) && isset( $core->version_checked ) && $core->version_checked === $wp_version ) { $core_known = true; foreach ( (array) $core->updates as $update ) { if ( ! is_object( $update ) || ! isset( $update->response, $update->version ) ) { continue; } if ( 'upgrade' === $update->response && version_compare( $update->version, $wp_version, '>' ) ) { $core_pending = 1; break; } } } $plugins = get_site_transient( 'update_plugins' ); $plugins_pending = 0; $plugins_known = is_object( $plugins ); if ( $plugins_known && ! empty( $plugins->response ) ) { $plugins_pending = count( (array) $plugins->response ); } $themes = get_site_transient( 'update_themes' ); $themes_pending = 0; $themes_known = is_object( $themes ); if ( $themes_known && ! empty( $themes->response ) ) { $themes_pending = count( (array) $themes->response ); } // A green result is trustworthy only when all three update sources are // populated. A partial cache must never turn unknown updates into "OK". $known = $core_known && $plugins_known && $themes_known; $total = $core_pending + $plugins_pending + $themes_pending; $level = 'unknown'; if ( $known ) { $level = $total > 0 ? 'warn' : 'ok'; } return array( 'level' => $level, 'core' => $core_pending, 'plugins' => $plugins_pending, 'themes' => $themes_pending, 'known' => $known, ); } /** * Signal: backup age (only if backups are logged). * * @since 0.1.1 * * @return array{level:string,age_days:int|null} */ protected static function check_backups() { $last_backup = Event_Log::last_event_time( Event_Log::TYPE_BACKUP ); if ( ! $last_backup ) { // No backup ever detected: neutral signal, no assumption. return array( 'level' => 'unknown', 'age_days' => null, ); } /** * Thresholds for the backup age in days: [warn, critical]. * * @since 0.1.1 * * @param array $days [warn, critical]. */ $thresholds = apply_filters( 'quietadmin_backup_age_days', array( 8, 31 ) ); $warn_after = isset( $thresholds[0] ) ? (float) $thresholds[0] : 8; $critical_after = isset( $thresholds[1] ) ? (float) $thresholds[1] : 31; $age_days = ( time() - $last_backup ) / DAY_IN_SECONDS; $level = 'ok'; if ( $age_days > $critical_after ) { $level = 'critical'; } elseif ( $age_days > $warn_after ) { $level = 'warn'; } return array( 'level' => $level, 'age_days' => (int) floor( $age_days ), ); } /** * Default keys for failures that count as errors. * * @since 0.1.2 * * @return string[] */ public static function default_failure_events() { return array( 'update_failed', 'core_update_failed', 'plugin_update_failed', 'theme_update_failed', 'auto_update_failed', 'backup_failed', 'critical_error', 'external_problem', ); } /** * Signal: plugins/themes paused by WordPress (fatal error protection). * * If WordPress has paused an extension because of a fatal error, * the website is only partially functional. This is * a real signal from core and is not guessed by QuietAdmin. * * @since 0.1.2 * * @return array{level:string,plugins:int,themes:int} */ protected static function check_paused() { $plugins = 0; $themes = 0; if ( function_exists( 'wp_paused_plugins' ) ) { $plugins = count( (array) wp_paused_plugins()->get_all() ); } if ( function_exists( 'wp_paused_themes' ) ) { $themes = count( (array) wp_paused_themes()->get_all() ); } return array( 'level' => ( $plugins + $themes ) > 0 ? 'critical' : 'ok', 'plugins' => $plugins, 'themes' => $themes, ); } /** * Signal: logged error events of the last 24 hours. * * Only real failures are counted (e.g. `update_failed`, * `backup_failed`). Everyday events such as failed logins * (bots) or spam hits deliberately do not count; they are normal * and not a maintenance problem. * * @since 0.1.1 * * @return array{level:string,count:int,events:string[]} */ protected static function check_recent_errors() { /** * Event keys that count as errors. * * @since 0.1.1 * * @param string[] $events Keys. */ $failure_events = (array) apply_filters( 'quietadmin_failure_events', self::default_failure_events() ); $count = Event_Log::count_events( $failure_events, Event_Log::utc_before( DAY_IN_SECONDS ), Event_Log::format_utc( time() ) ); return array( 'level' => $count > 0 ? 'warn' : 'ok', 'count' => $count, 'events' => $failure_events, ); } /** * Human-readable time (Today 08:00 / 2 hours ago). * * @since 0.1.0 * * @param int $timestamp Unix timestamp. * @return string */ public static function human_time( $timestamp ) { $timestamp = (int) $timestamp; if ( ! $timestamp ) { return __( 'unknown', 'oms-quietadmin' ); } $diff = time() - $timestamp; if ( $diff < 0 ) { $diff = 0; } if ( $diff < 60 ) { return __( 'just now', 'oms-quietadmin' ); } if ( $diff < HOUR_IN_SECONDS ) { $minutes = (int) floor( $diff / MINUTE_IN_SECONDS ); return sprintf( /* translators: %s: human readable time diff. */ __( '%s ago', 'oms-quietadmin' ), sprintf( /* translators: %d: number of minutes. */ _n( '%d minute', '%d minutes', $minutes, 'oms-quietadmin' ), $minutes ) ); } if ( $diff < DAY_IN_SECONDS ) { $hours = (int) floor( $diff / HOUR_IN_SECONDS ); return sprintf( /* translators: %s: human readable time diff. */ __( '%s ago', 'oms-quietadmin' ), sprintf( /* translators: %d: number of hours. */ _n( '%d hour', '%d hours', $hours, 'oms-quietadmin' ), $hours ) ); } return sprintf( /* translators: 1: date, 2: time. */ __( 'on %1$s at %2$s', 'oms-quietadmin' ), wp_date( get_option( 'date_format' ), $timestamp ), wp_date( 'H:i', $timestamp ) ); } /** * Notice "Website updated successfully (X ago)", only if it happened recently. * * @since 0.1.0 * * @param int $window_seconds Time window. * @return array{title:string,when:string}|null */ public static function last_update_note( $window_seconds = 86400 ) { if ( ! Options::get( 'show_update_note' ) ) { return null; } global $wpdb; $table = Event_Log::table(); $since = Event_Log::utc_before( $window_seconds ); // phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared $row = $wpdb->get_row( // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name is built from $wpdb->prefix. $wpdb->prepare( "SELECT title, created_at FROM {$table} WHERE event_type = %s AND event IN (%s, %s, %s) AND created_at >= %s ORDER BY created_at DESC LIMIT 1", Event_Log::TYPE_MAINTENANCE, 'core_updated', 'plugin_updated', 'theme_updated', $since ) ); // phpcs:enable if ( ! $row ) { return null; } $timestamp = Event_Log::to_timestamp( $row->created_at ); return array( 'title' => $row->title ? $row->title : __( 'Website maintained successfully', 'oms-quietadmin' ), 'when' => self::human_time( $timestamp ), ); } }