*/ private $rows = array(); /** * Constructor. * * @param ProfileRepository $sites Site storage. * @param ConnectionRepository $connections Connections. * @param HealthMonitor $health Connection health. * @param RunRepository $runs Report runs. * @param EmailLog $email_log Email status log. * @param callable|null $monitor Returns the Client Monitor. * @param Slack|null $slack Slack webhook. */ public function __construct( ProfileRepository $sites, ConnectionRepository $connections, HealthMonitor $health, RunRepository $runs, EmailLog $email_log, ?callable $monitor = null, ?Slack $slack = null ) { $this->sites = $sites; $this->connections = $connections; $this->health = $health; $this->runs = $runs; $this->email_log = $email_log; $this->monitor = $monitor; $this->slack = $slack; } /** * The Client Monitor, when it's switched on. * * @return Monitor|null */ private function monitor() { if ( ! $this->monitor ) { return null; } $monitor = call_user_func( $this->monitor ); return $monitor instanceof Monitor && $monitor->enabled() ? $monitor : null; } /** * {@inheritDoc} * * @return string */ public function menu_title() { return __( 'Client Monitor', 'turtlereport' ); } /** * {@inheritDoc} * * @return string */ public function capability() { return Capabilities::MANAGE; } /** * {@inheritDoc} * * @return void */ public function render() { $rows = $this->rows(); View::render( 'overview', array( 'rows' => $rows, 'period' => Periods::last_days( 28 ), 'emails' => self::email_summary( $rows ), 'settings' => SettingsPage::url() . '#tly-schedules', 'monitor' => null !== $this->monitor(), 'setup' => SettingsPage::url() . '#tly-monitor', ) ); } /** * {@inheritDoc} * * @return void */ public function enqueue() { $this->enqueue_admin_style(); $rows = $this->rows(); if ( ! $rows ) { return; } // The admin bundle provides the "Send now" confirmations. AdminAssets::enqueue( 'admin', 'turtlereport-admin' ); AdminAssets::enqueue( 'overview', 'turtlereport-overview' ); $period = Periods::last_days( 28 ); $sites = array(); foreach ( $rows as $row ) { $sites[] = array( 'id' => $row['id'], 'name' => $row['name'], 'url' => $row['report_url'], 'search' => $row['search'], 'conv' => $row['conv'], 'convLabel' => $row['conv_label'], 'events' => $row['events'], 'issues' => $row['issues'], ); } $monitor = $this->monitor(); wp_add_inline_script( 'turtlereport-overview', 'window.turtlereportOverview = ' . wp_json_encode( array( 'sites' => $sites, 'period' => $period, 'monitor' => null !== $monitor, ) ) . ';', 'before' ); if ( $monitor ) { $payload = $monitor->payload( $this->sites->all() ); $payload['slack'] = $this->slack && $this->slack->configured(); $payload['setup'] = SettingsPage::url() . '#tly-monitor'; wp_add_inline_script( 'turtlereport-overview', 'window.turtlereportAttention = ' . wp_json_encode( $payload ) . ';', 'before' ); } } /** * One row per site, sorted A–Z. Holds no credentials. * * @return array[] */ private function rows() { $blog = get_current_blog_id(); if ( isset( $this->rows[ $blog ] ) ) { return $this->rows[ $blog ]; } $rows = array(); $tables = Schema::ready(); foreach ( $this->sites->all() as $site ) { $rows[] = array( 'id' => $site->id, 'name' => $site->display_name(), 'label' => $site->site_label(), 'report_url' => ReportsPage::url( array( 'site' => $site->id ) ), 'edit_url' => SitesPage::url( array( 'action' => 'edit', 'site' => $site->id, ) ), 'search' => $site->wants( Profile::SECTION_SEARCH ), 'conv' => $site->wants( Profile::SECTION_CONVERSIONS ), 'conv_label' => $site->conversion_label(), 'events' => array_values( array_unique( $site->all_conversion_events() ) ), 'status' => $this->status( $site ), 'email' => $this->email( $site, $tables ), ); $rows[ count( $rows ) - 1 ]['issues'] = self::issues( $rows[ count( $rows ) - 1 ] ); } usort( $rows, static function ( $a, $b ) { return strnatcasecmp( $a['name'], $b['name'] ); } ); $this->rows[ $blog ] = $rows; return $rows; } /** * Whether the site's data is working, in one badge: the worst of its * connections and its setup. * * @param Profile $site Site. * @return array{tone:string,text:string,detail:string,url:string} */ private function status( Profile $site ) { $wanted = false; foreach ( array( Profile::SECTION_SEARCH, Profile::SECTION_CONVERSIONS, Profile::SECTION_AI, Profile::SECTION_TRAFFIC, Profile::SECTION_BUSINESS ) as $section ) { $wanted = $wanted || $site->wants( $section ); } if ( ! $wanted ) { return array( 'tone' => 'warn', 'text' => __( 'Not set up', 'turtlereport' ), 'detail' => __( 'Choose a Search Console or GA4 property in the site\'s settings.', 'turtlereport' ), 'url' => SitesPage::url( array( 'action' => 'edit', 'site' => $site->id, ) ), ); } foreach ( $site->connection_ids() as $id ) { $connection = $this->connections->find( $id ); if ( ! $connection ) { return array( 'tone' => 'bad', 'text' => __( 'Connection missing', 'turtlereport' ), 'detail' => __( 'The Google connection this site used was deleted. Pick another in the site\'s settings.', 'turtlereport' ), 'url' => SitesPage::url( array( 'action' => 'edit', 'site' => $site->id, ) ), ); } $health = $this->health->status( $id ); if ( ! $connection->is_connected() || false === $health['ok'] ) { return array( 'tone' => 'bad', 'text' => __( 'Google connection not working', 'turtlereport' ), 'detail' => '' !== (string) $health['message'] ? (string) $health['message'] : __( 'Reconnect it on the Connections screen.', 'turtlereport' ), 'url' => ConnectionsPage::url() . '#tly-conn-' . rawurlencode( $id ), ); } } return array( 'tone' => 'good', 'text' => __( 'Connected', 'turtlereport' ), 'detail' => '', 'url' => '', ); } /** * The site's report email: its last scheduled report and the next one. * * @param Profile $site Site. * @param bool $tables Whether the history tables exist. * @return array{state:string,status:string,active:bool,last:string,tone:string,when:int,next:int,url:string,settings:string} */ private function email( Profile $site, $tables ) { $log = $this->email_log->get( $site->id ); $out = array( 'state' => Schedule::is_active( $site->email ) ? 'new' : ( empty( $site->email['enabled'] ) ? 'off' : 'nobody' ), 'status' => '', 'active' => Schedule::is_active( $site->email ), 'last' => '', 'tone' => 'flat', 'when' => (int) $log['last_sent'], 'next' => Schedule::is_active( $site->email ) ? (int) $log['next'] : 0, 'url' => DeliveriesPage::url( array( 'site' => $site->id ) ), ); if ( $tables ) { $latest = $this->runs->query( array( 'site_id' => $site->id, 'kind' => RunRepository::KIND_SCHEDULED, 'limit' => 1, ) ); if ( $latest ) { $run = $latest[0]; $out['status'] = $run['status']; $out['last'] = RunRepository::label( $run['status'] ); $out['tone'] = RunRepository::tone( $run['status'] ); $out['when'] = (int) ( $run['sent_at'] ? $run['sent_at'] : $run['created'] ); $out['url'] = DeliveriesPage::url( array( 'run' => $run['id'] ) ); } } if ( '' === $out['last'] && '' !== (string) $log['last_error'] ) { $out['status'] = RunRepository::FAILED; $out['last'] = __( 'Failed', 'turtlereport' ); $out['tone'] = 'bad'; } elseif ( '' === $out['last'] && $out['when'] ) { $out['status'] = RunRepository::SENT; $out['last'] = RunRepository::label( RunRepository::SENT ); $out['tone'] = 'good'; } if ( $out['active'] ) { switch ( $out['status'] ) { case RunRepository::SENT: $out['state'] = 'sent'; break; case RunRepository::FAILED: case RunRepository::PARTIAL: $out['state'] = 'failed'; break; case '': $out['state'] = 'new'; break; default: $out['state'] = 'waiting'; } } $out['settings'] = SettingsPage::url() . '#tly-schedules'; return $out; } /** * Problems the server already knows about for one site: its data * connection and its report email. The browser adds drops in clicks, * leads and position once the numbers load. * * @param array $row Row from rows(). * @return array[] Items with tone (bad|warn), text, link and action. */ private static function issues( array $row ) { $out = array(); if ( 'good' !== $row['status']['tone'] ) { $out[] = array( 'tone' => $row['status']['tone'], 'text' => $row['status']['text'] . ( '' !== $row['status']['detail'] ? ': ' . $row['status']['detail'] : '' ), 'link' => $row['status']['url'], 'action' => __( 'Fix it', 'turtlereport' ), ); } $email = $row['email']; switch ( $email['state'] ) { case 'failed': $out[] = array( 'tone' => 'bad', 'text' => __( 'The last report email failed for some or all recipients.', 'turtlereport' ), 'link' => $email['url'], 'action' => __( 'See why and resend', 'turtlereport' ), ); break; case 'waiting': $out[] = array( 'tone' => 'warn', 'text' => RunRepository::REVIEW === $email['status'] ? __( 'A report email is waiting for your approval.', 'turtlereport' ) : ( RunRepository::HELD === $email['status'] ? __( 'A report email was held because some data couldn\'t be loaded.', 'turtlereport' ) : __( 'A report email is still being sent or retried.', 'turtlereport' ) ), 'link' => $email['url'], 'action' => __( 'Review', 'turtlereport' ), ); break; case 'nobody': $out[] = array( 'tone' => 'warn', 'text' => __( 'The report email is on but has nobody to send to.', 'turtlereport' ), 'link' => $email['settings'], 'action' => __( 'Add recipients', 'turtlereport' ), ); break; } return $out; } /** * Counts for the "Report emails" box. * * @param array[] $rows Rows from rows(). * @return array{total:int,automated:int,sent:int,failed:int,waiting:int,new:int,next:int,next_count:int} */ public static function email_summary( array $rows ) { $out = array( 'total' => count( $rows ), 'automated' => 0, 'sent' => 0, 'failed' => 0, 'waiting' => 0, 'new' => 0, 'next' => 0, 'next_count' => 0, ); foreach ( $rows as $row ) { $email = $row['email']; if ( ! $email['active'] ) { continue; } ++$out['automated']; if ( isset( $out[ $email['state'] ] ) ) { ++$out[ $email['state'] ]; } if ( $email['next'] ) { $day = wp_date( 'Y-m-d', $email['next'] ); if ( ! $out['next'] || $email['next'] < $out['next'] ) { $out['next_count'] = ( $out['next'] && wp_date( 'Y-m-d', $out['next'] ) === $day ) ? $out['next_count'] + 1 : 1; $out['next'] = $email['next']; } elseif ( wp_date( 'Y-m-d', $out['next'] ) === $day ) { ++$out['next_count']; } } } return $out; } }