checked, errors, alerts. * * @return array */ public function all() { $all = get_option( self::OPTION, array() ); return is_array( $all ) ? $all : array(); } /** * One site's record. * * @param string $site_id Site ID. * @return array{checked:int,errors:array,alerts:array} */ public function site( $site_id ) { return self::record( $this->all(), $site_id ); } /** * Normalizes one record from a freshly read option. * * @param array $all Stored records. * @param string $site_id Site ID. * @return array */ private static function record( array $all, $site_id ) { $rec = isset( $all[ $site_id ] ) && is_array( $all[ $site_id ] ) ? $all[ $site_id ] : array(); return array( 'checked' => isset( $rec['checked'] ) ? (int) $rec['checked'] : 0, 'errors' => isset( $rec['errors'] ) && is_array( $rec['errors'] ) ? $rec['errors'] : array(), 'alerts' => isset( $rec['alerts'] ) && is_array( $rec['alerts'] ) ? $rec['alerts'] : array(), ); } /** * IDs of alerts that aren't resolved. * * @param string $site_id Site ID. * @return string[] */ public function open_ids( $site_id ) { $ids = array(); foreach ( $this->site( $site_id )['alerts'] as $id => $alert ) { if ( self::RESOLVED !== $alert['state'] ) { $ids[] = (string) $id; } } return $ids; } /** * Applies a fresh check to a site's alerts. * * @param string $site_id Site ID. * @param array $result Detector::detect() result. * @param array $period Period checked (s, e, ps, pe). * @param int $now Timestamp. * @return array The site's updated record. */ public function reconcile( $site_id, array $result, array $period, $now = 0 ) { return $this->update( $site_id, static function ( array $rec ) use ( $result, $period, $now ) { return self::reconcile_record( $rec, $result, $period, $now ); } ); } /** * Applies findings while the option update owns the current record. * * @param array $rec Current record. * @param array $result Findings and errors. * @param array $period Period. * @param int $now Timestamp. * @return array */ private static function reconcile_record( array $rec, array $result, array $period, $now ) { $now = $now ? (int) $now : time(); $alerts = $rec['alerts']; $found = array(); foreach ( $result['findings'] as $finding ) { $found[ $finding['id'] ] = $finding; } $failed = array_keys( $result['errors'] ); foreach ( $alerts as $id => $alert ) { if ( isset( $found[ $id ] ) ) { $finding = $found[ $id ]; unset( $found[ $id ] ); $was = $alert['severity']; $alert = array_merge( $alert, array( 'severity' => $finding['severity'], 'data' => $finding['data'], 'period' => $period, 'last_seen' => $now, ) ); $alert['back'] = ''; switch ( $alert['state'] ) { case self::RESOLVED: // The same issue again: a new occurrence. $alert = array_merge( $alert, self::fresh( $now ), array( 'back' => 'again' ) ); break; case self::SNOOZED: if ( $now >= (int) $alert['until'] ) { $alert['state'] = self::ACTIVE; $alert['back'] = 'snooze_over'; $alert['until'] = 0; } break; case self::HANDLED: if ( 'high' === $finding['severity'] && 'high' !== $alert['handled_severity'] ) { $alert['state'] = self::ACTIVE; $alert['back'] = 'worse'; } break; } if ( self::ACTIVE === $alert['state'] && 'high' === $finding['severity'] && 'medium' === $was && '' === $alert['back'] ) { $alert['back'] = 'worse'; } $alerts[ $id ] = $alert; continue; } if ( self::RESOLVED === $alert['state'] ) { continue; } // Not found: resolved, unless its source couldn't be read this time. if ( in_array( $alert['source'], $failed, true ) ) { continue; } $alert['resolved_from'] = $alert['state']; $alert['state'] = self::RESOLVED; $alert['resolved_at'] = $now; $alert['until'] = 0; $alerts[ $id ] = $alert; } foreach ( $found as $id => $finding ) { $alerts[ $id ] = array_merge( array( 'id' => $id, 'type' => $finding['type'], 'subject' => $finding['subject'], 'source' => $finding['source'], 'severity' => $finding['severity'], 'data' => $finding['data'], 'period' => $period, ), self::fresh( $now ), array( 'back' => '' ) ); } // Forget resolved alerts after a while. foreach ( $alerts as $id => $alert ) { if ( self::RESOLVED === $alert['state'] && (int) $alert['resolved_at'] < $now - self::KEEP_RESOLVED_DAYS * DAY_IN_SECONDS ) { unset( $alerts[ $id ] ); } } $rec = array( 'checked' => $now, 'errors' => $result['errors'], 'alerts' => $alerts, ); return $rec; } /** * Marks an alert as handled. * * @param string $site_id Site ID. * @param string $alert_id Alert ID. * @param int $user_id Who. * @return array|null The alert, or null if it doesn't exist. */ public function handle( $site_id, $alert_id, $user_id ) { return $this->change( $site_id, $alert_id, static function ( array $alert ) use ( $user_id ) { return array_merge( $alert, array( 'state' => self::HANDLED, 'handled_severity' => $alert['severity'], 'until' => 0, 'back' => '', 'changed_at' => time(), 'changed_by' => (int) $user_id, ) ); } ); } /** * Snoozes an alert for SNOOZE_DAYS. * * @param string $site_id Site ID. * @param string $alert_id Alert ID. * @param int $user_id Who. * @return array|null */ public function snooze( $site_id, $alert_id, $user_id ) { return $this->change( $site_id, $alert_id, static function ( array $alert ) use ( $user_id ) { return array_merge( $alert, array( 'state' => self::SNOOZED, 'until' => time() + self::SNOOZE_DAYS * DAY_IN_SECONDS, 'back' => '', 'changed_at' => time(), 'changed_by' => (int) $user_id, ) ); } ); } /** * Puts a handled or snoozed alert back on the list (undo). * * @param string $site_id Site ID. * @param string $alert_id Alert ID. * @param int $user_id Who. * @return array|null */ public function restore( $site_id, $alert_id, $user_id ) { return $this->change( $site_id, $alert_id, static function ( array $alert ) use ( $user_id ) { if ( self::RESOLVED === $alert['state'] ) { return $alert; } return array_merge( $alert, array( 'state' => self::ACTIVE, 'until' => 0, 'back' => '', 'changed_at' => time(), 'changed_by' => (int) $user_id, ) ); } ); } /** * Records that an alert was shared to Slack. * * @param string $site_id Site ID. * @param string $alert_id Alert ID. * @param int $user_id Who. * @return array|null */ public function shared( $site_id, $alert_id, $user_id ) { return $this->change( $site_id, $alert_id, static function ( array $alert ) use ( $user_id ) { $alert['slack_at'] = time(); $alert['slack_by'] = (int) $user_id; return $alert; } ); } /** * One alert. * * @param string $site_id Site ID. * @param string $alert_id Alert ID. * @return array|null */ public function find( $site_id, $alert_id ) { $alerts = $this->site( $site_id )['alerts']; return isset( $alerts[ $alert_id ] ) ? $alerts[ $alert_id ] : null; } /** * What the list should treat an alert as now: a snooze that ran out shows * again straight away, without waiting for the next check. * * @param array $alert Alert. * @param int $now Timestamp. * @return string */ public static function state( array $alert, $now = 0 ) { $now = $now ? (int) $now : time(); if ( self::SNOOZED === $alert['state'] && $now >= (int) $alert['until'] ) { return self::ACTIVE; } return (string) $alert['state']; } /** * Drops the records of sites that no longer exist. * * @param string[] $site_ids Existing site IDs. * @return void */ public function keep_only( array $site_ids ) { OptionUpdate::change( self::OPTION, static function ( array $all ) use ( $site_ids ) { return array_intersect_key( $all, array_flip( $site_ids ) ); } ); } /** * Default fields of a newly opened alert. * * @param int $now Timestamp. * @return array */ private static function fresh( $now ) { return array( 'state' => self::ACTIVE, 'first_seen' => $now, 'last_seen' => $now, 'until' => 0, 'handled_severity' => '', 'resolved_at' => 0, 'resolved_from' => '', 'changed_at' => 0, 'changed_by' => 0, 'slack_at' => 0, 'slack_by' => 0, ); } /** * Applies a change to one alert and saves it. * * @param string $site_id Site ID. * @param string $alert_id Alert ID. * @param callable $callback Receives and returns the alert. * @return array|null */ private function change( $site_id, $alert_id, callable $callback ) { $rec = $this->update( $site_id, static function ( array $rec ) use ( $alert_id, $callback ) { if ( ! isset( $rec['alerts'][ $alert_id ] ) ) { return null; } $rec['alerts'][ $alert_id ] = $callback( $rec['alerts'][ $alert_id ] ); return $rec; } ); return null === $rec ? null : $rec['alerts'][ $alert_id ]; } /** * Reads, changes and saves a site without losing parallel edits. * * @param string $site_id Site ID. * @param callable $change Receives the current record; null leaves it unchanged. * @return array|null */ private function update( $site_id, callable $change ) { $record = null; OptionUpdate::change( self::OPTION, static function ( array $all ) use ( $site_id, $change, &$record ) { $record = $change( self::record( $all, $site_id ) ); if ( null !== $record ) { $all[ $site_id ] = $record; } return $all; } ); return $record; } }