Keepwatch page. The local dashboard is fully functional * without any remote account (WordPress.org trialware rule). */ final class Menu { public const SLUG = 'keepwatch'; public const CLEAR_ACTION = 'keepwatch_clear_errors'; public const SAVE_ACTION = 'keepwatch_save_settings'; private const STYLE_HANDLE = 'keepwatch-admin'; /** * Page hook suffix. * * @var string */ private $hook = ''; /** * Register hooks. */ public function register(): void { add_action( 'admin_menu', array( $this, 'add_page' ) ); add_action( 'admin_enqueue_scripts', array( $this, 'enqueue' ) ); add_action( 'admin_post_' . self::CLEAR_ACTION, array( $this, 'clear_errors' ) ); add_action( 'admin_post_' . self::SAVE_ACTION, array( $this, 'save_settings' ) ); add_filter( 'plugin_action_links_' . plugin_basename( KEEPWATCH_PLUGIN_FILE ), array( $this, 'action_links' ) ); } /** * Add the page under Tools. */ public function add_page(): void { $hook = add_management_page( __( 'Keepwatch', 'keepwatch' ), __( 'Keepwatch', 'keepwatch' ), 'manage_options', self::SLUG, array( $this, 'render' ) ); $this->hook = is_string( $hook ) ? $hook : ''; } /** * Styles only on our own screens (page + dashboard widget). * * @param string $hook_suffix Current admin page. */ public function enqueue( string $hook_suffix ): void { if ( $hook_suffix !== $this->hook && 'index.php' !== $hook_suffix ) { return; } wp_enqueue_style( self::STYLE_HANDLE, KEEPWATCH_PLUGIN_URL . 'assets/css/admin.css', array(), KEEPWATCH_VERSION ); } /** * "Dashboard" link on the Plugins screen. * * @param array $links Existing links. * @return array */ public function action_links( array $links ): array { $url = admin_url( 'tools.php?page=' . self::SLUG ); return array_merge( array( 'keepwatch' => '' . esc_html__( 'Dashboard', 'keepwatch' ) . '' ), $links ); } /** * Clear the local error list (nonce + capability). */ public function clear_errors(): void { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You do not have permission to do this.', 'keepwatch' ), 403 ); } check_admin_referer( self::CLEAR_ACTION ); ( new ErrorStore() )->clear(); wp_safe_redirect( self::tab_url( 'errors', array( 'keepwatch-cleared' => '1' ) ) ); exit; } /** * Save weekly email settings (nonce + capability). */ public function save_settings(): void { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You do not have permission to do this.', 'keepwatch' ), 403 ); } check_admin_referer( self::SAVE_ACTION ); $input = array(); if ( isset( $_POST['keepwatch'] ) && is_array( $_POST['keepwatch'] ) ) { // Sanitized inside Settings::update (sanitize_email / bool cast). // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized $input = wp_unslash( $_POST['keepwatch'] ); } Settings::update( $input ); wp_safe_redirect( self::tab_url( 'notifications', array( 'keepwatch-saved' => '1' ) ) ); exit; } /** * Group labels. * * @return array */ public static function group_labels(): array { return array( 'errors' => __( 'Errors', 'keepwatch' ), 'updates' => __( 'Updates', 'keepwatch' ), 'health' => __( 'Site Health', 'keepwatch' ), 'cron' => __( 'Scheduled tasks', 'keepwatch' ), 'environment' => __( 'Server & configuration', 'keepwatch' ), 'database' => __( 'Database', 'keepwatch' ), 'security' => __( 'Security', 'keepwatch' ), 'backups' => __( 'Backups', 'keepwatch' ), 'woocommerce' => __( 'WooCommerce', 'keepwatch' ), ); } /** * Tabs: slug => label. * * @return array */ public static function tabs(): array { return array( 'overview' => __( 'Overview', 'keepwatch' ), 'checks' => __( 'Checks', 'keepwatch' ), 'errors' => __( 'PHP errors', 'keepwatch' ), 'notifications' => __( 'Notifications', 'keepwatch' ), 'cloud' => __( 'WPXLab cloud', 'keepwatch' ), ); } /** * URL of a tab. * * @param string $tab Tab slug. * @param array $args Extra query args. */ public static function tab_url( string $tab, array $args = array() ): string { return add_query_arg( array_merge( array( 'tab' => $tab ), $args ), admin_url( 'tools.php?page=' . self::SLUG ) ); } /** * Render the page. */ public function render(): void { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You do not have permission to access this page.', 'keepwatch' ) ); } $tabs = self::tabs(); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only navigation. $tab = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : 'overview'; if ( ! isset( $tabs[ $tab ] ) ) { $tab = 'overview'; } $groups = Registry::run( Registry::checks() ); $summary = Registry::summary( $groups ); $errors = ( new ErrorStore() )->all(); echo '
'; $this->render_header( $summary ); $this->render_tabs( $tab, $summary, count( $errors ) ); $this->render_notices(); echo '
'; switch ( $tab ) { case 'checks': $this->render_checks( $groups, $summary ); break; case 'errors': $changes = new ChangeLog(); $changes->detect(); $this->render_errors( $errors, $changes->all() ); break; case 'notifications': $this->render_settings(); break; case 'cloud': ( new ConnectionPanel() )->render(); break; default: $this->render_overview( $groups, $summary, count( $errors ) ); } echo '
'; $footnote = Connection::state()['connected'] ? __( 'All checks run on this site. A short summary is sent hourly to your WPXLab account.', 'keepwatch' ) : __( 'All checks run on this site. Nothing is sent anywhere.', 'keepwatch' ); echo '

' . esc_html( $footnote ) . '

'; echo '
'; } /** * Brand header with overall status. * * @param array{good: int, recommended: int, critical: int} $summary Counts. */ private function render_header( array $summary ): void { if ( $summary[ Result::CRITICAL ] > 0 ) { $tone = Result::CRITICAL; $label = sprintf( /* translators: %d: number of critical issues */ _n( '%d critical issue', '%d critical issues', $summary[ Result::CRITICAL ], 'keepwatch' ), $summary[ Result::CRITICAL ] ); } elseif ( $summary[ Result::RECOMMENDED ] > 0 ) { $tone = Result::RECOMMENDED; $label = sprintf( /* translators: %d: number of recommendations */ _n( '%d recommendation', '%d recommendations', $summary[ Result::RECOMMENDED ], 'keepwatch' ), $summary[ Result::RECOMMENDED ] ); } else { $tone = Result::GOOD; $label = __( 'All checks passed', 'keepwatch' ); } echo '
'; echo '
' . wp_kses( self::logo(), self::logo_tags() ) . '

' . esc_html__( 'Keepwatch', 'keepwatch' ) . '

'; printf( 'v%s
', esc_html( KEEPWATCH_VERSION ) ); printf( '
%2$s', esc_attr( $tone ), esc_html( $label ) ); printf( '%2$s
', esc_url( self::tab_url( 'checks' ) ), esc_html__( 'View checks', 'keepwatch' ) ); echo '
'; } /** * Tab navigation. * * @param string $current Current tab. * @param array{good: int, recommended: int, critical: int} $summary Counts. * @param int $errors Recorded fatal errors. */ private function render_tabs( string $current, array $summary, int $errors ): void { $badges = array( 'checks' => array( $summary[ Result::CRITICAL ] + $summary[ Result::RECOMMENDED ], $summary[ Result::CRITICAL ] > 0 ? Result::CRITICAL : Result::RECOMMENDED ), 'errors' => array( $errors, Result::CRITICAL ), ); $connected = Connection::state()['connected']; echo '
'; } /** * One-time notices after redirects. */ private function render_notices(): void { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- display-only flag after a nonce-checked redirect. if ( isset( $_GET['keepwatch-cleared'] ) ) { echo '

' . esc_html__( 'Error list cleared.', 'keepwatch' ) . '

'; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- display-only flag after a nonce-checked redirect. if ( isset( $_GET['keepwatch-saved'] ) ) { echo '

' . esc_html__( 'Settings saved.', 'keepwatch' ) . '

'; } } /** * Overview tab: score, what needs attention, site facts, shortcuts. * * @param array $groups Results by group. * @param array{good: int, recommended: int, critical: int} $summary Counts. * @param int $errors Recorded fatal errors. */ private function render_overview( array $groups, array $summary, int $errors ): void { $total = $summary[ Result::GOOD ] + $summary[ Result::RECOMMENDED ] + $summary[ Result::CRITICAL ]; $score = $total > 0 ? (int) round( $summary[ Result::GOOD ] / $total * 100 ) : 100; $tone = $summary[ Result::CRITICAL ] > 0 ? Result::CRITICAL : ( $summary[ Result::RECOMMENDED ] > 0 ? Result::RECOMMENDED : Result::GOOD ); $headlines = array( Result::CRITICAL => __( 'Your site needs attention', 'keepwatch' ), Result::RECOMMENDED => __( 'Your site is in good shape', 'keepwatch' ), Result::GOOD => __( 'Everything looks healthy', 'keepwatch' ), ); $texts = array( Result::CRITICAL => __( 'Fix the critical items below first. They can break the site or put it at risk.', 'keepwatch' ), Result::RECOMMENDED => __( 'No critical problems. A few recommendations can make the site faster and safer.', 'keepwatch' ), Result::GOOD => __( 'All checks passed. Keepwatch keeps watching and records new problems as they appear.', 'keepwatch' ), ); echo '
'; printf( // Ring drawn with SVG attributes (pathLength + stroke-dasharray), no inline CSS (Envato requirement). '', esc_attr( $tone ), (int) $score, esc_attr( sprintf( /* translators: %d: percentage of passed checks */ __( '%d%% of checks passed', 'keepwatch' ), $score ) ) ); echo '

' . esc_html( $headlines[ $tone ] ) . '

' . esc_html( $texts[ $tone ] ) . '

'; self::render_summary( $summary ); echo '
'; $open = array(); foreach ( $groups as $results ) { foreach ( $results as $result ) { if ( Result::GOOD !== $result->status ) { $open[] = $result; } } } usort( $open, static function ( Result $a, Result $b ): int { return $a->weight() <=> $b->weight(); } ); echo '
'; echo '

' . esc_html__( 'Needs attention', 'keepwatch' ) . '

'; printf( '%2$s
', esc_url( self::tab_url( 'checks' ) ), esc_html__( 'All checks', 'keepwatch' ) ); if ( array() === $open ) { echo '

' . esc_html__( 'Nothing to fix right now.', 'keepwatch' ) . '

'; } else { echo '
    '; foreach ( array_slice( $open, 0, 6 ) as $result ) { self::render_result( $result ); } echo '
'; if ( count( $open ) > 6 ) { printf( '

%2$s

', esc_url( self::tab_url( 'checks' ) ), esc_html( sprintf( /* translators: %d: number of further items */ _n( 'Show %d more', 'Show %d more', count( $open ) - 6, 'keepwatch' ), count( $open ) - 6 ) ) ); } } echo '
'; echo '
'; $this->render_inventory( Inventory::snapshot() ); $this->render_shortcuts( $errors ); echo '
'; } /** * Small cards linking to errors and the cloud. * * @param int $errors Recorded fatal errors. */ private function render_shortcuts( int $errors ): void { printf( '%3$d%4$s
%5$s
', esc_url( self::tab_url( 'errors' ) ), $errors > 0 ? ' is-critical' : '', (int) $errors, esc_html__( 'PHP fatal errors', 'keepwatch' ), esc_html( $errors > 0 ? __( 'Recorded since activation. Review and clear.', 'keepwatch' ) : __( 'None recorded since activation.', 'keepwatch' ) ) ); $state = Connection::state(); if ( $state['connected'] ) { printf( '%2$s
%3$s
', esc_url( self::tab_url( 'cloud' ) ), esc_html__( 'Connected to WPXLab', 'keepwatch' ), esc_html__( 'History, outside checks and alerts are active.', 'keepwatch' ) ); return; } if ( Distribution::is_codecanyon() ) { return; // Envato: no premium service promotion in WP admin; the cloud tab stays as plain information. } printf( '%2$s
%3$s
', esc_url( self::tab_url( 'cloud' ) ), esc_html__( 'Watch all your sites in one place', 'keepwatch' ), esc_html__( 'Optional: connect to WPXLab for history, outside uptime checks and alerts.', 'keepwatch' ) ); } /** * Checks tab with a status filter. * * @param array $groups Results by group. * @param array{good: int, recommended: int, critical: int} $summary Counts. */ private function render_checks( array $groups, array $summary ): void { $labels = self::group_labels(); $filters = array( 'all' => array( __( 'All', 'keepwatch' ), $summary[ Result::GOOD ] + $summary[ Result::RECOMMENDED ] + $summary[ Result::CRITICAL ] ), Result::CRITICAL => array( __( 'Critical', 'keepwatch' ), $summary[ Result::CRITICAL ] ), Result::RECOMMENDED => array( __( 'Recommended', 'keepwatch' ), $summary[ Result::RECOMMENDED ] ), Result::GOOD => array( __( 'Passed', 'keepwatch' ), $summary[ Result::GOOD ] ), ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter. $filter = isset( $_GET['status'] ) ? sanitize_key( wp_unslash( $_GET['status'] ) ) : 'all'; if ( ! isset( $filters[ $filter ] ) ) { $filter = 'all'; } echo '
'; foreach ( $filters as $key => $item ) { printf( '%4$s %5$d', $key === $filter ? ' is-active' : '', esc_url( self::tab_url( 'checks', 'all' === $key ? array() : array( 'status' => $key ) ) ), $key === $filter ? ' aria-current="true"' : '', esc_html( $item[0] ), (int) $item[1] ); } echo '
'; $shown = 0; foreach ( $groups as $group => $results ) { if ( 'all' !== $filter ) { $results = array_values( array_filter( $results, static function ( Result $r ) use ( $filter ): bool { return $r->status === $filter; } ) ); } if ( array() === $results ) { continue; } ++$shown; $counts = array( Result::CRITICAL => 0, Result::RECOMMENDED => 0, Result::GOOD => 0, ); foreach ( $results as $result ) { ++$counts[ $result->status ]; } printf( '

%2$s

', esc_attr( 'group-' . $group ), esc_html( isset( $labels[ $group ] ) ? $labels[ $group ] : $group ) ); foreach ( $counts as $status => $count ) { if ( $count > 0 ) { printf( '%2$d', esc_attr( $status ), (int) $count ); } } echo '
    '; foreach ( $results as $result ) { self::render_result( $result ); } echo '
'; } if ( 0 === $shown ) { echo '

' . esc_html__( 'No checks with this status.', 'keepwatch' ) . '

'; } } /** * Allowed tags for the inline logo. * * @return array> */ private static function logo_tags(): array { $common = array( 'fill' => true, 'stroke' => true, 'stroke-width' => true, 'stroke-linecap' => true, 'stroke-linejoin' => true, ); return array( 'svg' => array( 'class' => true, 'viewbox' => true, 'width' => true, 'height' => true, 'aria-hidden' => true, 'focusable' => true, ), 'rect' => array_merge( $common, array( 'width' => true, 'height' => true, 'rx' => true, ) ), 'path' => array_merge( $common, array( 'd' => true ) ), 'polyline' => array_merge( $common, array( 'points' => true ) ), 'circle' => array_merge( $common, array( 'cx' => true, 'cy' => true, 'r' => true, ) ), ); } /** * Inline brand mark (no external request). */ private static function logo(): string { return ''; } /** * Summary tiles. * * @param array{good: int, recommended: int, critical: int} $summary Counts. */ public static function render_summary( array $summary ): void { $tiles = array( Result::CRITICAL => __( 'Critical', 'keepwatch' ), Result::RECOMMENDED => __( 'Recommended', 'keepwatch' ), Result::GOOD => __( 'Passed', 'keepwatch' ), ); echo '
'; foreach ( $tiles as $status => $label ) { printf( '
%2$d%3$s
', esc_attr( $status ), (int) $summary[ $status ], esc_html( $label ) ); } echo '
'; } /** * One result row. * * @param Result $result Result. */ public static function render_result( Result $result ): void { printf( '
  • ', esc_attr( $result->status ) ); echo '
    '; printf( '

    %s

    ', esc_html( $result->label ) ); if ( '' !== $result->description ) { printf( '

    %s

    ', esc_html( $result->description ) ); } echo '
    '; if ( '' !== $result->action_url ) { printf( '%2$s', esc_url( $result->action_url ), esc_html( '' !== $result->action_label ? $result->action_label : __( 'Fix', 'keepwatch' ) ) ); } echo '
  • '; } /** * Recent fatal errors. * * @param array $errors Errors. * @param array $changes Recent plugin/theme/core changes. */ private function render_errors( array $errors, array $changes = array() ): void { echo '

    ' . esc_html__( 'Recent PHP fatal errors', 'keepwatch' ) . '

    '; if ( array() === $errors ) { echo '

    ' . esc_html__( 'No fatal errors recorded. Keepwatch records them from the moment it is activated.', 'keepwatch' ) . '

    '; $this->render_changes( $changes ); return; } printf( '
    ', esc_url( admin_url( 'admin-post.php' ) ), esc_attr( self::CLEAR_ACTION ) ); wp_nonce_field( self::CLEAR_ACTION ); submit_button( __( 'Clear error list', 'keepwatch' ), 'secondary small', 'submit', false ); echo '
    '; echo '

    ' . esc_html__( 'File paths and secrets are redacted before they are stored.', 'keepwatch' ) . '

    '; echo '
    '; echo ''; echo ''; echo ''; echo ''; echo ''; foreach ( $errors as $error ) { $link = Correlator::match( Correlator::component( (string) $error['file'] ), (int) $error['first_seen'], $changes ); printf( '', esc_html( $error['type'] ), esc_html( $error['message'] ), esc_html( $error['file'] ), (int) $error['line'], (int) $error['count'], esc_html( /* translators: %s: human readable time difference, e.g. "5 mins" */ sprintf( __( '%s ago', 'keepwatch' ), human_time_diff( (int) $error['last_seen'] ) ) ), null === $link ? '' : '
    ' . esc_html( self::link_text( $link, (int) $error['first_seen'] ) ) . '' ); } echo '
    ' . esc_html__( 'Error', 'keepwatch' ) . '' . esc_html__( 'Location', 'keepwatch' ) . '' . esc_html__( 'Count', 'keepwatch' ) . '' . esc_html__( 'Last seen', 'keepwatch' ) . '
    %1$s %2$s%7$s%3$s:%4$d%5$d%6$s
    '; $this->render_changes( $changes ); } /** * "Possible link" sentence for an error. * * @param array{change: array{time: int, kind: string, type: string, slug: string, name: string, from: string|null, to: string|null}, reason: string} $link Match. * @param int $first_seen Error start. */ private static function link_text( array $link, int $first_seen ): string { $when = human_time_diff( $link['change']['time'], $first_seen ); $what = self::change_text( $link['change'] ); if ( 'same_component' === $link['reason'] ) { /* translators: 1: change, e.g. "WooCommerce updated 9.1 → 9.2", 2: time difference, e.g. "5 mins" */ return sprintf( __( 'Possible cause: started %2$s after %1$s (same plugin or theme).', 'keepwatch' ), $what, $when ); } /* translators: 1: change, e.g. "WooCommerce updated 9.1 → 9.2", 2: time difference, e.g. "5 mins" */ return sprintf( __( 'Possibly related: started %2$s after %1$s.', 'keepwatch' ), $what, $when ); } /** * One change as text. * * @param array{time: int, kind: string, type: string, slug: string, name: string, from: string|null, to: string|null} $change Change. */ private static function change_text( array $change ): string { switch ( $change['kind'] ) { case 'update': /* translators: 1: plugin/theme name, 2: old version, 3: new version */ return sprintf( __( '%1$s was updated %2$s → %3$s', 'keepwatch' ), $change['name'], (string) $change['from'], (string) $change['to'] ); case 'install': /* translators: %s: plugin/theme name */ return sprintf( __( '%s was installed', 'keepwatch' ), $change['name'] ); case 'delete': /* translators: %s: plugin/theme name */ return sprintf( __( '%s was deleted', 'keepwatch' ), $change['name'] ); case 'activate': /* translators: %s: plugin name */ return sprintf( __( '%s was activated', 'keepwatch' ), $change['name'] ); case 'deactivate': /* translators: %s: plugin name */ return sprintf( __( '%s was deactivated', 'keepwatch' ), $change['name'] ); case 'theme_switch': /* translators: %s: theme name */ return sprintf( __( 'the theme was switched to %s', 'keepwatch' ), $change['name'] ); } return $change['name']; } /** * Recent plugin, theme and core changes. * * @param array $changes Changes, newest first. */ private function render_changes( array $changes ): void { echo '

    ' . esc_html__( 'Recent changes', 'keepwatch' ) . '

    '; echo '

    ' . esc_html__( 'Plugin, theme and WordPress updates, installs and activations from the last 30 days. A fatal error that starts soon after a change is marked as a possible link above.', 'keepwatch' ) . '

    '; if ( array() === $changes ) { echo '

    ' . esc_html__( 'No changes recorded yet. Keepwatch records them from the moment it is activated.', 'keepwatch' ) . '

    '; return; } echo '
    '; foreach ( array_slice( $changes, 0, 30 ) as $change ) { printf( '', esc_html( ucfirst( self::change_text( $change ) ) ), esc_html( /* translators: %s: human readable time difference, e.g. "5 mins" */ sprintf( __( '%s ago', 'keepwatch' ), human_time_diff( $change['time'] ) ) ) ); } echo '
    ' . esc_html__( 'Change', 'keepwatch' ) . '' . esc_html__( 'When', 'keepwatch' ) . '
    %1$s%2$s
    '; } /** * Site facts card. * * @param array $snap Snapshot from Inventory::snapshot(). */ private function render_inventory( array $snap ): void { $rows = array( __( 'WordPress', 'keepwatch' ) => (string) $snap['wordpress'], __( 'PHP', 'keepwatch' ) => (string) $snap['php'], ); if ( '' !== $snap['mysql'] ) { $rows[ __( 'Database', 'keepwatch' ) ] = (string) $snap['mysql']; } if ( null !== $snap['woocommerce'] ) { $rows[ __( 'WooCommerce', 'keepwatch' ) ] = (string) $snap['woocommerce']; } $rows[ __( 'Theme', 'keepwatch' ) ] = (string) $snap['theme']; $rows[ __( 'Plugins', 'keepwatch' ) ] = sprintf( /* translators: 1: active plugins, 2: total plugins */ __( '%1$d active / %2$d installed', 'keepwatch' ), $snap['active_plugins'], $snap['total_plugins'] ); echo '

    ' . esc_html__( 'This site', 'keepwatch' ) . '

    '; foreach ( $rows as $label => $value ) { printf( '
    %1$s
    %2$s
    ', esc_html( $label ), esc_html( $value ) ); } echo '
    '; } /** * Email alert settings. */ private function render_settings(): void { $settings = Settings::all(); $recipient = '' !== $settings['weekly_email_to'] ? $settings['weekly_email_to'] : (string) get_option( 'admin_email' ); printf( '
    ', esc_url( admin_url( 'admin-post.php' ) ), esc_attr( self::SAVE_ACTION ) ); wp_nonce_field( self::SAVE_ACTION ); echo '

    ' . esc_html__( 'Email alerts', 'keepwatch' ) . '

    '; echo '

    ' . esc_html__( 'Optional emails sent by this site with wp_mail. Keepwatch does not send data to any remote service for these.', 'keepwatch' ) . '

    '; $toggles = array( 'weekly_email' => array( __( 'Weekly health summary', 'keepwatch' ), __( 'A short overview of checks and errors, once a week.', 'keepwatch' ) ), 'critical_email' => array( __( 'New critical issues', 'keepwatch' ), __( 'An email as soon as a new critical problem is found.', 'keepwatch' ) ), ); echo '
    '; foreach ( $toggles as $key => $text ) { printf( '', esc_html( $text[0] ), esc_html( $text[1] ), esc_attr( $key ), checked( $settings[ $key ], true, false ) ); } echo '
    '; echo '

    '; printf( '', esc_attr( $settings['weekly_email_to'] ), esc_attr( $recipient ) ); echo '' . esc_html__( 'Leave blank to use the site admin email.', 'keepwatch' ) . '

    '; // Uninstall choice (Envato: ask before deleting data and allow keeping it). echo '

    ' . esc_html__( 'Data on uninstall', 'keepwatch' ) . '

    '; echo '
    '; printf( '', esc_html__( 'Delete all Keepwatch data when the plugin is deleted', 'keepwatch' ), esc_html__( 'Removes settings, the error and change logs and the WPXLab connection details. Leave this off to keep your data if you reinstall later. Deactivating never deletes anything.', 'keepwatch' ), checked( $settings['delete_data'], true, false ) ); echo '
    '; submit_button( __( 'Save settings', 'keepwatch' ), 'primary', 'submit', false ); echo '
    '; } }