'no_key', 'message' => '', 'resets_at' => '' ), null ); } if ( $force && ! $force_now && self::force_throttled( $cached ) ) { return self::result( $cached, self::last_error(), self::fetched_at( $cached ), true ); } if ( ! $force && get_transient( 'yopingme_cache_fresh' ) ) { return self::result( $cached, self::last_error(), self::fetched_at( $cached ) ); } $response = Yopingme_Client::call_tool( 'list_monitors' ); if ( ! empty( $response['ok'] ) ) { $fresh = array( 'monitors' => isset( $response['data']['monitors'] ) ? $response['data']['monitors'] : array(), 'generated_at' => isset( $response['data']['generated_at'] ) ? $response['data']['generated_at'] : '', 'fetched_at' => time(), ); update_option( 'yopingme_cache', $fresh, false ); delete_option( 'yopingme_last_error' ); set_transient( 'yopingme_cache_fresh', 1, self::TTL ); return self::result( $fresh, null, $fresh['fetched_at'] ); } $error = $response['error']; update_option( 'yopingme_last_error', array_merge( $error, array( 'at' => time() ) ), false ); set_transient( 'yopingme_cache_fresh', 1, self::retry_after( $error ) ); return self::result( $cached, $error, self::fetched_at( $cached ) ); } /** * Whether this monitor's own numbers say something went wrong lately. * * The gate that keeps the downtime banner free for healthy sites: asking * for incidents costs a second daily query, so it is only worth spending * once the data we already have says there is something to find. Uptime * over 7 days is the widest window the banner ever reports on, which is * why the 30 day figure is deliberately not consulted. * * A paused monitor never qualifies. Pausing is the owner saying "stop * watching this", and a banner about it would be arguing with them. * * Pure function of a monitor array: no options, no requests. * * @param array|null $monitor A monitor from list_monitors. * @return bool */ public static function has_downtime_hint( $monitor ) { if ( ! is_array( $monitor ) || ! empty( $monitor['paused'] ) ) { return false; } $status = isset( $monitor['current_status'] ) ? (string) $monitor['current_status'] : ''; // "unknown" is what a monitor too young to have been checked looks // like. It has no incidents to report, so it is not a hint. if ( 'down' === $status || 'confirming' === $status ) { return true; } foreach ( array( 'uptime_24h_pct', 'uptime_7d_pct' ) as $field ) { if ( ! isset( $monitor[ $field ] ) || null === $monitor[ $field ] || '' === $monitor[ $field ] ) { continue; } if ( (float) $monitor[ $field ] < 100 ) { return true; } } return false; } /** * The last week of outages for one monitor, cached like the monitor list. * * Always asks for the same fixed seven day window rather than "since you * were last here", so one cached answer serves every administrator on the * site: who saw what is decided afterwards, in PHP, for free. Callers are * expected to have checked has_downtime_hint() first. * * @param string $monitor_id Monitor to ask about. * @return array {data: array|null, error: array|null, fetched_at: int|null} */ public static function get_incidents( $monitor_id ) { $monitor_id = (string) $monitor_id; $cached = get_option( 'yopingme_incidents', array() ); if ( '' === $monitor_id ) { return self::incident_result( null, array( 'code' => 'no_monitor', 'message' => '', 'resets_at' => '' ) ); } if ( '' === (string) get_option( 'yopingme_api_key', '' ) ) { return self::incident_result( null, array( 'code' => 'no_key', 'message' => '', 'resets_at' => '' ) ); } // A site re-added to YoPingMe gets a new monitor, and the outages of // the old one are not this site's news any more. Drop the lock with // the data: a different monitor deserves a fresh look now, not in // however long the previous one had left. if ( is_array( $cached ) && isset( $cached['monitor_id'] ) && $cached['monitor_id'] !== $monitor_id ) { delete_option( 'yopingme_incidents' ); delete_transient( 'yopingme_incidents_fresh' ); $cached = array(); } // Checked before, and independently of, whether there is anything // cached: a site whose very first incident fetch failed must still // wait before trying again, or it would spend a query on every admin // page it loads. if ( get_transient( 'yopingme_incidents_fresh' ) ) { return self::incident_result( $cached, null ); } $response = Yopingme_Client::call_tool( 'list_incidents', array( 'monitor_id' => $monitor_id, 'from' => gmdate( 'c', time() - self::INCIDENT_WINDOW ), 'limit' => 50, ) ); if ( ! empty( $response['ok'] ) ) { $fresh = array( 'monitor_id' => $monitor_id, 'incidents' => isset( $response['data']['incidents'] ) && is_array( $response['data']['incidents'] ) ? $response['data']['incidents'] : array(), 'fetched_at' => time(), ); update_option( 'yopingme_incidents', $fresh, false ); set_transient( 'yopingme_incidents_fresh', 1, self::TTL ); return self::incident_result( $fresh, null ); } // Nothing here is ever shown to anyone: a banner is not the place to // explain that YoPingMe is unreachable, and the dashboard widget // already says so. The lock is still set so a failing call cannot be // retried on every admin page. set_transient( 'yopingme_incidents_fresh', 1, self::retry_after( $response['error'] ) ); return self::incident_result( $cached, $response['error'] ); } /** * The monitor watching a given URL, out of everything the account has. * * Compares hosts rather than whole URLs: a site whose home_url gains or * loses a trailing slash, or moves between http and https, is still the * same site to watch. Shared by the settings page and the dashboard * widget so both agree on what "this site" means. * * @param array $state Result from get_monitors(). * @param string $url Site URL. * @return array|null */ public static function monitor_for( $state, $url ) { if ( empty( $state['data']['monitors'] ) ) { return null; } $host = strtolower( (string) wp_parse_url( $url, PHP_URL_HOST ) ); if ( '' === $host ) { return null; } foreach ( $state['data']['monitors'] as $monitor ) { if ( empty( $monitor['target'] ) ) { continue; } if ( strtolower( (string) wp_parse_url( $monitor['target'], PHP_URL_HOST ) ) === $host ) { return $monitor; } } return null; } /** * Drop everything we know, e.g. when the key changes. * * That includes which tools the server offers. YoPingMe gains features * while a site sits there with the answer cached, so every deliberate * "look again" has to re-ask rather than leave the owner staring at a * feature that quietly became available an hour ago. */ public static function flush() { delete_option( 'yopingme_cache' ); delete_option( 'yopingme_last_error' ); delete_option( 'yopingme_incidents' ); delete_transient( 'yopingme_cache_fresh' ); delete_transient( 'yopingme_incidents_fresh' ); delete_transient( 'yopingme_tools' ); delete_option( 'yopingme_status_cache' ); delete_transient( 'yopingme_status_fresh' ); delete_transient( 'yopingme_status_pages' ); } /** * How long to wait before trying again after a failure. * * @param array $error Error from the client. * @return int Seconds. */ private static function retry_after( $error ) { if ( 'rate_limited' !== $error['code'] || '' === $error['resets_at'] ) { return self::ERROR_TTL; } // The quota resets at UTC midnight, so waiting for exactly that beats // retrying every 15 minutes into a wall. $resets = strtotime( $error['resets_at'] ); if ( ! $resets ) { return self::ERROR_TTL; } return (int) min( DAY_IN_SECONDS, max( self::FORCE_THROTTLE, $resets - time() ) ); } /** * Whether a forced refresh is too soon after the last one. * * @param array $cached Cached payload. * @return bool */ private static function force_throttled( $cached ) { $fetched = self::fetched_at( $cached ); if ( ! $fetched ) { return false; } return ( time() - $fetched ) < self::FORCE_THROTTLE; } /** * @param array $cached Cached payload. * @return int|null */ private static function fetched_at( $cached ) { return ( is_array( $cached ) && ! empty( $cached['fetched_at'] ) ) ? (int) $cached['fetched_at'] : null; } /** * @return array|null */ private static function last_error() { $error = get_option( 'yopingme_last_error', null ); return is_array( $error ) ? $error : null; } /** * @param array|null $cached Cached payload. * @param array|null $error Current error, if any. * @param int|null $fetched_at When the data was fetched. * @param bool $throttled Whether a forced refresh was refused. * @return array */ /** * @param array|null $cached Cached incident payload. * @param array|null $error Current error, if any. * @return array */ private static function incident_result( $cached, $error ) { $data = ( is_array( $cached ) && isset( $cached['incidents'] ) && is_array( $cached['incidents'] ) ) ? $cached : null; return array( 'data' => $data, 'error' => $error, 'fetched_at' => ( $data && ! empty( $data['fetched_at'] ) ) ? (int) $data['fetched_at'] : null, ); } private static function result( $cached, $error, $fetched_at, $throttled = false ) { $data = null; if ( is_array( $cached ) && isset( $cached['monitors'] ) && is_array( $cached['monitors'] ) ) { $data = $cached; } return array( 'data' => $data, 'error' => $error, 'fetched_at' => $fetched_at, 'throttled' => $throttled, ); } }