From 00644c2c60c35ad503ed9bd65c792ae6fae40326 Mon Sep 17 00:00:00 2001 From: Boy132 Date: Tue, 24 Dec 2024 19:09:16 +0100 Subject: [PATCH] Health page (#469) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * add spatie health * change slug for health page * add check for panel version * only check for debug mode if env isn't local * add check for node versions * improve short summary * fix outdated check * run pint * fix health checks during tests * add count to ok message * fix typo * temp fix for phpstan job * fix pint... * improve "outdated" count Co-authored-by: MartinOscar <40749467+RMartinOscar@users.noreply.github.com> * run pint * skip node versions check if no nodes are created * auto run health checks if they didn't run before * small refactor * update navigation Co-authored-by: Charles * fix errors if tests didn't run yet * fix disk usage check * remove plugin and use own page * use health status indicator from spatie * fix after merge * update icon * update color classes * fix after merge * add back imports oops... * wrong import oops²... * update spatie/laravel-health to latest * move Health page to correct namespace * update NodeVersionsCheck * use style instead of tailwind classes workaround until we have vite * cleanup custom checks --------- Co-authored-by: MartinOscar <40749467+RMartinOscar@users.noreply.github.com> Co-authored-by: Charles --- app/Checks/NodeVersionsCheck.php | 43 ++++ app/Checks/PanelVersionCheck.php | 31 +++ app/Checks/UsedDiskSpaceCheck.php | 16 ++ app/Console/Kernel.php | 5 + app/Filament/Admin/Pages/Health.php | 120 +++++++++ app/Providers/AppServiceProvider.php | 23 ++ composer.json | 3 +- composer.lock | 237 +++++++++++++++++- config/health.php | 123 +++++++++ .../views/filament/pages/health.blade.php | 71 ++++++ 10 files changed, 669 insertions(+), 3 deletions(-) create mode 100644 app/Checks/NodeVersionsCheck.php create mode 100644 app/Checks/PanelVersionCheck.php create mode 100644 app/Checks/UsedDiskSpaceCheck.php create mode 100644 app/Filament/Admin/Pages/Health.php create mode 100644 config/health.php create mode 100644 resources/views/filament/pages/health.blade.php diff --git a/app/Checks/NodeVersionsCheck.php b/app/Checks/NodeVersionsCheck.php new file mode 100644 index 0000000000..d3eeb63708 --- /dev/null +++ b/app/Checks/NodeVersionsCheck.php @@ -0,0 +1,43 @@ +count(); + + if ($all === 0) { + $result = Result::make()->notificationMessage('No Nodes created')->shortSummary('No Nodes'); + $result->status = Status::skipped(); + + return $result; + } + + $latestVersion = $this->versionService->latestWingsVersion(); + + $outdated = Node::query()->get() + ->filter(fn (Node $node) => !isset($node->systemInformation()['exception']) && $node->systemInformation()['version'] !== $latestVersion) + ->count(); + + $result = Result::make() + ->meta([ + 'all' => $all, + 'outdated' => $outdated, + ]) + ->shortSummary($outdated === 0 ? 'All up-to-date' : "{$outdated}/{$all} outdated"); + + return $outdated === 0 + ? $result->ok('All Nodes are up-to-date.') + : $result->failed(':outdated/:all Nodes are outdated.'); + } +} diff --git a/app/Checks/PanelVersionCheck.php b/app/Checks/PanelVersionCheck.php new file mode 100644 index 0000000000..433bd7790a --- /dev/null +++ b/app/Checks/PanelVersionCheck.php @@ -0,0 +1,31 @@ +versionService->isLatestPanel(); + $currentVersion = $this->versionService->currentPanelVersion(); + $latestVersion = $this->versionService->latestPanelVersion(); + + $result = Result::make() + ->meta([ + 'isLatest' => $isLatest, + 'currentVersion' => $currentVersion, + 'latestVersion' => $latestVersion, + ]) + ->shortSummary($isLatest ? 'up-to-date' : 'outdated'); + + return $isLatest + ? $result->ok('Panel is up-to-date.') + : $result->failed('Installed version is `:currentVersion` but latest is `:latestVersion`.'); + } +} diff --git a/app/Checks/UsedDiskSpaceCheck.php b/app/Checks/UsedDiskSpaceCheck.php new file mode 100644 index 0000000000..26879d11de --- /dev/null +++ b/app/Checks/UsedDiskSpaceCheck.php @@ -0,0 +1,16 @@ +filesystemName ?? '/'); + $totalSpace = disk_total_space($this->filesystemName ?? '/'); + + return 100 - ($freeSpace * 100 / $totalSpace); + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 8b49a43d81..7545f177e3 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -13,6 +13,8 @@ use Illuminate\Console\Scheduling\Schedule; use Illuminate\Database\Console\PruneCommand; use Illuminate\Foundation\Console\Kernel as ConsoleKernel; +use Spatie\Health\Commands\RunHealthChecksCommand; +use Spatie\Health\Commands\ScheduleCheckHeartbeatCommand; class Kernel extends ConsoleKernel { @@ -53,5 +55,8 @@ protected function schedule(Schedule $schedule): void if (config('panel.webhook.prune_days')) { $schedule->command(PruneCommand::class, ['--model' => [Webhook::class]])->daily(); } + + $schedule->command(ScheduleCheckHeartbeatCommand::class)->everyMinute(); + $schedule->command(RunHealthChecksCommand::class)->everyFiveMinutes(); } } diff --git a/app/Filament/Admin/Pages/Health.php b/app/Filament/Admin/Pages/Health.php new file mode 100644 index 0000000000..b58c5211ba --- /dev/null +++ b/app/Filament/Admin/Pages/Health.php @@ -0,0 +1,120 @@ + '$refresh', + ]; + + protected function getActions(): array + { + return [ + Action::make('refresh') + ->button() + ->action('refresh'), + ]; + } + + protected function getViewData(): array + { + // @phpstan-ignore-next-line + $checkResults = app(ResultStore::class)->latestResults(); + + if ($checkResults === null) { + Artisan::call(RunHealthChecksCommand::class); + + $this->dispatch('refresh-component'); + } + + return [ + 'lastRanAt' => new Carbon($checkResults?->finishedAt), + 'checkResults' => $checkResults, + ]; + } + + public function refresh(): void + { + Artisan::call(RunHealthChecksCommand::class); + + $this->dispatch('refresh-component'); + + Notification::make() + ->title('Health check results refreshed') + ->success() + ->send(); + } + + public static function getNavigationBadge(): ?string + { + // @phpstan-ignore-next-line + $results = app(ResultStore::class)->latestResults(); + + if ($results === null) { + return null; + } + + $results = json_decode($results->toJson(), true); + + $failed = array_reduce($results['checkResults'], function ($numFailed, $result) { + return $numFailed + ($result['status'] === 'failed' ? 1 : 0); + }, 0); + + return $failed === 0 ? null : (string) $failed; + } + + public static function getNavigationBadgeColor(): string + { + return self::getNavigationBadge() > null ? 'danger' : ''; + } + + public static function getNavigationBadgeTooltip(): ?string + { + // @phpstan-ignore-next-line + $results = app(ResultStore::class)->latestResults(); + + if ($results === null) { + return null; + } + + $results = json_decode($results->toJson(), true); + + $failedNames = array_reduce($results['checkResults'], function ($carry, $result) { + if ($result['status'] === 'failed') { + $carry[] = $result['name']; + } + + return $carry; + }, []); + + return 'Failed: ' . implode(', ', $failedNames); + } + + public static function getNavigationIcon(): string + { + // @phpstan-ignore-next-line + $results = app(ResultStore::class)->latestResults(); + + if ($results === null) { + return 'tabler-heart-question'; + } + + return $results->containsFailingCheck() ? 'tabler-heart-exclamation' : 'tabler-heart-check'; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 6fdd39a082..2e8b80a80b 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,6 +2,9 @@ namespace App\Providers; +use App\Checks\NodeVersionsCheck; +use App\Checks\PanelVersionCheck; +use App\Checks\UsedDiskSpaceCheck; use App\Filament\Server\Pages\Console; use App\Models; use App\Models\ApiKey; @@ -26,6 +29,12 @@ use Illuminate\Support\Str; use Laravel\Sanctum\Sanctum; use SocialiteProviders\Manager\SocialiteWasCalled; +use Spatie\Health\Checks\Checks\CacheCheck; +use Spatie\Health\Checks\Checks\DatabaseCheck; +use Spatie\Health\Checks\Checks\DebugModeCheck; +use Spatie\Health\Checks\Checks\EnvironmentCheck; +use Spatie\Health\Checks\Checks\ScheduleCheck; +use Spatie\Health\Facades\Health; class AppServiceProvider extends ServiceProvider { @@ -103,6 +112,20 @@ public function boot(Application $app, SoftwareVersionService $versionService): scopes: Console::class, ); + // Don't run any health checks during tests + if (!$app->runningUnitTests()) { + Health::checks([ + DebugModeCheck::new()->if($app->isProduction()), + EnvironmentCheck::new(), + CacheCheck::new(), + DatabaseCheck::new(), + ScheduleCheck::new(), + UsedDiskSpaceCheck::new(), + PanelVersionCheck::new(), + NodeVersionsCheck::new(), + ]); + } + Gate::before(function (User $user, $ability) { return $user->isRootAdmin() ? true : null; }); diff --git a/composer.json b/composer.json index c2a0d571c3..231904fdce 100644 --- a/composer.json +++ b/composer.json @@ -35,6 +35,7 @@ "socialiteproviders/discord": "^4.2", "socialiteproviders/steam": "^4.2", "spatie/laravel-fractal": "^6.2", + "spatie/laravel-health": "^1.30", "spatie/laravel-permission": "^6.9", "spatie/laravel-query-builder": "^5.8.1", "spatie/temporary-directory": "^2.2", @@ -92,4 +93,4 @@ }, "minimum-stability": "stable", "prefer-stable": true -} +} \ No newline at end of file diff --git a/composer.lock b/composer.lock index e6c025fed8..8bbfc82f52 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "d3b70b68927bf912d19adea9705d70ff", + "content-hash": "2fddfe7f5db269cdf9e906b83c815756", "packages": [ { "name": "abdelhamiderrahmouni/filament-monaco-editor", @@ -7162,6 +7162,82 @@ ], "time": "2024-09-20T14:00:15+00:00" }, + { + "name": "spatie/enum", + "version": "3.13.0", + "source": { + "type": "git", + "url": "https://github.com/spatie/enum.git", + "reference": "f1a0f464ba909491a53e60a955ce84ad7cd93a2c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/enum/zipball/f1a0f464ba909491a53e60a955ce84ad7cd93a2c", + "reference": "f1a0f464ba909491a53e60a955ce84ad7cd93a2c", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": "^8.0" + }, + "require-dev": { + "fakerphp/faker": "^1.9.1", + "larapack/dd": "^1.1", + "phpunit/phpunit": "^9.0", + "vimeo/psalm": "^4.3" + }, + "suggest": { + "fakerphp/faker": "To use the enum faker provider", + "phpunit/phpunit": "To use the enum assertions" + }, + "type": "library", + "autoload": { + "psr-4": { + "Spatie\\Enum\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Brent Roose", + "email": "brent@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + }, + { + "name": "Tom Witkowski", + "email": "dev@gummibeer.de", + "homepage": "https://gummibeer.de", + "role": "Developer" + } + ], + "description": "PHP Enums", + "homepage": "https://github.com/spatie/enum", + "keywords": [ + "enum", + "enumerable", + "spatie" + ], + "support": { + "docs": "https://docs.spatie.be/enum", + "issues": "https://github.com/spatie/enum/issues", + "source": "https://github.com/spatie/enum" + }, + "funding": [ + { + "url": "https://spatie.be/open-source/support-us", + "type": "custom" + }, + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2022-04-22T08:51:55+00:00" + }, { "name": "spatie/fractalistic", "version": "2.9.5", @@ -7363,6 +7439,100 @@ ], "time": "2024-06-04T09:33:08+00:00" }, + { + "name": "spatie/laravel-health", + "version": "1.30.1", + "source": { + "type": "git", + "url": "https://github.com/spatie/laravel-health.git", + "reference": "98e91b8a4b5ffc9086cf5d9cd2e7fa9cf1be0661" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/laravel-health/zipball/98e91b8a4b5ffc9086cf5d9cd2e7fa9cf1be0661", + "reference": "98e91b8a4b5ffc9086cf5d9cd2e7fa9cf1be0661", + "shasum": "" + }, + "require": { + "dragonmantank/cron-expression": "^3.3.1", + "guzzlehttp/guzzle": "^6.5|^7.4.5|^7.2", + "illuminate/console": "^8.75|^9.0|^10.0|^11.0", + "illuminate/contracts": "^8.75|^9.0|^10.0|^11.0", + "illuminate/database": "^8.75|^9.0|^10.0|^11.0", + "illuminate/notifications": "^8.75|^9.0|^10.0|^11.0", + "illuminate/support": "^8.75|^9.0|^10.0|^11.0", + "laravel/serializable-closure": "^1.3", + "nunomaduro/termwind": "^1.0|^2.0", + "php": "^8.0", + "spatie/enum": "^3.13", + "spatie/laravel-package-tools": "^1.12.1", + "spatie/regex": "^3.1.1|^3.1", + "spatie/temporary-directory": "^2.2", + "symfony/process": "^5.4|^6.0|^7.0" + }, + "require-dev": { + "larastan/larastan": "^1.0.3|^2.4", + "laravel/horizon": "^5.9.10", + "laravel/slack-notification-channel": "^2.4|^3.2", + "nunomaduro/collision": "^5.10|^6.2.1|^6.1|^8.0", + "orchestra/testbench": "^6.23|^7.6|^8.0|^9.0", + "pestphp/pest": "^1.21.3|^2.34", + "pestphp/pest-plugin-laravel": "^1.2|^2.3", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan-deprecation-rules": "^1.0", + "phpstan/phpstan-phpunit": "^1.1.1", + "phpunit/phpunit": "^9.5.21|^9.5.10|^10.5", + "spatie/laravel-ray": "^1.30", + "spatie/pest-plugin-snapshots": "^1.1|^2.1", + "spatie/pest-plugin-test-time": "^1.1.1|^1.1|^2.0", + "spatie/test-time": "^1.3" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Spatie\\Health\\HealthServiceProvider" + ], + "aliases": { + "Health": "Spatie\\Health\\Facades\\Health" + } + } + }, + "autoload": { + "psr-4": { + "Spatie\\Health\\": "src", + "Spatie\\Health\\Database\\Factories\\": "database/factories" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "role": "Developer" + } + ], + "description": "Monitor the health of a Laravel application", + "homepage": "https://github.com/spatie/laravel-health", + "keywords": [ + "laravel", + "laravel-health", + "spatie" + ], + "support": { + "source": "https://github.com/spatie/laravel-health/tree/1.30.1" + }, + "funding": [ + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2024-08-02T14:01:48+00:00" + }, { "name": "spatie/laravel-package-tools", "version": "1.16.5", @@ -7579,6 +7749,69 @@ ], "time": "2024-05-10T08:19:35+00:00" }, + { + "name": "spatie/regex", + "version": "3.1.1", + "source": { + "type": "git", + "url": "https://github.com/spatie/regex.git", + "reference": "d543de2019a0068e7b80da0ba24f1c51c7469303" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/regex/zipball/d543de2019a0068e7b80da0ba24f1c51c7469303", + "reference": "d543de2019a0068e7b80da0ba24f1c51c7469303", + "shasum": "" + }, + "require": { + "php": "^8.0|^8.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Spatie\\Regex\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Sebastian De Deyne", + "email": "sebastian@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + } + ], + "description": "A sane interface for php's built in preg_* functions", + "homepage": "https://github.com/spatie/regex", + "keywords": [ + "expression", + "expressions", + "regex", + "regular", + "spatie" + ], + "support": { + "issues": "https://github.com/spatie/regex/issues", + "source": "https://github.com/spatie/regex/tree/3.1.1" + }, + "funding": [ + { + "url": "https://spatie.be/open-source/support-us", + "type": "custom" + }, + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2021-11-30T21:13:59+00:00" + }, { "name": "spatie/temporary-directory", "version": "2.2.1", @@ -13756,5 +13989,5 @@ "ext-zip": "*" }, "platform-dev": [], - "plugin-api-version": "2.3.0" + "plugin-api-version": "2.6.0" } diff --git a/config/health.php b/config/health.php new file mode 100644 index 0000000000..85cefc501d --- /dev/null +++ b/config/health.php @@ -0,0 +1,123 @@ + [ + /* + Spatie\Health\ResultStores\EloquentHealthResultStore::class => [ + 'connection' => env('HEALTH_DB_CONNECTION', env('DB_CONNECTION')), + 'model' => Spatie\Health\Models\HealthCheckResultHistoryItem::class, + 'keep_history_for_days' => 5, + ], + */ + + Spatie\Health\ResultStores\CacheHealthResultStore::class => [ + 'store' => 'file', + ], + + /* + Spatie\Health\ResultStores\JsonFileHealthResultStore::class => [ + 'disk' => 's3', + 'path' => 'health.json', + ], + + Spatie\Health\ResultStores\InMemoryHealthResultStore::class, + */ + ], + + /* + * You can get notified when specific events occur. Out of the box you can use 'mail' and 'slack'. + * For Slack you need to install laravel/slack-notification-channel. + */ + 'notifications' => [ + /* + * Notifications will only get sent if this option is set to `true`. + */ + 'enabled' => false, + + 'notifications' => [ + Spatie\Health\Notifications\CheckFailedNotification::class => ['mail'], + ], + + /* + * Here you can specify the notifiable to which the notifications should be sent. The default + * notifiable will use the variables specified in this config file. + */ + 'notifiable' => Spatie\Health\Notifications\Notifiable::class, + + /* + * When checks start failing, you could potentially end up getting + * a notification every minute. + * + * With this setting, notifications are throttled. By default, you'll + * only get one notification per hour. + */ + 'throttle_notifications_for_minutes' => 60, + 'throttle_notifications_key' => 'health:latestNotificationSentAt:', + + 'mail' => [ + 'to' => 'your@example.com', + + 'from' => [ + 'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'), + 'name' => env('MAIL_FROM_NAME', 'Example'), + ], + ], + + 'slack' => [ + 'webhook_url' => env('HEALTH_SLACK_WEBHOOK_URL', ''), + + /* + * If this is set to null the default channel of the webhook will be used. + */ + 'channel' => null, + + 'username' => null, + + 'icon' => null, + ], + ], + + /* + * You can let Oh Dear monitor the results of all health checks. This way, you'll + * get notified of any problems even if your application goes totally down. Via + * Oh Dear, you can also have access to more advanced notification options. + */ + 'oh_dear_endpoint' => [ + 'enabled' => false, + + /* + * When this option is enabled, the checks will run before sending a response. + * Otherwise, we'll send the results from the last time the checks have run. + */ + 'always_send_fresh_results' => true, + + /* + * The secret that is displayed at the Application Health settings at Oh Dear. + */ + 'secret' => env('OH_DEAR_HEALTH_CHECK_SECRET'), + + /* + * The URL that should be configured in the Application health settings at Oh Dear. + */ + 'url' => '/oh-dear-health-check-results', + ], + + /* + * You can set a theme for the local results page + * + * - light: light mode + * - dark: dark mode + */ + 'theme' => 'light', + + /* + * When enabled, completed `HealthQueueJob`s will be displayed + * in Horizon's silenced jobs screen. + */ + 'silence_health_queue_job' => true, +]; diff --git a/resources/views/filament/pages/health.blade.php b/resources/views/filament/pages/health.blade.php new file mode 100644 index 0000000000..13cf00aec7 --- /dev/null +++ b/resources/views/filament/pages/health.blade.php @@ -0,0 +1,71 @@ +@php + if(! function_exists('backgroundColor')) { + function backgroundColor($status) { + return match ($status) { + Spatie\Health\Enums\Status::ok()->value => 'background-color: rgb(209 250 229);', // bg-emerald-100 + Spatie\Health\Enums\Status::warning()->value => 'background-color: rgb(254 249 195);', // bg-yellow-100 + Spatie\Health\Enums\Status::skipped()->value => 'background-color: rgb(219 234 254);', // bg-blue-100 + Spatie\Health\Enums\Status::failed()->value, Spatie\Health\Enums\Status::crashed()->value => 'background-color: rgb(254 226 226);', // bg-red-100 + default => 'background-color: rgb(243 244 246);' // bg-gray-100 + }; + } + } + + if(! function_exists('iconColor')) { + function iconColor($status) + { + return match ($status) { + Spatie\Health\Enums\Status::ok()->value => 'color: rgb(16 185 129);', // text-emerald-500 + Spatie\Health\Enums\Status::warning()->value => 'color: rgb(234 179 8);', // text-yellow-500 + Spatie\Health\Enums\Status::skipped()->value => 'color: rgb(59 130 246);', // text-blue-500 + Spatie\Health\Enums\Status::failed()->value, Spatie\Health\Enums\Status::crashed()->value => 'color: rgb(239 68 68);', // text-red-500 + default => 'color: rgb(107 114 128);' // text-gray-500 + }; + } + } + + if(! function_exists('icon')) { + function icon($status) + { + return match ($status) { + Spatie\Health\Enums\Status::ok()->value => 'tabler-circle-check', + Spatie\Health\Enums\Status::warning()->value => 'tabler-exclamation-circle', + Spatie\Health\Enums\Status::skipped()->value => 'tabler-circle-chevron-right', + Spatie\Health\Enums\Status::failed()->value, Spatie\Health\Enums\Status::crashed()->value => 'tabler-circle-x', + default => 'tabler-help-circle' + }; + } + } +@endphp + + + @if (count($checkResults?->storedCheckResults ?? [])) + + @foreach ($checkResults->storedCheckResults as $result) +
+
+ +
+
+
+ {{ $result->label }} +
+
+ @if (!empty($result->notificationMessage)) + {{ $result->notificationMessage }} + @else + {{ $result->shortSummary }} + @endif +
+
+
+ @endforeach +
+ @endif + + @if ($lastRanAt) +
+ Check results from {{ $lastRanAt->diffForHumans() }} +
+ @endif +