diff --git a/app/Http/RequestHandlers/TelemetryPreviewPage.php b/app/Http/RequestHandlers/TelemetryPreviewPage.php new file mode 100644 index 0000000000..03dc8826cc --- /dev/null +++ b/app/Http/RequestHandlers/TelemetryPreviewPage.php @@ -0,0 +1,58 @@ +. + */ + +declare(strict_types=1); + +namespace Fisharebest\Webtrees\Http\RequestHandlers; + +use Fisharebest\Webtrees\Http\ViewResponseTrait; +use Fisharebest\Webtrees\I18N; +use Fisharebest\Webtrees\Services\TelemetryDataService; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Server\RequestHandlerInterface; + +use function json_encode; + +use const JSON_PRETTY_PRINT; +use const JSON_UNESCAPED_SLASHES; +use const JSON_UNESCAPED_UNICODE; + +final class TelemetryPreviewPage implements RequestHandlerInterface +{ + use ViewResponseTrait; + + public function __construct( + private readonly TelemetryDataService $telemetry_data_service, + ) { + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + $this->layout = 'layouts/administration'; + + $payload = $this->telemetry_data_service->assemblePayload(); + $json = json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + + $title = I18N::translate('Telemetry data preview'); + + return $this->viewResponse('admin/telemetry-preview', [ + 'title' => $title, + 'payload' => $json, + ]); + } +} diff --git a/app/Http/RequestHandlers/TelemetrySettingsAction.php b/app/Http/RequestHandlers/TelemetrySettingsAction.php new file mode 100644 index 0000000000..73ad44cb0f --- /dev/null +++ b/app/Http/RequestHandlers/TelemetrySettingsAction.php @@ -0,0 +1,47 @@ +. + */ + +declare(strict_types=1); + +namespace Fisharebest\Webtrees\Http\RequestHandlers; + +use Fisharebest\Webtrees\FlashMessages; +use Fisharebest\Webtrees\I18N; +use Fisharebest\Webtrees\Site; +use Fisharebest\Webtrees\Validator; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Server\RequestHandlerInterface; + +use function redirect; +use function route; + +final class TelemetrySettingsAction implements RequestHandlerInterface +{ + public function handle(ServerRequestInterface $request): ResponseInterface + { + $endpointUrl = Validator::parsedBody($request)->string('TELEMETRY_ENDPOINT_URL'); + $publishableKey = Validator::parsedBody($request)->string('TELEMETRY_PUBLISHABLE_KEY'); + + Site::setPreference('TELEMETRY_ENDPOINT_URL', $endpointUrl); + Site::setPreference('TELEMETRY_PUBLISHABLE_KEY', $publishableKey); + + FlashMessages::addMessage(I18N::translate('The telemetry settings have been updated.'), 'success'); + + return redirect(route(ControlPanel::class)); + } +} diff --git a/app/Http/RequestHandlers/TelemetrySettingsPage.php b/app/Http/RequestHandlers/TelemetrySettingsPage.php new file mode 100644 index 0000000000..a91af3b3f1 --- /dev/null +++ b/app/Http/RequestHandlers/TelemetrySettingsPage.php @@ -0,0 +1,45 @@ +. + */ + +declare(strict_types=1); + +namespace Fisharebest\Webtrees\Http\RequestHandlers; + +use Fisharebest\Webtrees\Http\ViewResponseTrait; +use Fisharebest\Webtrees\I18N; +use Fisharebest\Webtrees\Site; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Server\RequestHandlerInterface; + +final class TelemetrySettingsPage implements RequestHandlerInterface +{ + use ViewResponseTrait; + + public function handle(ServerRequestInterface $request): ResponseInterface + { + $this->layout = 'layouts/administration'; + + $title = I18N::translate('Telemetry settings'); + + return $this->viewResponse('admin/telemetry-settings', [ + 'title' => $title, + 'endpoint_url' => Site::getPreference('TELEMETRY_ENDPOINT_URL') ?: 'https://vjdwtasmykejamkdprro.supabase.co', + 'publishable_key' => Site::getPreference('TELEMETRY_PUBLISHABLE_KEY') ?: 'sb_publishable_mzCwYJ8Fs2yjE2gvQgDDeg_Vwpj5uDQ', + ]); + } +} diff --git a/app/Http/RequestHandlers/TelemetrySubmitAction.php b/app/Http/RequestHandlers/TelemetrySubmitAction.php new file mode 100644 index 0000000000..c7feb3c6d4 --- /dev/null +++ b/app/Http/RequestHandlers/TelemetrySubmitAction.php @@ -0,0 +1,86 @@ +. + */ + +declare(strict_types=1); + +namespace Fisharebest\Webtrees\Http\RequestHandlers; + +use Fisharebest\Webtrees\FlashMessages; +use Fisharebest\Webtrees\I18N; +use Fisharebest\Webtrees\Services\TelemetryDataService; +use Fisharebest\Webtrees\Site; +use GuzzleHttp\Client; +use GuzzleHttp\Exception\GuzzleException; +use GuzzleHttp\Exception\RequestException; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Server\RequestHandlerInterface; + +use function json_encode; +use function redirect; +use function route; + +use const JSON_UNESCAPED_SLASHES; +use const JSON_UNESCAPED_UNICODE; + +final class TelemetrySubmitAction implements RequestHandlerInterface +{ + public function __construct( + private readonly TelemetryDataService $telemetry_data_service, + ) { + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + $payload = $this->telemetry_data_service->assemblePayload(); + $json = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + + $endpointUrl = Site::getPreference('TELEMETRY_ENDPOINT_URL') ?: 'https://vjdwtasmykejamkdprro.supabase.co'; + $publishableKey = Site::getPreference('TELEMETRY_PUBLISHABLE_KEY') ?: 'sb_publishable_mzCwYJ8Fs2yjE2gvQgDDeg_Vwpj5uDQ'; + + $url = rtrim($endpointUrl, '/') . '/rest/v1/rpc/submit_webtrees_telemetry'; + + try { + $client = new Client([ + 'timeout' => 15, + ]); + + $client->post($url, [ + 'headers' => [ + 'Content-Type' => 'application/json', + 'apikey' => $publishableKey, + 'Authorization' => 'Bearer ' . $publishableKey, + 'Prefer' => 'return=minimal', + ], + 'body' => $json, + ]); + + FlashMessages::addMessage(I18N::translate('Telemetry data has been sent successfully. Thank you!'), 'success'); + } catch (GuzzleException $exception) { + $errorDetail = ''; + if ($exception instanceof RequestException && $exception->getResponse() !== null) { + $errorDetail = (string) $exception->getResponse()->getBody(); + } + if ($errorDetail === '') { + $errorDetail = $exception->getMessage(); + } + FlashMessages::addMessage(I18N::translate('Failed to send telemetry data.') . ' ' . $errorDetail, 'danger'); + } + + return redirect(route(ControlPanel::class)); + } +} diff --git a/app/Http/Routes/WebRoutes.php b/app/Http/Routes/WebRoutes.php index d0d8825958..cde0722fca 100644 --- a/app/Http/Routes/WebRoutes.php +++ b/app/Http/Routes/WebRoutes.php @@ -280,6 +280,10 @@ use Fisharebest\Webtrees\Http\RequestHandlers\SubmissionPage; use Fisharebest\Webtrees\Http\RequestHandlers\SubmitterPage; use Fisharebest\Webtrees\Http\RequestHandlers\SynchronizeTrees; +use Fisharebest\Webtrees\Http\RequestHandlers\TelemetryPreviewPage; +use Fisharebest\Webtrees\Http\RequestHandlers\TelemetrySettingsAction; +use Fisharebest\Webtrees\Http\RequestHandlers\TelemetrySettingsPage; +use Fisharebest\Webtrees\Http\RequestHandlers\TelemetrySubmitAction; use Fisharebest\Webtrees\Http\RequestHandlers\TomSelectFamily; use Fisharebest\Webtrees\Http\RequestHandlers\TomSelectIndividual; use Fisharebest\Webtrees\Http\RequestHandlers\TomSelectLocation; @@ -445,6 +449,10 @@ public function load(Map $router): void $router->post(SiteRegistrationAction::class, '/site-registration'); $router->get(SiteTagsPage::class, '/tags'); $router->post(SiteTagsAction::class, '/tags'); + $router->get(TelemetryPreviewPage::class, '/telemetry-preview'); + $router->post(TelemetrySubmitAction::class, '/telemetry-submit'); + $router->get(TelemetrySettingsPage::class, '/telemetry-settings'); + $router->post(TelemetrySettingsAction::class, '/telemetry-settings'); $router->get(TreePageDefaultEdit::class, '/trees/default-blocks'); $router->post(TreePageDefaultUpdate::class, '/trees/default-blocks'); $router->get(MergeTreesPage::class, '/trees/merge'); diff --git a/app/Services/TelemetryDataService.php b/app/Services/TelemetryDataService.php new file mode 100644 index 0000000000..b23084aab8 --- /dev/null +++ b/app/Services/TelemetryDataService.php @@ -0,0 +1,178 @@ +. + */ + +declare(strict_types=1); + +namespace Fisharebest\Webtrees\Services; + +use Fisharebest\Webtrees\Contracts\UserInterface; +use Fisharebest\Webtrees\DB; +use Fisharebest\Webtrees\Module\ModuleCustomInterface; +use Fisharebest\Webtrees\Module\ModuleInterface; +use Fisharebest\Webtrees\Site; +use Fisharebest\Webtrees\Webtrees; +use Illuminate\Support\Collection; +use PDO; + +use function ini_get; +use function php_uname; + +/** + * Assemble telemetry data payload for submission. + */ +class TelemetryDataService +{ + public function __construct( + private readonly ModuleService $module_service, + private readonly TreeService $tree_service, + private readonly UserService $user_service, + ) { + } + + /** + * Assemble the complete telemetry payload as an associative array. + * + * @return array + */ + public function assemblePayload(): array + { + return [ + 'p_site_uuid' => $this->siteUuid(), + 'p_metrics' => $this->metrics(), + ]; + } + + private function siteUuid(): string + { + return Site::getUuid(); + } + + /** + * @return array + */ + private function metrics(): array + { + return [ + 'php_version' => PHP_VERSION, + 'php_memory_limit' => ini_get('memory_limit') ?: 'unknown', + 'db_type' => DB::driverName(), + 'db_version' => $this->databaseVersion(), + 'os_type' => php_uname('s'), + 'webtrees_version' => Webtrees::VERSION, + 'default_language' => Site::getPreference('LANGUAGE'), + 'changes_count' => $this->changesCount(), + 'trees' => $this->treesData(), + 'default_theme' => Site::getPreference('THEME_DIR'), + 'users_count' => $this->user_service->all()->count(), + 'user_settings' => $this->userSettings(), + 'enabled_standard_modules' => $this->enabledStandardModules(), + 'custom_modules' => $this->customModules(), + ]; + } + + private function databaseVersion(): string + { + return DB::connection()->getPdo()->getAttribute(PDO::ATTR_SERVER_VERSION); + } + + private function changesCount(): int + { + return (int) DB::table('change') + ->count(); + } + + /** + * @return array> + */ + private function treesData(): array + { + $trees = $this->tree_service->all(); + $data = []; + + foreach ($trees as $tree) { + $treeId = $tree->id(); + + $roleCounts = DB::table('user_gedcom_setting') + ->where('gedcom_id', '=', $treeId) + ->where('setting_name', '=', UserInterface::PREF_TREE_ROLE) + ->selectRaw('setting_value, COUNT(*) as total') + ->groupBy('setting_value') + ->pluck('total', 'setting_value') + ->map(static fn (string $count): int => (int) $count) + ->all(); + + $data[] = [ + 'individuals_count' => (int) DB::table('individuals')->where('i_file', '=', $treeId)->count(), + 'families_count' => (int) DB::table('families')->where('f_file', '=', $treeId)->count(), + 'sources_count' => (int) DB::table('sources')->where('s_file', '=', $treeId)->count(), + 'repositories_count' => (int) DB::table('other')->where('o_file', '=', $treeId)->where('o_type', '=', 'REPO')->count(), + 'media_count' => (int) DB::table('media')->where('m_file', '=', $treeId)->count(), + 'notes_count' => (int) DB::table('other')->where('o_file', '=', $treeId)->where('o_type', '=', 'NOTE')->count(), + 'places_count' => (int) DB::table('places')->where('p_file', '=', $treeId)->count(), + 'user_permissions_count' => $roleCounts, + ]; + } + + return $data; + } + + /** + * @return array> + */ + private function userSettings(): array + { + $users = $this->user_service->all(); + $now = time(); + $settings = []; + + foreach ($users as $user) { + $registeredTimestamp = (int) $user->getPreference(UserInterface::PREF_TIMESTAMP_REGISTERED); + $lastLoginTimestamp = (int) $user->getPreference(UserInterface::PREF_TIMESTAMP_ACTIVE); + + $settings[] = [ + 'language' => $user->getPreference(UserInterface::PREF_LANGUAGE), + 'last_login_age_days' => $lastLoginTimestamp > 0 ? (int) (($now - $lastLoginTimestamp) / 86400) : -1, + 'account_age_days' => $registeredTimestamp > 0 ? (int) (($now - $registeredTimestamp) / 86400) : -1, + ]; + } + + return $settings; + } + + /** + * @return array + */ + private function enabledStandardModules(): array + { + return $this->module_service->all() + ->filter(static fn (ModuleInterface $module): bool => !$module instanceof ModuleCustomInterface) + ->map(static fn (ModuleInterface $module): string => $module->name()) + ->values() + ->all(); + } + + /** + * @return array + */ + private function customModules(): array + { + return $this->module_service->findByInterface(ModuleCustomInterface::class) + ->map(static fn (ModuleCustomInterface $module): string => $module::class) + ->values() + ->all(); + } +} diff --git a/app/Services/UpgradeService.php b/app/Services/UpgradeService.php index d834e3667a..3b1d95eb65 100644 --- a/app/Services/UpgradeService.php +++ b/app/Services/UpgradeService.php @@ -344,17 +344,10 @@ private function fetchLatestVersion(bool $force): string */ private function serverParameters(): array { - $site_uuid = Site::getPreference('SITE_UUID'); - - if ($site_uuid === '') { - $site_uuid = Registry::idFactory()->uuid(); - Site::setPreference('SITE_UUID', $site_uuid); - } - return [ 'w' => Webtrees::VERSION, 'p' => PHP_VERSION, - 's' => $site_uuid, + 's' => Site::getUuid(), 'd' => DB::driverName(), ]; } diff --git a/app/Site.php b/app/Site.php index b8ba9d0ccc..ddb23abf98 100644 --- a/app/Site.php +++ b/app/Site.php @@ -135,8 +135,23 @@ public static function setPreference(string $setting_name, string $setting_value } } - /** - * Get the site’s configuration settings + /** * Get or generate the unique site identifier. + * + * @return string + */ + public static function getUuid(): string + { + $site_uuid = self::getPreference('SITE_UUID'); + + if ($site_uuid === '') { + $site_uuid = Registry::idFactory()->uuid(); + self::setPreference('SITE_UUID', $site_uuid); + } + + return $site_uuid; + } + + /** * Get the site’s configuration settings * * @param string $setting_name * diff --git a/resources/views/admin/control-panel.phtml b/resources/views/admin/control-panel.phtml index 29278f9006..fba1e10bf3 100644 --- a/resources/views/admin/control-panel.phtml +++ b/resources/views/admin/control-panel.phtml @@ -39,6 +39,8 @@ use Fisharebest\Webtrees\Http\RequestHandlers\SiteLogsPage; use Fisharebest\Webtrees\Http\RequestHandlers\SitePreferencesPage; use Fisharebest\Webtrees\Http\RequestHandlers\SiteRegistrationPage; use Fisharebest\Webtrees\Http\RequestHandlers\SiteTagsPage; +use Fisharebest\Webtrees\Http\RequestHandlers\TelemetryPreviewPage; +use Fisharebest\Webtrees\Http\RequestHandlers\TelemetrySettingsPage; use Fisharebest\Webtrees\Http\RequestHandlers\TreePageDefaultEdit; use Fisharebest\Webtrees\Http\RequestHandlers\UpgradeWizardPage; use Fisharebest\Webtrees\Http\RequestHandlers\UploadMediaPage; @@ -218,6 +220,13 @@ use Illuminate\Support\Collection;

+

+ + + + +

+
@@ -284,6 +293,12 @@ use Illuminate\Support\Collection; +
  • + + + + +
  • diff --git a/resources/views/admin/telemetry-preview.phtml b/resources/views/admin/telemetry-preview.phtml new file mode 100644 index 0000000000..3daac03e67 --- /dev/null +++ b/resources/views/admin/telemetry-preview.phtml @@ -0,0 +1,47 @@ + + + [route(ControlPanel::class) => I18N::translate('Control panel'), $title]]) ?> + +

    + +

    + +

    + +
    + + +
    +
    + + + + + + +
    +
    +
    + +
    +
    +
    +
    +
    + diff --git a/resources/views/admin/telemetry-settings.phtml b/resources/views/admin/telemetry-settings.phtml new file mode 100644 index 0000000000..b7180aa24b --- /dev/null +++ b/resources/views/admin/telemetry-settings.phtml @@ -0,0 +1,61 @@ + + + [route(ControlPanel::class) => I18N::translate('Control panel'), $title]]) ?> + +

    + +
    +
    + +
    + +
    + +
    +
    +
    + +
    + +
    + +
    + +
    +
    +
    + +
    +
    + + + + + + +
    +
    + + +
    diff --git a/resources/views/icons/telemetry.phtml b/resources/views/icons/telemetry.phtml new file mode 100644 index 0000000000..b7dc338953 --- /dev/null +++ b/resources/views/icons/telemetry.phtml @@ -0,0 +1 @@ +