├── src ├── Support │ ├── Run.php │ ├── Checks.php │ ├── BackupFile.php │ └── DbConnectionInfo.php ├── Events │ ├── CheckStartingEvent.php │ └── CheckEndedEvent.php ├── Components │ ├── Logo.php │ └── StatusIndicator.php ├── Exceptions │ ├── DatabaseNotSupported.php │ ├── CheckDidNotComplete.php │ ├── DuplicateCheckNamesFound.php │ ├── InvalidCheck.php │ └── CouldNotSaveResultsInStore.php ├── ResultStores │ ├── ResultStore.php │ ├── ResultStores.php │ ├── InMemoryHealthResultStore.php │ ├── CacheHealthResultStore.php │ ├── StoredCheckResults │ │ ├── StoredCheckResult.php │ │ └── StoredCheckResults.php │ ├── JsonFileHealthResultStore.php │ └── EloquentHealthResultStore.php ├── Traits │ ├── HasDatabaseConnection.php │ └── Pingable.php ├── Commands │ ├── ResumeHealthChecksCommand.php │ ├── PauseHealthChecksCommand.php │ ├── DispatchQueueCheckJobsCommand.php │ ├── ScheduleCheckHeartbeatCommand.php │ ├── ListHealthChecksCommand.php │ └── RunHealthChecksCommand.php ├── Enums │ └── Status.php ├── Http │ ├── Middleware │ │ ├── RequiresSecretToken.php │ │ ├── RequiresSecret.php │ │ └── HealthMiddleware.php │ └── Controllers │ │ ├── HealthCheckResultsController.php │ │ ├── HealthCheckJsonResultsController.php │ │ └── SimpleHealthCheckController.php ├── Notifications │ ├── Notifiable.php │ └── CheckFailedNotification.php ├── Testing │ ├── FakeValues.php │ ├── FakeCheck.php │ └── FakeHealth.php ├── Jobs │ └── HealthQueueJob.php ├── Checks │ ├── Checks │ │ ├── DatabaseCheck.php │ │ ├── EnvironmentCheck.php │ │ ├── DebugModeCheck.php │ │ ├── RedisCheck.php │ │ ├── DatabaseSizeCheck.php │ │ ├── CacheCheck.php │ │ ├── HorizonCheck.php │ │ ├── OptimizedAppCheck.php │ │ ├── UsedDiskSpaceCheck.php │ │ ├── DatabaseConnectionCountCheck.php │ │ ├── DatabaseTableSizeCheck.php │ │ ├── ScheduleCheck.php │ │ ├── MeilisearchCheck.php │ │ ├── RedisMemoryUsageCheck.php │ │ ├── PingCheck.php │ │ ├── FlareErrorOccurrenceCountCheck.php │ │ ├── QueueCheck.php │ │ └── BackupsCheck.php │ ├── Result.php │ └── Check.php ├── Facades │ └── Health.php ├── Models │ └── HealthCheckResultHistoryItem.php ├── Health.php └── HealthServiceProvider.php ├── resources ├── css │ └── health.css ├── views │ ├── mail │ │ └── checkFailedNotification.blade.php │ ├── logo.blade.php │ ├── list-cli.blade.php │ ├── status-indicator.blade.php │ └── list.blade.php ├── lang │ ├── cz │ │ └── notifications.php │ ├── da │ │ └── notifications.php │ ├── sk │ │ └── notifications.php │ ├── ar │ │ └── notifications.php │ ├── fr │ │ └── notifications.php │ ├── ru │ │ └── notifications.php │ ├── en │ │ └── notifications.php │ ├── lv │ │ └── notifications.php │ ├── fa │ │ └── notifications.php │ ├── it │ │ └── notifications.php │ ├── nl │ │ └── notifications.php │ ├── pt │ │ └── notifications.php │ ├── es │ │ └── notifications.php │ ├── hi │ │ └── notifications.php │ ├── de │ │ └── notifications.php │ ├── bn │ │ └── notifications.php │ ├── pt_BR │ │ └── notifications.php │ ├── bg │ │ └── notifications.php │ └── tr │ │ └── notifications.php └── dist │ └── health.min.css ├── postcss.config.js ├── tailwind.config.js ├── phpstan.neon.dist ├── package.json ├── database ├── factories │ └── HealthCheckResultHistoryItemFactory.php └── migrations │ └── create_health_tables.php.stub ├── LICENSE.md ├── phpstan-baseline.neon ├── composer.json ├── README.md ├── config └── health.php └── yarn.lock /src/Support/Run.php: -------------------------------------------------------------------------------- 1 | check->getLabel() }}: {{ $result->getNotificationMessage() }} 8 | @endforeach 9 | 10 | @endcomponent 11 | -------------------------------------------------------------------------------- /phpstan.neon.dist: -------------------------------------------------------------------------------- 1 | includes: 2 | - phpstan-baseline.neon 3 | 4 | parameters: 5 | level: 8 6 | paths: 7 | - src 8 | - config 9 | - database 10 | tmpDir: build/phpstan 11 | checkOctaneCompatibility: true 12 | checkModelProperties: true 13 | checkMissingIterableValueType: true 14 | 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "watch": "npx tailwindcss -i ./resources/css/health.css -o ./resources/dist/health.min.css --watch", 5 | "build": "npx tailwindcss -i ./resources/css/health.css -o ./resources/dist/health.min.css --minify" 6 | }, 7 | "devDependencies": { 8 | "tailwindcss": "^3.0.1" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/Support/Checks.php: -------------------------------------------------------------------------------- 1 | $checks */ 10 | public function __construct(array $checks) 11 | { 12 | parent::__construct($checks); 13 | } 14 | 15 | public function run(): void {} 16 | } 17 | -------------------------------------------------------------------------------- /src/Exceptions/DatabaseNotSupported.php: -------------------------------------------------------------------------------- 1 | getDriverName()}` is not supported by this package."); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/ResultStores/ResultStore.php: -------------------------------------------------------------------------------- 1 | $checkResults */ 11 | public function save(Collection $checkResults): void; 12 | 13 | public function latestResults(): ?StoredCheckResults; 14 | } 15 | -------------------------------------------------------------------------------- /resources/lang/cz/notifications.php: -------------------------------------------------------------------------------- 1 | 'Laravel Health', 5 | 6 | 'check_failed_mail_subject' => 'Některé kontroly stavu :application_name selhaly', 7 | 8 | 'check_failed_mail_body' => 'Následující kontroly hlásí upozornění a chyby:', 9 | 10 | 'check_failed_slack_message' => 'Některé kontroly stavu :application_name selhaly.', 11 | 12 | 'health_results' => 'Výsledky', 13 | 14 | 'check_results_from' => 'Poslední kontrola', 15 | ]; 16 | -------------------------------------------------------------------------------- /resources/lang/da/notifications.php: -------------------------------------------------------------------------------- 1 | 'Laravel Health', 5 | 6 | 'check_failed_mail_subject' => 'Nogle tjeks på :application_name mislykkedes', 7 | 8 | 'check_failed_mail_body' => 'Følgende tjeks rapporterede advarsler og fejl:', 9 | 10 | 'check_failed_slack_message' => 'Nogle tjeks på :application_name mislykkedes.', 11 | 12 | 'health_results' => 'Health resultater', 13 | 14 | 'check_results_from' => 'Tjek resultater fra', 15 | ]; 16 | -------------------------------------------------------------------------------- /src/Traits/HasDatabaseConnection.php: -------------------------------------------------------------------------------- 1 | connectionName = $connectionName; 12 | 13 | return $this; 14 | } 15 | 16 | protected function getDefaultConnectionName(): string 17 | { 18 | return config('database.default'); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /resources/lang/sk/notifications.php: -------------------------------------------------------------------------------- 1 | 'Laravel Health', 5 | 6 | 'check_failed_mail_subject' => 'Niektoré kontroly stavu :application_name zlyhali', 7 | 8 | 'check_failed_mail_body' => 'Nasledovné kontroly hlásených upozornení a chýb:', 9 | 10 | 'check_failed_slack_message' => 'Niektoré kontroly stavu :application_name zlyhali.', 11 | 12 | 'health_results' => 'Výsledky', 13 | 14 | 'check_results_from' => 'Posledná kontrola', 15 | ]; 16 | -------------------------------------------------------------------------------- /resources/lang/ar/notifications.php: -------------------------------------------------------------------------------- 1 | 'Laravel Health', 5 | 6 | 'check_failed_mail_subject' => 'لقد فشلت بعض عمليات التحقق من السلامة على :application_name', 7 | 8 | 'check_failed_mail_body' => 'أبلغت عمليات التحقق التالية عن تحذيرات وأخطاء:', 9 | 10 | 'check_failed_slack_message' => 'لقد فشلت بعض عمليات التحقق من السلامة على :application_name.', 11 | 12 | 'health_results' => 'نتائج الصحة', 13 | 14 | 'check_results_from' => 'التحقق من النتائج من', 15 | ]; 16 | -------------------------------------------------------------------------------- /resources/lang/fr/notifications.php: -------------------------------------------------------------------------------- 1 | 'Laravel Health', 5 | 6 | 'check_failed_mail_subject' => 'Certains tests de :application_name ont échoué', 7 | 8 | 'check_failed_mail_body' => 'Les tests suivants comportent des avertissements et des erreurs:', 9 | 10 | 'check_failed_slack_message' => 'Certains tests de :application_name ont échoué.', 11 | 12 | 'health_results' => 'Bilan de santé', 13 | 14 | 'check_results_from' => 'Résultats des tests de', 15 | ]; 16 | -------------------------------------------------------------------------------- /resources/lang/ru/notifications.php: -------------------------------------------------------------------------------- 1 | 'Laravel Health', 5 | 6 | 'check_failed_mail_subject' => 'Во время проверки :application_name обнаружены ошибки', 7 | 8 | 'check_failed_mail_body' => 'Обнаружены следующие ошибки и предупреждения:', 9 | 10 | 'check_failed_slack_message' => 'Во время проверки :application_name обнаружены ошибки.', 11 | 12 | 'health_results' => 'Результаты проверки', 13 | 14 | 'check_results_from' => 'Посмотреть результаты', 15 | ]; 16 | -------------------------------------------------------------------------------- /resources/lang/en/notifications.php: -------------------------------------------------------------------------------- 1 | 'Laravel Health', 5 | 6 | 'check_failed_mail_subject' => 'Some of the health checks on :application_name have failed', 7 | 8 | 'check_failed_mail_body' => 'The following checks reported warnings and errors:', 9 | 10 | 'check_failed_slack_message' => 'Some of the health checks on :application_name have failed.', 11 | 12 | 'health_results' => 'Health Results', 13 | 14 | 'check_results_from' => 'Check results from', 15 | ]; 16 | -------------------------------------------------------------------------------- /resources/lang/lv/notifications.php: -------------------------------------------------------------------------------- 1 | 'Laravel Health', 5 | 6 | 'check_failed_mail_subject' => ':application_name pārbaudes laikā tika konstatētas kļūdas', 7 | 8 | 'check_failed_mail_body' => 'Konstatētas sekojošas kļūdas un brīdinājumi:', 9 | 10 | 'check_failed_slack_message' => ':application_name pārbaudes laikā tika konstatētas kļūdas.', 11 | 12 | 'health_results' => 'Pārbaudes rezultāti', 13 | 14 | 'check_results_from' => 'Pārbaudes rezultāti', 15 | ]; 16 | -------------------------------------------------------------------------------- /resources/lang/fa/notifications.php: -------------------------------------------------------------------------------- 1 | 'سلامت لاراول', 5 | 6 | 'check_failed_mail_subject' => 'بررسی برخی موارد امنیتی در برنامه :application_name از نظر سلامت ناموفق بود', 7 | 8 | 'check_failed_mail_body' => 'موارد ذکر شده دارای خطا می باشد:', 9 | 10 | 'check_failed_slack_message' => 'بررسی برخی موارد امنیتی در برنامه :application_name از نظر سلامت ناموفق بود', 11 | 12 | 'health_results' => 'گزارش وضعیت سلامت', 13 | 14 | 'check_results_from' => 'نتیجه بررسی:', 15 | ]; 16 | -------------------------------------------------------------------------------- /resources/lang/it/notifications.php: -------------------------------------------------------------------------------- 1 | 'Laravel Health', 5 | 6 | 'check_failed_mail_subject' => 'Alcuni check sulla salute di :application_name sono falliti', 7 | 8 | 'check_failed_mail_body' => 'I seguenti check hanno riportato warning o errori:', 9 | 10 | 'check_failed_slack_message' => 'Alcuni check sulla salute di :application_name sono falliti.', 11 | 12 | 'health_results' => 'Risultati check sulla salute', 13 | 14 | 'check_results_from' => 'Risultati check di', 15 | ]; 16 | -------------------------------------------------------------------------------- /resources/lang/nl/notifications.php: -------------------------------------------------------------------------------- 1 | 'Laravel Health', 5 | 6 | 'check_failed_mail_subject' => 'Enkele van de health checks op :application_name faalden', 7 | 8 | 'check_failed_mail_body' => 'De volgende health checks resulteerden in een warning of error:', 9 | 10 | 'check_failed_slack_message' => 'Enkele van de health checks op :application_name faalden.', 11 | 12 | 'health_results' => 'Health Results', 13 | 14 | 'check_results_from' => 'Resultaten van check van ', 15 | ]; 16 | -------------------------------------------------------------------------------- /resources/lang/pt/notifications.php: -------------------------------------------------------------------------------- 1 | 'Laravel Health', 5 | 6 | 'check_failed_mail_subject' => 'Falharam algumas verificações de saúde em :application_name', 7 | 8 | 'check_failed_mail_body' => 'As seguintes verificações reportaram avisos e erros:', 9 | 10 | 'check_failed_slack_message' => 'Falharam algumas verificações de saúde em :application_name .', 11 | 12 | 'health_results' => 'Resultados da saúde', 13 | 14 | 'check_results_from' => 'Verificar resultados de', 15 | ]; 16 | -------------------------------------------------------------------------------- /resources/lang/es/notifications.php: -------------------------------------------------------------------------------- 1 | 'Laravel Health', 5 | 6 | 'check_failed_mail_subject' => 'Algunos de los chequeos en :application_name han fallado', 7 | 8 | 'check_failed_mail_body' => 'Las siguientes validaciones reportaron warnings y errors:', 9 | 10 | 'check_failed_slack_message' => 'Algunos de los chequeos en :application_name han fallado.', 11 | 12 | 'health_results' => 'Resultados del chequeo', 13 | 14 | 'check_results_from' => 'Resultados del chequeo de', 15 | ]; 16 | -------------------------------------------------------------------------------- /resources/lang/hi/notifications.php: -------------------------------------------------------------------------------- 1 | 'लारावेल स्वास्थ्य जाँच', 5 | 6 | 'check_failed_mail_subject' => ':application_name पर कुछ स्वास्थ्य जाँचें विफल हो गई हैं।', 7 | 8 | 'check_failed_mail_body' => 'निम्नलिखित जाँचों ने चेतावनी और त्रुटियों की सूचना दी:', 9 | 10 | 'check_failed_slack_message' => ':application_name पर कुछ स्वास्थ्य जाँच विफल हो गई हैं।', 11 | 12 | 'health_results' => 'स्वास्थ्य जाँच के परिणाम', 13 | 14 | 'check_results_from' => 'परिणाम जाँचा गया है', 15 | ]; 16 | -------------------------------------------------------------------------------- /resources/lang/de/notifications.php: -------------------------------------------------------------------------------- 1 | 'Laravel Gesundheit', 5 | 6 | 'check_failed_mail_subject' => 'Einige Kontrollen für :application_name sind fehlgeschlagen', 7 | 8 | 'check_failed_mail_body' => 'Folgende Kontrollen haben Warnungen und Fehler gemeldet:', 9 | 10 | 'check_failed_slack_message' => 'Einige Kontrollen für :application_name sind fehlgeschlagen.', 11 | 12 | 'health_results' => 'Gesundheitsergebnisse', 13 | 14 | 'check_results_from' => 'Gesundheitsergebnisse von', 15 | ]; 16 | -------------------------------------------------------------------------------- /resources/lang/bn/notifications.php: -------------------------------------------------------------------------------- 1 | 'লারাভেল স্বাস্থ্য পরীক্ষা', 5 | 6 | 'check_failed_mail_subject' => ':application_name এর কিছু স্বাস্থ্য পরীক্ষা ব্যর্থ হয়েছে', 7 | 8 | 'check_failed_mail_body' => 'নিম্নলিখিত পরীক্ষাগুলোর সতর্কতা এবং ত্রুটি রিপোর্ট করা হয়েছে:', 9 | 10 | 'check_failed_slack_message' => ':application_name এর কিছু স্বাস্থ্য পরীক্ষা ব্যর্থ হয়েছে।', 11 | 12 | 'health_results' => 'স্বাস্থ্য পরীক্ষার ফলাফল', 13 | 14 | 'check_results_from' => 'এখান থেকে ফলাফল যাচাই করুন', 15 | ]; 16 | -------------------------------------------------------------------------------- /resources/lang/pt_BR/notifications.php: -------------------------------------------------------------------------------- 1 | 'Laravel Health', 5 | 6 | 'check_failed_mail_subject' => 'Algumas das verificações de saúde em :application_name falharam', 7 | 8 | 'check_failed_mail_body' => 'As seguintes verificações relataram avisos e erros:', 9 | 10 | 'check_failed_slack_message' => 'Algumas das verificações de saúde em :application_name não foram bem-sucedidas.', 11 | 12 | 'health_results' => 'Resultados da saúde', 13 | 14 | 'check_results_from' => 'Confira os resultados de', 15 | ]; 16 | -------------------------------------------------------------------------------- /resources/lang/bg/notifications.php: -------------------------------------------------------------------------------- 1 | 'Laravel Health', 5 | 6 | 'check_failed_mail_subject' => 'Някои от проверките на здравето на :application_name са неуспешни', 7 | 8 | 'check_failed_mail_body' => 'Следните проверки съобщават за предупреждения и грешки:', 9 | 10 | 'check_failed_slack_message' => 'Някои от проверките на здравето на :application_name са неуспешни.', 11 | 12 | 'health_results' => 'Резултати за проверка на здравето', 13 | 14 | 'check_results_from' => 'Проверка на резултатите от', 15 | ]; 16 | -------------------------------------------------------------------------------- /resources/lang/tr/notifications.php: -------------------------------------------------------------------------------- 1 | 'Laravel Sağlık', 5 | 6 | 'check_failed_mail_subject' => ':application_name isimli Uygulamada bazı sağlık kontrolleri başarısız oldu', 7 | 8 | 'check_failed_mail_body' => 'Aşağıda belirtilen sağlık kontrolleri uyarı veya hata verdi:', 9 | 10 | 'check_failed_slack_message' => ':application_name isimli Uygulamada sağlık kontrolleri başarısız oldu.', 11 | 12 | 'health_results' => 'Sağlık Durumu Raporu', 13 | 14 | 'check_results_from' => 'Kontrol sonuçları:', 15 | ]; 16 | -------------------------------------------------------------------------------- /src/Exceptions/CheckDidNotComplete.php: -------------------------------------------------------------------------------- 1 | getName()}` did not complete. An exception was thrown with this message: `".get_class($exception).": {$exception->getMessage()}`", 14 | previous: $exception, 15 | ); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/Commands/ResumeHealthChecksCommand.php: -------------------------------------------------------------------------------- 1 | comment('All health check resumed'); 19 | 20 | return self::SUCCESS; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Enums/Status.php: -------------------------------------------------------------------------------- 1 | '#2EB67D', 20 | self::warning() => '#ECB22E', 21 | self::failed(), self::crashed() => '#E01E5A', 22 | default => '', 23 | }; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Http/Middleware/RequiresSecretToken.php: -------------------------------------------------------------------------------- 1 | headers->get('X-Secret-Token') !== config('health.secret_token')) 16 | ) { 17 | abort(403, 'Incorrect secret token'); 18 | } 19 | 20 | return $next($request); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Notifications/Notifiable.php: -------------------------------------------------------------------------------- 1 | */ 12 | public function routeNotificationForMail(): string|array 13 | { 14 | return config('health.notifications.mail.to'); 15 | } 16 | 17 | public function routeNotificationForSlack(): string 18 | { 19 | return config('health.notifications.slack.webhook_url'); 20 | } 21 | 22 | public function getKey(): int 23 | { 24 | return 1; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Http/Middleware/RequiresSecret.php: -------------------------------------------------------------------------------- 1 | headers->get('oh-dear-health-check-secret')) { 14 | abort(403, 'Secret header not set'); 15 | } 16 | 17 | if ($secret !== config('health.oh_dear_endpoint.secret')) { 18 | abort(403, 'Incorrect secret'); 19 | } 20 | 21 | return $next($request); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Http/Middleware/HealthMiddleware.php: -------------------------------------------------------------------------------- 1 | each( 21 | fn (Check $check) => $check->onTerminate($request, $response) 22 | ); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Testing/FakeValues.php: -------------------------------------------------------------------------------- 1 | result; 26 | } 27 | 28 | public function shouldRun(): ?bool 29 | { 30 | return $this->shouldRun; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/ResultStores/ResultStores.php: -------------------------------------------------------------------------------- 1 | */ 10 | public static function createFromConfig(): Collection 11 | { 12 | $configValues = config('health.result_stores'); 13 | 14 | return collect($configValues) 15 | ->keyBy(fn (mixed $value, mixed $key) => is_array($value) ? $key : $value) 16 | ->map(fn (mixed $value) => is_array($value) ? $value : []) 17 | ->map(function (array $parameters, string $className): ResultStore { 18 | return app($className, $parameters); 19 | }); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Exceptions/DuplicateCheckNamesFound.php: -------------------------------------------------------------------------------- 1 | map(fn (string $name) => "`{$name}`") 14 | ->join(', ', ' and '); 15 | 16 | return new self("You registered checks with a non-unique name: {$duplicateCheckNamesString}. Each check should be unique. If you really want to use the same check class twice, make sure to call `name()` on them to ensure that they all have unique names."); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Commands/PauseHealthChecksCommand.php: -------------------------------------------------------------------------------- 1 | argument('seconds'); 21 | 22 | Cache::put(self::CACHE_KEY, true, $seconds); 23 | 24 | $this->comment('All health check paused until '.now()->addSeconds($seconds)->toDateTimeString()); 25 | 26 | return self::SUCCESS; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Jobs/HealthQueueJob.php: -------------------------------------------------------------------------------- 1 | queueCheck = $queueCheck; 19 | } 20 | 21 | public function handle(): void 22 | { 23 | $cacheStore = $this->queueCheck->getCacheStoreName(); 24 | 25 | cache() 26 | ->store($cacheStore) 27 | ->set( 28 | $this->queueCheck->getCacheKey($this->queue), 29 | now()->timestamp, 30 | ); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Checks/Checks/DatabaseCheck.php: -------------------------------------------------------------------------------- 1 | connectionName ?? $this->getDefaultConnectionName(); 18 | 19 | $result = Result::make()->meta([ 20 | 'connection_name' => $connectionName, 21 | ]); 22 | 23 | try { 24 | DB::connection($connectionName)->getPdo(); 25 | 26 | return $result->ok(); 27 | } catch (Exception $exception) { 28 | return $result->failed("Could not connect to the database: `{$exception->getMessage()}`"); 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /database/factories/HealthCheckResultHistoryItemFactory.php: -------------------------------------------------------------------------------- 1 | $this->faker->word(), 18 | 'check_label' => $this->faker->word(), 19 | 'status' => $this->faker->randomElement(Status::toArray()), 20 | 'notification_message' => $this->faker->text(), 21 | 'short_summary' => $this->faker->sentences(asText: true), 22 | 'meta' => [], 23 | 'batch' => (string) Str::uuid(), 24 | 'ended_at' => now(), 25 | ]; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Exceptions/InvalidCheck.php: -------------------------------------------------------------------------------- 1 | filter( 21 | fn (Check $check) => $check instanceof QueueCheck 22 | ); 23 | 24 | foreach ($queueChecks as $queueCheck) { 25 | foreach ($queueCheck->getQueues() as $queue) { 26 | HealthQueueJob::dispatch($queueCheck)->onQueue($queue); 27 | } 28 | } 29 | 30 | return static::SUCCESS; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Http/Controllers/HealthCheckResultsController.php: -------------------------------------------------------------------------------- 1 | has('fresh')) { 19 | Artisan::call(RunHealthChecksCommand::class); 20 | } 21 | 22 | $checkResults = $resultStore->latestResults(); 23 | 24 | return view('health::list', [ 25 | 'lastRanAt' => new Carbon($checkResults?->finishedAt), 26 | 'checkResults' => $checkResults, 27 | 'assets' => $health->assets(), 28 | 'theme' => config('health.theme'), 29 | ]); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Exceptions/CouldNotSaveResultsInStore.php: -------------------------------------------------------------------------------- 1 | getMessage()}`", 17 | previous: $exception, 18 | ); 19 | } 20 | 21 | public static function doesNotExtendHealthCheckResultHistoryItem(mixed $invalidValue): self 22 | { 23 | $className = HealthCheckResultHistoryItem::class; 24 | 25 | return new self( 26 | "You tried to register an invalid HealthCheckResultHistoryItem model: `{$invalidValue}`. A valid model should extend `{$className}`" 27 | ); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Checks/Checks/EnvironmentCheck.php: -------------------------------------------------------------------------------- 1 | expectedEnvironment = $expectedEnvironment; 17 | 18 | return $this; 19 | } 20 | 21 | public function run(): Result 22 | { 23 | $actualEnvironment = (string) app()->environment(); 24 | 25 | $result = Result::make() 26 | ->meta([ 27 | 'actual' => $actualEnvironment, 28 | 'expected' => $this->expectedEnvironment, 29 | ]) 30 | ->shortSummary($actualEnvironment); 31 | 32 | return $this->expectedEnvironment === $actualEnvironment 33 | ? $result->ok() 34 | : $result->failed('The environment was expected to be `:expected`, but actually was `:actual`'); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) spatie 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/Facades/Health.php: -------------------------------------------------------------------------------- 1 | , Result|FakeValues|(Closure(Check): Result|FakeValues)> $checks 19 | */ 20 | public static function fake(array $checks = []): FakeHealth 21 | { 22 | $fake = (new FakeHealth(static::getFacadeRoot(), $checks)); 23 | 24 | static::swap($fake); 25 | static::swapAlias($fake); 26 | 27 | return $fake; 28 | } 29 | 30 | protected static function swapAlias(FakeHealth $fakeHealth): void 31 | { 32 | static::$resolvedInstance[\Spatie\Health\Health::class] = $fakeHealth; 33 | static::$app->instance(\Spatie\Health\Health::class, $fakeHealth); 34 | } 35 | 36 | protected static function getFacadeAccessor() 37 | { 38 | return 'health'; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Http/Controllers/HealthCheckJsonResultsController.php: -------------------------------------------------------------------------------- 1 | has('fresh') || config('health.oh_dear_endpoint.always_send_fresh_results')) { 16 | Artisan::call(RunHealthChecksCommand::class); 17 | } 18 | 19 | $checkResults = $resultStore->latestResults(); 20 | 21 | $statusCode = $checkResults?->containsFailingCheck() 22 | ? config('health.json_results_failure_status', Response::HTTP_OK) 23 | : Response::HTTP_OK; 24 | 25 | return response($checkResults?->toJson() ?? '', $statusCode) 26 | ->header('Content-Type', 'application/json') 27 | ->header('Cache-Control', 'no-store, no-cache, must-revalidate, post-check=0, pre-check=0'); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Checks/Checks/DebugModeCheck.php: -------------------------------------------------------------------------------- 1 | expected = $bool; 17 | 18 | return $this; 19 | } 20 | 21 | public function run(): Result 22 | { 23 | $actual = config('app.debug'); 24 | 25 | $result = Result::make() 26 | ->meta([ 27 | 'actual' => $actual, 28 | 'expected' => $this->expected, 29 | ]) 30 | ->shortSummary($this->convertToWord($actual)); 31 | 32 | return $this->expected === $actual 33 | ? $result->ok() 34 | : $result->failed("The debug mode was expected to be `{$this->convertToWord((bool) $this->expected)}`, but actually was `{$this->convertToWord((bool) $actual)}`"); 35 | } 36 | 37 | protected function convertToWord(bool $boolean): string 38 | { 39 | return $boolean ? 'true' : 'false'; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Testing/FakeCheck.php: -------------------------------------------------------------------------------- 1 | fakedCheck = $fakedCheck; 22 | $this->fakeValues = $values; 23 | 24 | $this->name($fakedCheck->getName()); 25 | $this->label($fakedCheck->getLabel()); 26 | 27 | return $this; 28 | } 29 | 30 | public function shouldRun(): bool 31 | { 32 | return $this->fakeValues->shouldRun() === null 33 | ? $this->fakedCheck->shouldRun() 34 | : $this->fakeValues->shouldRun(); 35 | } 36 | 37 | public function onTerminate(mixed $request, mixed $response): void 38 | { 39 | $this->fakedCheck->onTerminate($request, $response); 40 | } 41 | 42 | public function run(): Result 43 | { 44 | return $this->fakeValues->getResult(); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Checks/Checks/RedisCheck.php: -------------------------------------------------------------------------------- 1 | connectionName = $connectionName; 17 | 18 | return $this; 19 | } 20 | 21 | public function run(): Result 22 | { 23 | $result = Result::make()->meta([ 24 | 'connection_name' => $this->connectionName, 25 | ]); 26 | 27 | try { 28 | $response = $this->pingRedis(); 29 | } catch (Exception $exception) { 30 | return $result->failed("An exception occurred when connecting to Redis: `{$exception->getMessage()}`"); 31 | } 32 | 33 | if ($response === false) { 34 | return $result->failed('Redis returned a falsy response when try to connection to it.'); 35 | } 36 | 37 | return $result->ok(); 38 | } 39 | 40 | protected function pingRedis(): bool|string 41 | { 42 | return Redis::connection($this->connectionName)->ping(); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Testing/FakeHealth.php: -------------------------------------------------------------------------------- 1 | , Result|FakeValues|(Closure(Check): Result|FakeValues)> $fakeChecks 15 | */ 16 | public function __construct( 17 | private Health $decoratedHealth, 18 | private array $fakeChecks 19 | ) {} 20 | 21 | public function registeredChecks(): Collection 22 | { 23 | return $this->decoratedHealth->registeredChecks()->map( 24 | fn (Check $check) => array_key_exists($check::class, $this->fakeChecks) 25 | ? $this->buildFakeCheck($check, $this->fakeChecks[$check::class]) 26 | : $check 27 | ); 28 | } 29 | 30 | /** 31 | * @param Result|FakeValues|(Closure(Check): Result|FakeValues) $result 32 | */ 33 | protected function buildFakeCheck(Check $decoratedCheck, Result|FakeValues|Closure $result): FakeCheck 34 | { 35 | // @phpstan-ignore-next-line 36 | $result = FakeValues::from(value($result, $decoratedCheck)); 37 | 38 | return FakeCheck::new()->fake($decoratedCheck, $result); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /resources/views/logo.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /database/migrations/create_health_tables.php.stub: -------------------------------------------------------------------------------- 1 | getConnectionName(); 14 | $tableName = EloquentHealthResultStore::getHistoryItemInstance()->getTable(); 15 | 16 | Schema::connection($connection)->create($tableName, function (Blueprint $table) { 17 | $table->id(); 18 | 19 | $table->string('check_name'); 20 | $table->string('check_label'); 21 | $table->string('status'); 22 | $table->text('notification_message')->nullable(); 23 | $table->string('short_summary')->nullable(); 24 | $table->json('meta'); 25 | $table->timestamp('ended_at'); 26 | $table->uuid('batch'); 27 | 28 | $table->timestamps(); 29 | }); 30 | 31 | Schema::connection($connection)->table($tableName, function (Blueprint $table) { 32 | $table->index('created_at'); 33 | $table->index('batch'); 34 | }); 35 | } 36 | }; 37 | -------------------------------------------------------------------------------- /src/Http/Controllers/SimpleHealthCheckController.php: -------------------------------------------------------------------------------- 1 | has('fresh') || config('health.oh_dear_endpoint.always_send_fresh_results')) 20 | && Cache::missing(PauseHealthChecksCommand::CACHE_KEY) 21 | ) { 22 | Artisan::call(RunHealthChecksCommand::class); 23 | } 24 | 25 | if (! ($resultStore->latestResults()?->allChecksOk())) { 26 | throw new ServiceUnavailableHttpException(message: 'Application not healthy'); 27 | } 28 | 29 | return response([ 30 | 'healthy' => true, 31 | ]) 32 | ->header('Content-Type', 'application/json') 33 | ->header('Cache-Control', 'no-store, no-cache, must-revalidate, post-check=0, pre-check=0'); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Commands/ScheduleCheckHeartbeatCommand.php: -------------------------------------------------------------------------------- 1 | first( 20 | fn (Check $check) => $check instanceof ScheduleCheck 21 | ); 22 | 23 | if (! $scheduleCheck) { 24 | $this->error("In order to use this command, you should register the `Spatie\Health\Checks\Checks\ScheduleCheck`"); 25 | 26 | return static::FAILURE; 27 | } 28 | 29 | $cacheKey = $scheduleCheck->getCacheKey(); 30 | 31 | if (! $cacheKey) { 32 | $this->error("You must set the `cacheKey` of `Spatie\Health\Checks\Checks\ScheduleCheck` to a non-empty value"); 33 | 34 | return static::FAILURE; 35 | } 36 | 37 | cache()->store($scheduleCheck->getCacheStoreName())->set($cacheKey, now()->timestamp); 38 | 39 | return static::SUCCESS; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/ResultStores/InMemoryHealthResultStore.php: -------------------------------------------------------------------------------- 1 | map(function (Result $result) { 20 | return new StoredCheckResult( 21 | name: $result->check->getName(), 22 | label: $result->check->getLabel(), 23 | notificationMessage: $result->getNotificationMessage(), 24 | shortSummary: $result->getShortSummary(), 25 | status: (string) $result->status->value, 26 | meta: $result->meta, 27 | ); 28 | }) 29 | ->each(function (StoredCheckResult $check) { 30 | self::$storedCheckResults?->addCheck($check); 31 | }); 32 | } 33 | 34 | public function latestResults(): ?StoredCheckResults 35 | { 36 | return self::$storedCheckResults; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Support/BackupFile.php: -------------------------------------------------------------------------------- 1 | file = new SymfonyFile($path); 22 | } 23 | } 24 | 25 | public function path(): string 26 | { 27 | return $this->path; 28 | } 29 | 30 | public function size(): int 31 | { 32 | return $this->file ? $this->file->getSize() : $this->disk->size($this->path); 33 | } 34 | 35 | public function lastModified(): ?int 36 | { 37 | if ($this->parseModifiedUsing) { 38 | $filename = Str::of($this->path)->afterLast('/')->before('.'); 39 | 40 | try { 41 | return (int) Carbon::createFromFormat($this->parseModifiedUsing, $filename)->timestamp; 42 | } catch (InvalidFormatException $e) { 43 | return null; 44 | } 45 | } 46 | 47 | return $this->file ? $this->file->getMTime() : $this->disk->lastModified($this->path); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Checks/Checks/DatabaseSizeCheck.php: -------------------------------------------------------------------------------- 1 | failWhenSizeAboveGb = $errorThresholdGb; 20 | 21 | return $this; 22 | } 23 | 24 | public function run(): Result 25 | { 26 | $databaseSizeInGb = $this->getDatabaseSizeInGb(); 27 | 28 | $result = Result::make() 29 | ->meta([ 30 | 'database_size' => $databaseSizeInGb, 31 | ]) 32 | ->shortSummary("{$databaseSizeInGb} GB"); 33 | 34 | return $databaseSizeInGb >= $this->failWhenSizeAboveGb 35 | ? $result->failed("Database size is {$databaseSizeInGb} GB, which is above the threshold of {$this->failWhenSizeAboveGb} GB") 36 | : $result->ok(); 37 | } 38 | 39 | protected function getDatabaseSizeInGb(): float 40 | { 41 | $connectionName = $this->connectionName ?? $this->getDefaultConnectionName(); 42 | 43 | $connection = app(ConnectionResolverInterface::class)->connection($connectionName); 44 | 45 | return round((new DbConnectionInfo)->databaseSizeInMb($connection) / 1000, 2); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Models/HealthCheckResultHistoryItem.php: -------------------------------------------------------------------------------- 1 | $meta 19 | * @property string $status 20 | * @property string $check_name 21 | * @property string $check_label 22 | */ 23 | class HealthCheckResultHistoryItem extends Model 24 | { 25 | use HasFactory; 26 | use MassPrunable; 27 | 28 | protected $guarded = []; 29 | 30 | /** @var array */ 31 | public $casts = [ 32 | 'meta' => 'array', 33 | 'started_failing_at' => 'timestamp', 34 | ]; 35 | 36 | public function getConnectionName(): string 37 | { 38 | return $this->connection ?: 39 | config('health.result_stores.'.EloquentHealthResultStore::class.'.connection') ?: 40 | config('database.default'); 41 | } 42 | 43 | public function prunable(): Builder 44 | { 45 | $days = config('health.result_stores.'.EloquentHealthResultStore::class.'.keep_history_for_days') ?? 5; 46 | 47 | return static::where('created_at', '<=', now()->subDays($days)); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Traits/Pingable.php: -------------------------------------------------------------------------------- 1 | isValidUrl($url)) { 22 | Log::error("Invalid URL provided for health check ping: {$url}"); 23 | 24 | return; 25 | } 26 | 27 | try { 28 | Http::timeout($this->timeout) 29 | ->retry($this->retryTimes) 30 | ->get($url); 31 | } catch (\Exception $e) { 32 | Log::error('Failed to ping health check URL: '.$e->getMessage()); 33 | } 34 | } 35 | 36 | protected function isValidUrl(string $url): bool 37 | { 38 | if (! filter_var($url, FILTER_VALIDATE_URL)) { 39 | return false; 40 | } 41 | 42 | return true; 43 | } 44 | 45 | public function pingTimeout(int $seconds): self 46 | { 47 | if ($seconds <= 0) { 48 | throw new InvalidArgumentException('Timeout must be a positive integer.'); 49 | } 50 | $this->timeout = $seconds; 51 | 52 | return $this; 53 | } 54 | 55 | public function pingRetryTimes(int $times): self 56 | { 57 | $this->retryTimes = $times; 58 | 59 | return $this; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /resources/views/list-cli.blade.php: -------------------------------------------------------------------------------- 1 |
2 | @if(count($checkResults?->storedCheckResults ?? [])) 3 |
4 | Laravel Health Check Results 5 | 6 | Last ran all the checks 7 | @if ($lastRanAt->diffInMinutes() < 1) 8 | just now 9 | @else 10 | {{ $lastRanAt->diffForHumans() }} 11 | @endif 12 | 13 |
14 | @foreach ($checkResults->storedCheckResults as $result) 15 |
16 | 17 | 18 | {{ ucfirst($result->status) }} 19 | 20 | 21 | {{ $result->label }} 22 | 23 | {{ $result->shortSummary }} 24 |
25 | @if ($result->notificationMessage) 26 |
27 | ⇂ {{ $result->notificationMessage }} 28 |
29 | @endif 30 | @endforeach 31 | @else 32 |
33 | No checks have run yet...
34 | Please execute this command: 35 |

36 | php artisan health:check 37 |
38 | @endif 39 |
40 | -------------------------------------------------------------------------------- /src/Checks/Checks/CacheCheck.php: -------------------------------------------------------------------------------- 1 | driver = $driver; 18 | 19 | return $this; 20 | } 21 | 22 | public function run(): Result 23 | { 24 | $driver = $this->driver ?? $this->defaultDriver(); 25 | 26 | $result = Result::make()->meta([ 27 | 'driver' => $driver, 28 | ]); 29 | 30 | try { 31 | return $this->canWriteValuesToCache($driver) 32 | ? $result->ok() 33 | : $result->failed('Could not set or retrieve an application cache value.'); 34 | } catch (Exception $exception) { 35 | return $result->failed("An exception occurred with the application cache: `{$exception->getMessage()}`"); 36 | } 37 | } 38 | 39 | protected function defaultDriver(): ?string 40 | { 41 | return config('cache.default', 'file'); 42 | } 43 | 44 | protected function canWriteValuesToCache(?string $driver): bool 45 | { 46 | $expectedValue = Str::random(5); 47 | 48 | $cacheName = "laravel-health:check-{$expectedValue}"; 49 | 50 | Cache::driver($driver)->put($cacheName, $expectedValue, 10); 51 | 52 | $actualValue = Cache::driver($driver)->get($cacheName); 53 | 54 | Cache::driver($driver)->forget($cacheName); 55 | 56 | return $actualValue === $expectedValue; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /phpstan-baseline.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | ignoreErrors: 3 | - 4 | message: "#^Call to an undefined method Illuminate\\\\Database\\\\ConnectionInterface\\:\\:getDriverName\\(\\)\\.$#" 5 | count: 1 6 | path: src/Exceptions/DatabaseNotSupported.php 7 | 8 | - 9 | message: "#^Method Spatie\\\\Health\\\\Notifications\\\\CheckFailedNotification\\:\\:transParameters\\(\\) should return array\\ but returns array\\\\.$#" 10 | count: 1 11 | path: src/Notifications/CheckFailedNotification.php 12 | 13 | - 14 | message: "#^Unable to resolve the template type TKey in call to function collect$#" 15 | count: 1 16 | path: src/ResultStores/ResultStores.php 17 | 18 | - 19 | message: "#^Unable to resolve the template type TValue in call to function collect$#" 20 | count: 1 21 | path: src/ResultStores/ResultStores.php 22 | 23 | - 24 | message: "#^Property Spatie\\\\Health\\\\ResultStores\\\\StoredCheckResults\\\\StoredCheckResults\\:\\:\\$storedCheckResults \\(Illuminate\\\\Support\\\\Collection\\\\) does not accept Illuminate\\\\Support\\\\Collection\\\\|Illuminate\\\\Support\\\\Collection\\\\.$#" 25 | count: 1 26 | path: src/ResultStores/StoredCheckResults/StoredCheckResults.php 27 | 28 | - 29 | message: "#^Unable to resolve the template type TKey in call to function collect$#" 30 | count: 2 31 | path: src/ResultStores/StoredCheckResults/StoredCheckResults.php 32 | 33 | - 34 | message: "#^Unable to resolve the template type TValue in call to function collect$#" 35 | count: 2 36 | path: src/ResultStores/StoredCheckResults/StoredCheckResults.php 37 | -------------------------------------------------------------------------------- /resources/views/status-indicator.blade.php: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | @if($icon($result->status) == 'check-circle') 5 | 6 | @elseif($icon($result->status) == 'exclamation-circle') 7 | 8 | @elseif($icon($result->status) == 'arrow-circle-right') 9 | 10 | @elseif($icon($result->status) == 'x-circle') 11 | 12 | @else 13 | 14 | 15 | @endif 16 | 17 |
18 | 19 | -------------------------------------------------------------------------------- /src/ResultStores/CacheHealthResultStore.php: -------------------------------------------------------------------------------- 1 | map(function (Result $result) { 23 | return new StoredCheckResult( 24 | name: $result->check->getName(), 25 | label: $result->check->getLabel(), 26 | notificationMessage: $result->getNotificationMessage(), 27 | shortSummary: $result->getShortSummary(), 28 | status: (string) $result->status->value, 29 | meta: $result->meta, 30 | ); 31 | }) 32 | ->each(function (StoredCheckResult $check) use ($report) { 33 | $report->addCheck($check); 34 | }); 35 | 36 | cache() 37 | ->store($this->store) 38 | ->put($this->cacheKey, $report->toJson()); 39 | } 40 | 41 | public function latestResults(): ?StoredCheckResults 42 | { 43 | $healthResultsJson = cache() 44 | ->store($this->store) 45 | ->get($this->cacheKey); 46 | 47 | if (! $healthResultsJson) { 48 | return null; 49 | } 50 | 51 | return StoredCheckResults::fromJson($healthResultsJson); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Checks/Checks/HorizonCheck.php: -------------------------------------------------------------------------------- 1 | heartbeatUrl = $url; 24 | 25 | return $this; 26 | } 27 | 28 | public function run(): Result 29 | { 30 | $result = Result::make(); 31 | 32 | try { 33 | $horizon = app(MasterSupervisorRepository::class); 34 | } catch (Exception) { 35 | return $result->failed('Horizon does not seem to be installed correctly.'); 36 | } 37 | 38 | $masterSupervisors = $horizon->all(); 39 | 40 | if (count($masterSupervisors) === 0) { 41 | return $result 42 | ->failed('Horizon is not running.') 43 | ->shortSummary('Not running'); 44 | } 45 | 46 | $masterSupervisor = $masterSupervisors[0]; 47 | 48 | if ($masterSupervisor->status === 'paused') { 49 | return $result 50 | ->warning('Horizon is running, but the status is paused.') 51 | ->shortSummary('Paused'); 52 | } 53 | 54 | $heartbeatUrl = $this->heartbeatUrl ?? config('health.horizon.heartbeat_url'); 55 | 56 | if ($heartbeatUrl) { 57 | $this->pingUrl($heartbeatUrl); 58 | } 59 | 60 | return $result->ok()->shortSummary('Running'); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/Checks/Checks/OptimizedAppCheck.php: -------------------------------------------------------------------------------- 1 | |null */ 17 | public ?array $checks = null; 18 | 19 | public function run(): Result 20 | { 21 | $result = Result::make(); 22 | 23 | if ($this->shouldPerformCheck(self::CONFIG)) { 24 | if (! app()->configurationIsCached()) { 25 | return $result->failed('Configs are not cached.'); 26 | } 27 | } 28 | 29 | if ($this->shouldPerformCheck(self::ROUTES)) { 30 | if (! app()->routesAreCached()) { 31 | return $result->failed('Routes are not cached.'); 32 | } 33 | } 34 | 35 | if ($this->shouldPerformCheck(self::EVENTS)) { 36 | if (! app()->eventsAreCached()) { 37 | return $result->failed('The events are not cached.'); 38 | } 39 | } 40 | 41 | return $result->ok(); 42 | } 43 | 44 | public function checkConfig(): self 45 | { 46 | return $this->addCheck(self::CONFIG); 47 | } 48 | 49 | public function checkRoutes(): self 50 | { 51 | return $this->addCheck(self::ROUTES); 52 | } 53 | 54 | public function checkEvents(): self 55 | { 56 | return $this->addCheck(self::EVENTS); 57 | } 58 | 59 | protected function addCheck(string $check): self 60 | { 61 | if ($this->checks === null) { 62 | $this->checks = []; 63 | } 64 | 65 | $this->checks[] = $check; 66 | 67 | return $this; 68 | } 69 | 70 | protected function shouldPerformCheck(string $check): bool 71 | { 72 | if ($this->checks === null) { 73 | return true; 74 | } 75 | 76 | return in_array($check, $this->checks); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/Checks/Checks/UsedDiskSpaceCheck.php: -------------------------------------------------------------------------------- 1 | filesystemName = $filesystemName; 21 | 22 | return $this; 23 | } 24 | 25 | public function warnWhenUsedSpaceIsAbovePercentage(int $percentage): self 26 | { 27 | $this->warningThreshold = $percentage; 28 | 29 | return $this; 30 | } 31 | 32 | public function failWhenUsedSpaceIsAbovePercentage(int $percentage): self 33 | { 34 | $this->errorThreshold = $percentage; 35 | 36 | return $this; 37 | } 38 | 39 | public function run(): Result 40 | { 41 | $diskSpaceUsedPercentage = $this->getDiskUsagePercentage(); 42 | 43 | $result = Result::make() 44 | ->meta(['disk_space_used_percentage' => $diskSpaceUsedPercentage]) 45 | ->shortSummary($diskSpaceUsedPercentage.'%'); 46 | 47 | if ($diskSpaceUsedPercentage > $this->errorThreshold) { 48 | return $result->failed("The disk is almost full ({$diskSpaceUsedPercentage}% used)."); 49 | } 50 | 51 | if ($diskSpaceUsedPercentage > $this->warningThreshold) { 52 | return $result->warning("The disk is almost full ({$diskSpaceUsedPercentage}% used)."); 53 | } 54 | 55 | return $result->ok(); 56 | } 57 | 58 | protected function getDiskUsagePercentage(): int 59 | { 60 | $process = Process::fromShellCommandline('df -P '.($this->filesystemName ?: '.')); 61 | 62 | $process->run(); 63 | 64 | $output = $process->getOutput(); 65 | 66 | return (int) Regex::match('/(\d*)%/', $output)->group(1); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/ResultStores/StoredCheckResults/StoredCheckResult.php: -------------------------------------------------------------------------------- 1 | $meta 9 | */ 10 | public static function make( 11 | string $name, 12 | string $label = '', 13 | ?string $notificationMessage = '', 14 | string $shortSummary = '', 15 | string $status = '', 16 | array $meta = [], 17 | ): self { 18 | return new self(...func_get_args()); 19 | } 20 | 21 | /** 22 | * @param array $meta 23 | */ 24 | public function __construct( 25 | public string $name, 26 | public string $label = '', 27 | public ?string $notificationMessage = '', 28 | public string $shortSummary = '', 29 | public string $status = '', 30 | public array $meta = [], 31 | ) {} 32 | 33 | public function notificationMessage(string $message): self 34 | { 35 | $this->notificationMessage = $message; 36 | 37 | return $this; 38 | } 39 | 40 | public function status(string $status): self 41 | { 42 | $this->status = $status; 43 | 44 | return $this; 45 | } 46 | 47 | public function shortSummary(string $shortSummary): self 48 | { 49 | $this->shortSummary = $shortSummary; 50 | 51 | return $this; 52 | } 53 | 54 | /** 55 | * @param array $meta 56 | * @return $this 57 | */ 58 | public function meta(array $meta): self 59 | { 60 | $this->meta = $meta; 61 | 62 | return $this; 63 | } 64 | 65 | /** @return array */ 66 | public function toArray(): array 67 | { 68 | return [ 69 | 'name' => $this->name, 70 | 'label' => $this->label, 71 | 'notificationMessage' => $this->notificationMessage, 72 | 'shortSummary' => $this->shortSummary, 73 | 'status' => $this->status, 74 | 'meta' => $this->meta, 75 | ]; 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/Checks/Checks/DatabaseConnectionCountCheck.php: -------------------------------------------------------------------------------- 1 | warningThreshold = $warningThreshold; 23 | 24 | return $this; 25 | } 26 | 27 | public function failWhenMoreConnectionsThan(int $errorThreshold): self 28 | { 29 | $this->errorThreshold = $errorThreshold; 30 | 31 | return $this; 32 | } 33 | 34 | public function run(): Result 35 | { 36 | $connectionCount = $this->getConnectionCount(); 37 | 38 | $shortSummary = $connectionCount.' '.Str::plural('connection', $connectionCount); 39 | 40 | $result = Result::make() 41 | ->ok() 42 | ->meta(['connection_count' => $connectionCount]) 43 | ->shortSummary($shortSummary); 44 | 45 | if ($connectionCount > $this->errorThreshold) { 46 | return $result->failed("There are too many database connections ({$connectionCount} connections)"); 47 | } 48 | 49 | if (! is_null($this->warningThreshold)) { 50 | if ($connectionCount > $this->warningThreshold) { 51 | return $result->warning($shortSummary); 52 | } 53 | } 54 | 55 | return $result; 56 | } 57 | 58 | protected function getConnectionCount(): int 59 | { 60 | $connectionName = $this->connectionName ?? $this->getDefaultConnectionName(); 61 | 62 | $connection = app(ConnectionResolverInterface::class)->connection($connectionName); 63 | 64 | return (new DbConnectionInfo)->connectionCount($connection); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Components/StatusIndicator.php: -------------------------------------------------------------------------------- 1 | $this->result, 18 | 'backgroundColor' => fn (string $status) => $this->getBackgroundColor($status), 19 | 'iconColor' => fn (string $status) => $this->getIconColor($status), 20 | 'icon' => fn (string $status) => $this->getIcon($status), 21 | ]); 22 | } 23 | 24 | protected function getBackgroundColor(string $status): string 25 | { 26 | return match ($status) { 27 | Status::ok()->value => 'md:bg-emerald-100 md:dark:bg-emerald-800', 28 | Status::warning()->value => 'md:bg-yellow-100 md:dark:bg-yellow-800', 29 | Status::skipped()->value => 'md:bg-blue-100 md:dark:bg-blue-800', 30 | Status::failed()->value, Status::crashed()->value => 'md:bg-red-100 md:dark:bg-red-800', 31 | default => 'md:bg-gray-100 md:dark:bg-gray-600' 32 | }; 33 | } 34 | 35 | protected function getIconColor(string $status): string 36 | { 37 | return match ($status) { 38 | Status::ok()->value => 'text-emerald-500', 39 | Status::warning()->value => 'text-yellow-500', 40 | Status::skipped()->value => 'text-blue-500', 41 | Status::failed()->value, Status::crashed()->value => 'text-red-500', 42 | default => 'text-gray-500' 43 | }; 44 | } 45 | 46 | protected function getIcon(string $status): string 47 | { 48 | return match ($status) { 49 | Status::ok()->value => 'check-circle', 50 | Status::warning()->value => 'exclamation-circle', 51 | Status::skipped()->value => 'arrow-circle-right', 52 | Status::failed()->value, Status::crashed()->value => 'x-circle', 53 | default => '' 54 | }; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/ResultStores/JsonFileHealthResultStore.php: -------------------------------------------------------------------------------- 1 | disk = Storage::disk($disk); 22 | 23 | $this->path = $path; 24 | } 25 | 26 | /** @param Collection $checkResults */ 27 | public function save(Collection $checkResults): void 28 | { 29 | $report = new StoredCheckResults(now()); 30 | 31 | $checkResults 32 | ->map(function (Result $result) { 33 | return new StoredCheckResult( 34 | name: $result->check->getName(), 35 | label: $result->check->getLabel(), 36 | notificationMessage: $result->getNotificationMessage(), 37 | shortSummary: $result->getShortSummary(), 38 | status: (string) $result->status->value, 39 | meta: $result->meta, 40 | ); 41 | }) 42 | ->each(function (StoredCheckResult $check) use ($report) { 43 | $report->addCheck($check); 44 | }); 45 | 46 | $contents = $report->toJson(); 47 | 48 | if ($this->disk->exists($this->path)) { 49 | $this->disk->delete($this->path); 50 | } 51 | $this->disk->write($this->path, $contents); 52 | } 53 | 54 | public function latestResults(): ?StoredCheckResults 55 | { 56 | $content = null; 57 | 58 | try { 59 | $content = $this->disk->read($this->path); 60 | } catch (Exception $exception) { 61 | report($exception); 62 | } 63 | 64 | if (! $content) { 65 | return null; 66 | } 67 | 68 | return StoredCheckResults::fromJson($content); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/Checks/Checks/DatabaseTableSizeCheck.php: -------------------------------------------------------------------------------- 1 | */ 16 | protected array $checkingTables = []; 17 | 18 | public function table(string $name, int $maxSizeInMb): self 19 | { 20 | $this->checkingTables[$name] = $maxSizeInMb; 21 | 22 | return $this; 23 | } 24 | 25 | public function run(): Result 26 | { 27 | $tableSizes = collect($this->checkingTables) 28 | ->map(function (int $maxSizeInMb, string $tableName) { 29 | return [ 30 | 'name' => $tableName, 31 | 'actualSize' => $this->getTableSizeInMb($tableName), 32 | 'maxSize' => $maxSizeInMb, 33 | ]; 34 | }); 35 | 36 | $result = Result::make()->meta($tableSizes->toArray()); 37 | 38 | $tooBigTables = $tableSizes->filter( 39 | fn (array $tableProperties) => $tableProperties['actualSize'] > $tableProperties['maxSize'] 40 | ); 41 | 42 | if ($tooBigTables->isEmpty()) { 43 | return $result 44 | ->ok() 45 | ->shortSummary('Table sizes are ok'); 46 | } 47 | 48 | $tablesString = $tooBigTables->map(function (array $tableProperties) { 49 | return "`{$tableProperties['name']}` ({$tableProperties['actualSize']} MB)"; 50 | })->join(', ', ' and '); 51 | 52 | $messageStart = $tooBigTables->count() === 1 53 | ? 'This table is' 54 | : 'These tables are'; 55 | 56 | $message = "{$messageStart} too big: {$tablesString}"; 57 | 58 | return $result->failed($message); 59 | } 60 | 61 | protected function getTableSizeInMb(string $tableName): float 62 | { 63 | $connectionName = $this->connectionName ?? $this->getDefaultConnectionName(); 64 | 65 | $connection = app(ConnectionResolverInterface::class)->connection($connectionName); 66 | 67 | return (new DbConnectionInfo)->tableSizeInMb($connection, $tableName); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/Checks/Checks/ScheduleCheck.php: -------------------------------------------------------------------------------- 1 | cacheStoreName = $cacheStoreName; 24 | 25 | return $this; 26 | } 27 | 28 | public function getCacheStoreName(): string 29 | { 30 | return $this->cacheStoreName ?? config('cache.default'); 31 | } 32 | 33 | public function cacheKey(string $cacheKey): self 34 | { 35 | $this->cacheKey = $cacheKey; 36 | 37 | return $this; 38 | } 39 | 40 | public function heartbeatMaxAgeInMinutes(int $heartbeatMaxAgeInMinutes): self 41 | { 42 | $this->heartbeatMaxAgeInMinutes = $heartbeatMaxAgeInMinutes; 43 | 44 | return $this; 45 | } 46 | 47 | public function getCacheKey(): string 48 | { 49 | return $this->cacheKey; 50 | } 51 | 52 | public function run(): Result 53 | { 54 | $result = Result::make()->ok(); 55 | 56 | $lastHeartbeatTimestamp = cache()->store($this->cacheStoreName)->get($this->cacheKey); 57 | 58 | if (! $lastHeartbeatTimestamp) { 59 | return $result->failed('The schedule did not run yet.'); 60 | } 61 | 62 | $latestHeartbeatAt = Carbon::createFromTimestamp($lastHeartbeatTimestamp); 63 | 64 | $carbonVersion = InstalledVersions::getVersion('nesbot/carbon'); 65 | 66 | $minutesAgo = $latestHeartbeatAt->diffInMinutes(); 67 | 68 | if (version_compare( 69 | $carbonVersion, 70 | '3.0.0', 71 | '<' 72 | )) { 73 | $minutesAgo += 1; 74 | } 75 | 76 | if ($minutesAgo > $this->heartbeatMaxAgeInMinutes) { 77 | return $result->failed("The last run of the schedule was more than {$minutesAgo} minutes ago."); 78 | } 79 | 80 | if (config('health.schedule.heartbeat_url')) { 81 | $this->pingUrl(config('health.schedule.heartbeat_url')); 82 | } 83 | 84 | return $result; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/Checks/Checks/MeilisearchCheck.php: -------------------------------------------------------------------------------- 1 | timeout = $seconds; 22 | 23 | return $this; 24 | } 25 | 26 | public function url(string $url): self 27 | { 28 | $this->url = $url; 29 | 30 | return $this; 31 | } 32 | 33 | public function token(string $token): self 34 | { 35 | $this->token = $token; 36 | 37 | return $this; 38 | } 39 | 40 | public function getLabel(): string 41 | { 42 | return $this->getName(); 43 | } 44 | 45 | public function run(): Result 46 | { 47 | try { 48 | $response = Http::timeout($this->timeout) 49 | ->when($this->token !== null, fn ($r) => $r->withToken($this->token)) 50 | ->asJson() 51 | ->get($this->url); 52 | } catch (Exception) { 53 | return Result::make() 54 | ->failed() 55 | ->shortSummary('Unreachable') 56 | ->notificationMessage("Could not reach {$this->url}."); 57 | } 58 | 59 | /** @phpstan-ignore-next-line */ 60 | if (! $response) { 61 | return Result::make() 62 | ->failed() 63 | ->shortSummary('Did not respond') 64 | ->notificationMessage("Did not get a response from {$this->url}."); 65 | } 66 | 67 | if (! Arr::has($response, 'status')) { 68 | return Result::make() 69 | ->failed() 70 | ->shortSummary('Invalid response') 71 | ->notificationMessage('The response did not contain a `status` key.'); 72 | } 73 | 74 | $status = Arr::get($response, 'status'); 75 | 76 | if (! in_array($status, ['available', 'running'])) { 77 | return Result::make() 78 | ->failed() 79 | ->shortSummary(ucfirst($status)) 80 | ->notificationMessage("The health check returned a status `{$status}`."); 81 | } 82 | 83 | return Result::make() 84 | ->ok() 85 | ->shortSummary(ucfirst($status)); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/Health.php: -------------------------------------------------------------------------------- 1 | */ 16 | protected array $checks = []; 17 | 18 | /** @var array */ 19 | protected array $inlineStylesheets = []; 20 | 21 | /** @param array $checks */ 22 | public function checks(array $checks): self 23 | { 24 | $this->ensureCheckInstances($checks); 25 | 26 | $this->checks = array_merge($this->checks, $checks); 27 | 28 | $this->guardAgainstDuplicateCheckNames(); 29 | 30 | return $this; 31 | } 32 | 33 | public function clearChecks(): self 34 | { 35 | $this->checks = []; 36 | 37 | return $this; 38 | } 39 | 40 | /** @return Collection */ 41 | public function registeredChecks(): Collection 42 | { 43 | return collect($this->checks); 44 | } 45 | 46 | /** @return Collection */ 47 | public function resultStores(): Collection 48 | { 49 | return ResultStores::createFromConfig(); 50 | } 51 | 52 | public function inlineStylesheet(string $stylesheet): self 53 | { 54 | $this->inlineStylesheets[] = $stylesheet; 55 | 56 | return $this; 57 | } 58 | 59 | public function assets(): HtmlString 60 | { 61 | $assets = []; 62 | 63 | foreach ($this->inlineStylesheets as $inlineStylesheet) { 64 | $assets[] = ""; 65 | } 66 | 67 | return new HtmlString(implode('', $assets)); 68 | } 69 | 70 | /** @param array $checks */ 71 | protected function ensureCheckInstances(array $checks): void 72 | { 73 | foreach ($checks as $check) { 74 | if (! $check instanceof Check) { 75 | throw InvalidCheck::doesNotExtendCheck($check); 76 | } 77 | } 78 | } 79 | 80 | protected function guardAgainstDuplicateCheckNames(): void 81 | { 82 | $duplicateCheckNames = collect($this->checks) 83 | ->map(fn (Check $check) => $check->getName()) 84 | ->duplicates(); 85 | 86 | if ($duplicateCheckNames->isNotEmpty()) { 87 | throw DuplicateCheckNamesFound::make($duplicateCheckNames); 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/Checks/Checks/RedisMemoryUsageCheck.php: -------------------------------------------------------------------------------- 1 | connectionName = $connectionName; 22 | 23 | return $this; 24 | } 25 | 26 | public function warnWhenAboveMb(float $errorThresholdMb): self 27 | { 28 | $this->warnWhenAboveMb = $errorThresholdMb; 29 | 30 | return $this; 31 | } 32 | 33 | public function failWhenAboveMb(float $errorThresholdMb): self 34 | { 35 | $this->failWhenAboveMb = $errorThresholdMb; 36 | 37 | return $this; 38 | } 39 | 40 | public function run(): Result 41 | { 42 | $memoryUsage = $this->getMemoryUsageInMb(); 43 | 44 | $result = Result::make()->shortSummary("{$memoryUsage} MB used"); 45 | 46 | $result->meta([ 47 | 'connection_name' => $this->connectionName, 48 | 'memory_usage' => $memoryUsage, 49 | ]); 50 | 51 | $message = "Redis memory usage is {$memoryUsage} MB. The {threshold_type} threshold is {memory_threshold} MB."; 52 | 53 | if ($memoryUsage >= $this->failWhenAboveMb) { 54 | return $result->failed(strtr($message, [ 55 | '{threshold_type}' => 'fail', 56 | '{memory_threshold}' => $this->failWhenAboveMb, 57 | ])); 58 | } 59 | if ($this->warnWhenAboveMb && $memoryUsage >= $this->warnWhenAboveMb) { 60 | return $result->warning(strtr($message, [ 61 | '{threshold_type}' => 'warning', 62 | '{memory_threshold}' => $this->warnWhenAboveMb, 63 | ])); 64 | } 65 | 66 | return $result->ok(); 67 | } 68 | 69 | protected function getMemoryUsageInMb(): float 70 | { 71 | $redis = Redis::connection($this->connectionName); 72 | 73 | $memoryUsage = (int) match (get_class($redis)) { 74 | PhpRedisConnection::class => $redis->info()['used_memory'], 75 | PredisConnection::class => $redis->info()['Memory']['used_memory'], 76 | default => null, 77 | }; 78 | 79 | return round($memoryUsage / 1024 / 1024, 2); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /resources/views/list.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {{ __('health::notifications.health_results') }} 6 | 7 | {{$assets}} 8 | 9 | 10 | 11 |
12 |
13 |

{{ __('health::notifications.laravel_health') }}

14 |
15 | 16 |
17 | @if ($lastRanAt) 18 |
19 | {{ __('health::notifications.check_results_from') }} {{ $lastRanAt->diffForHumans() }} 20 |
21 | @endif 22 |
23 |
24 | @if (count($checkResults?->storedCheckResults ?? [])) 25 |
26 | @foreach ($checkResults->storedCheckResults as $result) 27 |
28 | 29 |
30 |
31 | {{ $result->label }} 32 |
33 |
34 | @if (!empty($result->notificationMessage)) 35 | {{ $result->notificationMessage }} 36 | @else 37 | {{ $result->shortSummary }} 38 | @endif 39 |
40 |
41 |
42 | @endforeach 43 |
44 | @endif 45 |
46 |
47 | 48 | 49 | -------------------------------------------------------------------------------- /src/Checks/Checks/PingCheck.php: -------------------------------------------------------------------------------- 1 | */ 24 | protected array $headers = []; 25 | 26 | public function url(string $url): self 27 | { 28 | $this->url = $url; 29 | 30 | return $this; 31 | } 32 | 33 | public function timeout(int $seconds): self 34 | { 35 | $this->timeout = $seconds; 36 | 37 | return $this; 38 | } 39 | 40 | public function method(string $method): self 41 | { 42 | $this->method = $method; 43 | 44 | return $this; 45 | } 46 | 47 | public function retryTimes(int $times): self 48 | { 49 | $this->retryTimes = $times; 50 | 51 | return $this; 52 | } 53 | 54 | /** 55 | * @param array $headers 56 | * @return $this 57 | */ 58 | public function headers(array $headers = []): self 59 | { 60 | $this->headers = $headers; 61 | 62 | return $this; 63 | } 64 | 65 | public function failureMessage(string $failureMessage): self 66 | { 67 | $this->failureMessage = $failureMessage; 68 | 69 | return $this; 70 | } 71 | 72 | public function run(): Result 73 | { 74 | if (is_null($this->url)) { 75 | return Result::make() 76 | ->failed() 77 | ->shortSummary(InvalidCheck::urlNotSet()->getMessage()); 78 | } 79 | 80 | try { 81 | $request = Http::timeout($this->timeout) 82 | ->withHeaders($this->headers) 83 | ->retry($this->retryTimes) 84 | ->send($this->method, $this->url); 85 | 86 | if (! $request->successful()) { 87 | return $this->failedResult(); 88 | } 89 | } catch (Exception) { 90 | return $this->failedResult(); 91 | } 92 | 93 | return Result::make() 94 | ->ok() 95 | ->shortSummary('Reachable'); 96 | } 97 | 98 | protected function failedResult(): Result 99 | { 100 | return Result::make() 101 | ->failed() 102 | ->shortSummary('Unreachable') 103 | ->notificationMessage($this->failureMessage ?? "Pinging {$this->getName()} failed."); 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/Commands/ListHealthChecksCommand.php: -------------------------------------------------------------------------------- 1 | option('fresh')) { 26 | $parameters = []; 27 | if ($this->option('do-not-store-results')) { 28 | $parameters['--do-not-store-results'] = true; 29 | } 30 | if ($this->option('no-notification')) { 31 | $parameters['--no-notification'] = true; 32 | } 33 | 34 | Artisan::call(RunHealthChecksCommand::class, $parameters); 35 | } 36 | 37 | $resultStore = app(ResultStore::class); 38 | 39 | $checkResults = $resultStore->latestResults(); 40 | 41 | renderUsing($this->output); 42 | render(view('health::list-cli', [ 43 | 'lastRanAt' => new Carbon($checkResults?->finishedAt), 44 | 'checkResults' => $checkResults, 45 | 'color' => fn (string $status) => $this->getBackgroundColor($status), 46 | ])); 47 | 48 | return $this->determineCommandResult($checkResults); 49 | } 50 | 51 | protected function getBackgroundColor(string $status): string 52 | { 53 | $status = Status::from($status); 54 | 55 | return match ($status) { 56 | Status::ok() => 'text-green-600', 57 | Status::warning() => 'text-yellow-600', 58 | Status::skipped() => 'text-blue-600', 59 | Status::failed(), Status::crashed() => 'text-red-600', 60 | default => '' 61 | }; 62 | } 63 | 64 | protected function determineCommandResult(?StoredCheckResults $results): int 65 | { 66 | if (! $this->option('fail-command-on-failing-check') || is_null($results)) { 67 | return self::SUCCESS; 68 | } 69 | 70 | $containsFailingCheck = $results->storedCheckResults->contains(function (StoredCheckResult $result) { 71 | return in_array($result->status, [ 72 | Status::crashed(), 73 | Status::failed(), 74 | Status::warning(), 75 | ]); 76 | }); 77 | 78 | return $containsFailingCheck 79 | ? self::FAILURE 80 | : self::SUCCESS; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/Checks/Result.php: -------------------------------------------------------------------------------- 1 | */ 14 | public array $meta = []; 15 | 16 | public Check $check; 17 | 18 | public ?CarbonInterface $ended_at; 19 | 20 | public static function make(string $message = ''): self 21 | { 22 | return new self(Status::ok(), $message); 23 | } 24 | 25 | public function __construct( 26 | public Status $status, 27 | public string $notificationMessage = '', 28 | public string $shortSummary = '', 29 | ) {} 30 | 31 | public function shortSummary(string $shortSummary): self 32 | { 33 | $this->shortSummary = $shortSummary; 34 | 35 | return $this; 36 | } 37 | 38 | public function getShortSummary(): string 39 | { 40 | if (! empty($this->shortSummary)) { 41 | return $this->shortSummary; 42 | } 43 | 44 | return Str::of($this->status)->snake()->replace('_', ' ')->title(); 45 | } 46 | 47 | public function check(Check $check): self 48 | { 49 | $this->check = $check; 50 | 51 | return $this; 52 | } 53 | 54 | public function notificationMessage(string $notificationMessage): self 55 | { 56 | $this->notificationMessage = $notificationMessage; 57 | 58 | return $this; 59 | } 60 | 61 | public function getNotificationMessage(): string 62 | { 63 | $meta = collect($this->meta) 64 | ->filter(function ($item) { 65 | return is_scalar($item); 66 | })->toArray(); 67 | 68 | return trans($this->notificationMessage, $meta); 69 | } 70 | 71 | public function ok(string $message = ''): self 72 | { 73 | $this->notificationMessage = $message; 74 | 75 | $this->status = Status::ok(); 76 | 77 | return $this; 78 | } 79 | 80 | public function warning(string $message = ''): self 81 | { 82 | $this->notificationMessage = $message; 83 | 84 | $this->status = Status::warning(); 85 | 86 | return $this; 87 | } 88 | 89 | public function failed(string $message = ''): self 90 | { 91 | $this->notificationMessage = $message; 92 | 93 | $this->status = Status::failed(); 94 | 95 | return $this; 96 | } 97 | 98 | /** @param array $meta */ 99 | public function meta(array $meta): self 100 | { 101 | $this->meta = $meta; 102 | 103 | return $this; 104 | } 105 | 106 | public function appendMeta($meta): self 107 | { 108 | $this->meta = array_merge($this->meta, $meta); 109 | 110 | return $this; 111 | } 112 | 113 | public function endedAt(CarbonInterface $carbon): self 114 | { 115 | $this->ended_at = $carbon; 116 | 117 | return $this; 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/Support/DbConnectionInfo.php: -------------------------------------------------------------------------------- 1 | (int) $connection->selectOne('SELECT COUNT(*) FROM information_schema.PROCESSLIST')->{'COUNT(*)'}, 16 | $connection instanceof PostgresConnection => (int) $connection->selectOne('select count(*) as connections from pg_stat_activity')->connections, 17 | default => throw DatabaseNotSupported::make($connection), 18 | }; 19 | } 20 | 21 | public function tableSizeInMb(ConnectionInterface $connection, string $table): float 22 | { 23 | $sizeInBytes = match (true) { 24 | $connection instanceof MySqlConnection => $this->getMySQLTableSize($connection, $table), 25 | $connection instanceof PostgresConnection => $this->getPostgresTableSize($connection, $table), 26 | default => throw DatabaseNotSupported::make($connection), 27 | }; 28 | 29 | return $sizeInBytes / 1024 / 1024; 30 | } 31 | 32 | public function databaseSizeInMb(ConnectionInterface $connection): float 33 | { 34 | return match (true) { 35 | $connection instanceof MySqlConnection => $this->getMySQlDatabaseSize($connection), 36 | $connection instanceof PostgresConnection => $this->getPostgresDatabaseSize($connection), 37 | default => throw DatabaseNotSupported::make($connection), 38 | }; 39 | } 40 | 41 | protected function getMySQLTableSize(ConnectionInterface $connection, string $table): int 42 | { 43 | return $connection->selectOne('SELECT (data_length + index_length) AS size FROM information_schema.TABLES WHERE table_schema = ? AND table_name = ?', [ 44 | $connection->getDatabaseName(), 45 | $table, 46 | ])->size; 47 | } 48 | 49 | protected function getPostgresTableSize(ConnectionInterface $connection, string $table): int 50 | { 51 | return $connection->selectOne('SELECT pg_total_relation_size(?) AS size;', [ 52 | $table, 53 | ])->size; 54 | } 55 | 56 | protected function getMySQLDatabaseSize(ConnectionInterface $connection): int 57 | { 58 | return $connection->selectOne('SELECT size from (SELECT table_schema "name", ROUND(SUM(data_length + index_length) / 1024 / 1024) as size FROM information_schema.tables GROUP BY table_schema) alias_one where name = ?', [ 59 | $connection->getDatabaseName(), 60 | ])->size; 61 | } 62 | 63 | protected function getPostgresDatabaseSize(ConnectionInterface $connection): int 64 | { 65 | return $connection->selectOne('SELECT pg_database_size(?) / 1024 / 1024 AS size;', [ 66 | $connection->getDatabaseName(), 67 | ])->size; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "spatie/laravel-health", 3 | "description": "Monitor the health of a Laravel application", 4 | "keywords": [ 5 | "spatie", 6 | "laravel", 7 | "laravel-health" 8 | ], 9 | "homepage": "https://github.com/spatie/laravel-health", 10 | "license": "MIT", 11 | "authors": [ 12 | { 13 | "name": "Freek Van der Herten", 14 | "email": "freek@spatie.be", 15 | "role": "Developer" 16 | } 17 | ], 18 | "require": { 19 | "php": "^8.2", 20 | "dragonmantank/cron-expression": "^3.3.1", 21 | "guzzlehttp/guzzle": "^6.5|^7.4.5|^7.2", 22 | "illuminate/console": "^11.0|^12.0", 23 | "illuminate/contracts": "^11.0|^12.0", 24 | "illuminate/database": "^11.0|^12.0", 25 | "illuminate/notifications": "^11.0|^12.0", 26 | "illuminate/support": "^11.0|^12.0", 27 | "laravel/serializable-closure": "^1.3|^2.0", 28 | "nunomaduro/termwind": "^1.0|^2.0", 29 | "spatie/enum": "^3.13", 30 | "spatie/laravel-package-tools": "^1.12.1", 31 | "spatie/regex": "^3.1.1|^3.1", 32 | "spatie/temporary-directory": "^2.2", 33 | "symfony/process": "^5.4|^6.0|^7.0|^8.0" 34 | }, 35 | "require-dev": { 36 | "laravel/horizon": "^5.9.10", 37 | "laravel/slack-notification-channel": "^2.4|^3.2", 38 | "nunomaduro/collision": "^5.10|^6.2.1|^6.1|^8.0", 39 | "orchestra/testbench": "^9.0|^10.0", 40 | "pestphp/pest": "^2.34|^3.0|^4.0", 41 | "pestphp/pest-plugin-laravel": "^2.3|^3.0|^4.0", 42 | "phpstan/extension-installer": "^1.1", 43 | "phpstan/phpstan-deprecation-rules": "^1.0", 44 | "phpstan/phpstan-phpunit": "^1.1.1", 45 | "phpunit/phpunit": "^9.5.21|^9.5.10|^10.5|^11.0|^12.0", 46 | "spatie/laravel-ray": "^1.30", 47 | "spatie/pest-plugin-snapshots": "^1.1|^2.1|^3.0", 48 | "spatie/pest-plugin-test-time": "^1.1.1|^1.1|^2.0|^3.0", 49 | "spatie/test-time": "^1.3|^2.0" 50 | }, 51 | "autoload": { 52 | "psr-4": { 53 | "Spatie\\Health\\": "src", 54 | "Spatie\\Health\\Database\\Factories\\": "database/factories" 55 | } 56 | }, 57 | "autoload-dev": { 58 | "psr-4": { 59 | "Spatie\\Health\\Tests\\": "tests" 60 | } 61 | }, 62 | "scripts": { 63 | "analyse": "vendor/bin/phpstan analyse", 64 | "baseline": "vendor/bin/phpstan --generate-baseline", 65 | "test": "vendor/bin/pest", 66 | "test-coverage": "vendor/bin/pest coverage" 67 | }, 68 | "config": { 69 | "sort-packages": true, 70 | "allow-plugins": { 71 | "phpstan/extension-installer": true, 72 | "pestphp/pest-plugin": true 73 | } 74 | }, 75 | "extra": { 76 | "laravel": { 77 | "providers": [ 78 | "Spatie\\Health\\HealthServiceProvider" 79 | ], 80 | "aliases": { 81 | "Health": "Spatie\\Health\\Facades\\Health" 82 | } 83 | } 84 | }, 85 | "minimum-stability": "dev", 86 | "prefer-stable": true 87 | } 88 | -------------------------------------------------------------------------------- /src/Checks/Checks/FlareErrorOccurrenceCountCheck.php: -------------------------------------------------------------------------------- 1 | warningThreshold = $warningThreshold; 25 | 26 | return $this; 27 | } 28 | 29 | public function failWhenMoreErrorsReceivedThan(int $errorThreshold): self 30 | { 31 | $this->errorThreshold = $errorThreshold; 32 | 33 | return $this; 34 | } 35 | 36 | public function periodInMinutes(int $periodInMinutes): self 37 | { 38 | $this->periodInMinutes = $periodInMinutes; 39 | 40 | return $this; 41 | } 42 | 43 | public function apiToken(string $apiToken): self 44 | { 45 | $this->flareApiToken = $apiToken; 46 | 47 | return $this; 48 | } 49 | 50 | public function projectId(int $projectId): self 51 | { 52 | $this->flareProjectId = $projectId; 53 | 54 | return $this; 55 | } 56 | 57 | public function run(): Result 58 | { 59 | $errorOccurrenceCount = $this->getFlareErrorOccurrenceCount(); 60 | 61 | $shortSummary = $errorOccurrenceCount.' '.Str::plural('error', $errorOccurrenceCount)." in past {$this->periodInMinutes} minutes"; 62 | 63 | $result = Result::make() 64 | ->ok() 65 | ->meta([ 66 | 'count' => $errorOccurrenceCount, 67 | ]) 68 | ->shortSummary($shortSummary); 69 | 70 | $message = "In the past {$this->periodInMinutes} minutes, {$errorOccurrenceCount} errors occurred."; 71 | 72 | if ($errorOccurrenceCount > $this->errorThreshold) { 73 | return $result->failed($message); 74 | } 75 | 76 | if ($errorOccurrenceCount > $this->warningThreshold) { 77 | return $result->warning($message); 78 | } 79 | 80 | return $result; 81 | } 82 | 83 | protected function getFlareErrorOccurrenceCount(): int 84 | { 85 | $startDate = now()->subMinutes($this->periodInMinutes)->utc()->format('Y-m-d H:i:s'); 86 | $endDate = now()->utc()->format('Y-m-d H:i:s'); 87 | 88 | return Http::acceptJson() 89 | ->get("https://flareapp.io/api/projects/{$this->flareProjectId}/error-occurrence-count", [ 90 | 'start_date' => $startDate, 91 | 'end_date' => $endDate, 92 | 'api_token' => $this->flareApiToken, 93 | ])->json('count', 0); 94 | } 95 | 96 | public function getLabel(): string 97 | { 98 | if ($this->label) { 99 | return $this->label; 100 | } 101 | 102 | return 'Flare error count'; 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/Checks/Checks/QueueCheck.php: -------------------------------------------------------------------------------- 1 | cacheStoreName = $cacheStoreName; 24 | 25 | return $this; 26 | } 27 | 28 | public function getCacheStoreName(): string 29 | { 30 | return $this->cacheStoreName ?? config('cache.default'); 31 | } 32 | 33 | public function cacheKey(string $cacheKey): self 34 | { 35 | $this->cacheKey = $cacheKey; 36 | 37 | return $this; 38 | } 39 | 40 | public function failWhenHealthJobTakesLongerThanMinutes(int $minutes): self 41 | { 42 | $this->failWhenTestJobTakesLongerThanMinutes = $minutes; 43 | 44 | return $this; 45 | } 46 | 47 | public function getCacheKey(string $queue): string 48 | { 49 | return "{$this->cacheKey}.{$queue}"; 50 | } 51 | 52 | public function onQueue(array|string $queue): self 53 | { 54 | $this->onQueues = array_unique(Arr::wrap($queue)); 55 | 56 | return $this; 57 | } 58 | 59 | public function getQueues(): array 60 | { 61 | return $this->onQueues ?? [$this->getDefaultQueue(config('queue.default'))]; 62 | } 63 | 64 | protected function getDefaultQueue($connection) 65 | { 66 | return config("queue.connections.{$connection}.queue", 'default'); 67 | } 68 | 69 | public function run(): Result 70 | { 71 | $fails = []; 72 | 73 | foreach ($this->getQueues() as $queue) { 74 | $lastHeartbeatTimestamp = cache()->store($this->cacheStoreName)->get($this->getCacheKey($queue)); 75 | 76 | if (! $lastHeartbeatTimestamp) { 77 | $fails[] = "The `{$queue}` queue did not run yet."; 78 | 79 | continue; 80 | } 81 | 82 | $latestHeartbeatAt = Carbon::createFromTimestamp($lastHeartbeatTimestamp); 83 | 84 | $carbonVersion = InstalledVersions::getVersion('nesbot/carbon'); 85 | 86 | $minutesAgo = $latestHeartbeatAt->diffInMinutes(); 87 | 88 | if (version_compare($carbonVersion, 89 | '3.0.0', '<')) { 90 | $minutesAgo += 1; 91 | } 92 | 93 | if ($minutesAgo > $this->failWhenTestJobTakesLongerThanMinutes) { 94 | $fails[] = "The last run of the `{$queue}` queue was more than {$minutesAgo} minutes ago."; 95 | } 96 | } 97 | 98 | $result = Result::make(); 99 | 100 | if (! empty($fails)) { 101 | $result->meta($fails); 102 | 103 | return $result->failed('Queue jobs running failed. Check meta for more information.'); 104 | } 105 | 106 | return $result->ok(); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/ResultStores/EloquentHealthResultStore.php: -------------------------------------------------------------------------------- 1 | $checkResults */ 38 | public function save(Collection $checkResults): void 39 | { 40 | $batch = Str::uuid(); 41 | $checkResults->each(function (Result $result) use ($batch) { 42 | (static::determineHistoryItemModel())::create([ 43 | 'check_name' => $result->check->getName(), 44 | 'check_label' => $result->check->getLabel(), 45 | 'status' => $result->status, 46 | 'notification_message' => $result->getNotificationMessage(), 47 | 'short_summary' => $result->getShortSummary(), 48 | 'meta' => $result->meta, 49 | 'batch' => $batch, 50 | 'ended_at' => $result->ended_at, 51 | ]); 52 | }); 53 | } 54 | 55 | public function latestResults(): ?StoredCheckResults 56 | { 57 | if (! $latestItem = (static::determineHistoryItemModel())::latest()->first()) { 58 | return null; 59 | } 60 | 61 | /** @var Collection $storedCheckResults */ 62 | $storedCheckResults = (static::determineHistoryItemModel())::query() 63 | ->where('batch', $latestItem->batch) 64 | ->get() 65 | ->map(function (HealthCheckResultHistoryItem $historyItem) { 66 | return new StoredCheckResult( 67 | name: $historyItem->check_name, 68 | label: $historyItem->check_label, 69 | notificationMessage: $historyItem->notification_message, 70 | shortSummary: $historyItem->short_summary, 71 | status: $historyItem->status, 72 | meta: $historyItem->meta, 73 | ); 74 | }); 75 | 76 | return new StoredCheckResults( 77 | finishedAt: $latestItem->created_at, 78 | checkResults: $storedCheckResults, 79 | ); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/ResultStores/StoredCheckResults/StoredCheckResults.php: -------------------------------------------------------------------------------- 1 | */ 15 | public Collection $storedCheckResults; 16 | 17 | private array $okStatuses; 18 | 19 | public static function fromJson(string $json): StoredCheckResults 20 | { 21 | $properties = json_decode($json, true); 22 | 23 | $checkResults = collect($properties['checkResults']) 24 | ->map(fn (array $lineProperties) => new StoredCheckResult( 25 | $lineProperties['name'], 26 | $lineProperties['label'] ?? '', 27 | $lineProperties['notificationMessage'] ?? '', 28 | $lineProperties['shortSummary'] ?? '', 29 | $lineProperties['status'] ?? '', 30 | $lineProperties['meta'] ?? [], 31 | )) 32 | ->unique('name') 33 | ->sortBy(fn (StoredCheckResult $result) => strtolower($result->label)); 34 | 35 | return new self( 36 | finishedAt: (new DateTime)->setTimestamp($properties['finishedAt']), 37 | checkResults: $checkResults, 38 | ); 39 | } 40 | 41 | /** 42 | * @param ?Collection $checkResults 43 | */ 44 | public function __construct( 45 | ?DateTimeInterface $finishedAt = null, 46 | ?Collection $checkResults = null 47 | ) { 48 | $this->finishedAt = $finishedAt ?? new DateTime; 49 | 50 | $this->storedCheckResults = $checkResults ?? collect(); 51 | 52 | $treatSkippedAsFailure = config('health.treat_skipped_as_failure', true); 53 | 54 | $this->okStatuses = $treatSkippedAsFailure 55 | ? [Status::ok()->value] 56 | : [Status::ok()->value, Status::skipped()->value]; 57 | } 58 | 59 | public function addCheck(StoredCheckResult $line): self 60 | { 61 | $this->storedCheckResults[] = $line; 62 | 63 | return $this; 64 | } 65 | 66 | public function allChecksOk(): bool 67 | { 68 | return ! $this->containsFailingCheck(); 69 | } 70 | 71 | public function containsFailingCheck(): bool 72 | { 73 | return $this->storedCheckResults->contains( 74 | fn (StoredCheckResult $line) => ! in_array($line->status, $this->okStatuses) 75 | ); 76 | } 77 | 78 | /** 79 | * @param array|Status $statuses 80 | */ 81 | public function containsCheckWithStatus(array|Status $statuses): bool 82 | { 83 | if ($statuses instanceof Status) { 84 | $statuses = [$statuses]; 85 | } 86 | 87 | return $this->storedCheckResults->contains( 88 | fn (StoredCheckResult $line) => in_array($line->status, $statuses) 89 | ); 90 | } 91 | 92 | public function toJson(): string 93 | { 94 | return (string) json_encode([ 95 | 'finishedAt' => $this->finishedAt->getTimestamp(), 96 | 'checkResults' => $this->storedCheckResults->map(fn (StoredCheckResult $line) => $line->toArray()), 97 | ]); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/HealthServiceProvider.php: -------------------------------------------------------------------------------- 1 | name('laravel-health') 28 | ->hasConfigFile() 29 | ->hasViews() 30 | ->hasViewComponents('health', Logo::class) 31 | ->hasViewComponents('health', StatusIndicator::class) 32 | ->hasTranslations() 33 | ->hasMigration('create_health_tables') 34 | ->hasCommands( 35 | ListHealthChecksCommand::class, 36 | RunHealthChecksCommand::class, 37 | ScheduleCheckHeartbeatCommand::class, 38 | DispatchQueueCheckJobsCommand::class, 39 | PauseHealthChecksCommand::class, 40 | ResumeHealthChecksCommand::class, 41 | ); 42 | } 43 | 44 | public function packageRegistered(): void 45 | { 46 | $this->app->singleton(Health::class); 47 | $this->app->alias(Health::class, 'health'); 48 | 49 | $this->app->bind(ResultStore::class, fn () => ResultStores::createFromConfig()->first()); 50 | } 51 | 52 | public function packageBooted(): void 53 | { 54 | $this->app->make(Health::class)->inlineStylesheet(file_get_contents(__DIR__.'/../resources/dist/health.min.css')); 55 | 56 | $this 57 | ->registerOhDearEndpoint() 58 | ->silenceHealthQueueJob(); 59 | } 60 | 61 | protected function registerOhDearEndpoint(): self 62 | { 63 | if (! config('health.oh_dear_endpoint.enabled')) { 64 | return $this; 65 | } 66 | 67 | if (! config('health.oh_dear_endpoint.secret')) { 68 | return $this; 69 | } 70 | 71 | if (! config('health.oh_dear_endpoint.url')) { 72 | return $this; 73 | } 74 | 75 | Route::get(config('health.oh_dear_endpoint.url'), HealthCheckJsonResultsController::class) 76 | ->middleware(RequiresSecret::class); 77 | 78 | return $this; 79 | } 80 | 81 | protected function silenceHealthQueueJob(): self 82 | { 83 | if (! config('health.silence_health_queue_job', true)) { 84 | return $this; 85 | } 86 | 87 | $silencedJobs = config('horizon.silenced', []); 88 | 89 | if (in_array(HealthQueueJob::class, $silencedJobs)) { 90 | return $this; 91 | } 92 | 93 | $silencedJobs[] = HealthQueueJob::class; 94 | 95 | config()->set('horizon.silenced', $silencedJobs); 96 | 97 | return $this; 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | Logo for laravel-health 6 | 7 | 8 | 9 |

Check the health of your Laravel app

10 | 11 | [![Latest Version on Packagist](https://img.shields.io/packagist/v/spatie/laravel-health.svg?style=flat-square)](https://packagist.org/packages/spatie/laravel-health) 12 | [![run-tests](https://github.com/spatie/laravel-health/actions/workflows/run-tests.yml/badge.svg)](https://github.com/spatie/laravel-health/actions/workflows/run-tests.yml) 13 | [![Check & fix styling](https://github.com/spatie/laravel-health/actions/workflows/pint.yml/badge.svg)](https://github.com/spatie/laravel-health/actions/workflows/pint.yml) 14 | [![Total Downloads](https://img.shields.io/packagist/dt/spatie/laravel-health.svg?style=flat-square)](https://packagist.org/packages/spatie/laravel-health) 15 | 16 |
17 | 18 | Using this package you can monitor the health of your application by registering checks. 19 | 20 | Here's an example where we'll monitor available disk space. 21 | 22 | ```php 23 | // typically, in a service provider 24 | 25 | use Spatie\Health\Facades\Health; 26 | use Spatie\Health\Checks\Checks\UsedDiskSpaceCheck; 27 | 28 | Health::checks([ 29 | UsedDiskSpaceCheck::new() 30 | ->warnWhenUsedSpaceIsAbovePercentage(70) 31 | ->failWhenUsedSpaceIsAbovePercentage(90), 32 | ]); 33 | ``` 34 | 35 | When the used disk space is over 70%, then a notification with a warning will be sent. If it's above 90%, you'll get an error notification. Out of the box, the package can notify you via mail and Slack. 36 | 37 | ## Support us 38 | 39 | [](https://spatie.be/github-ad-click/laravel-health) 40 | 41 | We invest a lot of resources into creating [best in class open source packages](https://spatie.be/open-source). You can support us by [buying one of our paid products](https://spatie.be/open-source/support-us). 42 | 43 | We highly appreciate you sending us a postcard from your hometown, mentioning which of our package(s) you are using. You'll find our address on [our contact page](https://spatie.be/about-us). We publish all received postcards on [our virtual postcard wall](https://spatie.be/open-source/postcards). 44 | 45 | ## Documentation 46 | 47 | All documentation is available [on our documentation site](https://spatie.be/docs/laravel-health). 48 | 49 | ## Alternatives 50 | 51 | If you don't like our package, do try out one of these alternatives: 52 | 53 | - [ans-group/laravel-health-check](https://github.com/ans-group/laravel-health-check) 54 | - [Antonioribeiro/health](https://github.com/antonioribeiro/health) 55 | 56 | ## Testing 57 | 58 | ```bash 59 | composer test 60 | ``` 61 | 62 | ## Changelog 63 | 64 | Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. 65 | 66 | ## Contributing 67 | 68 | Please see [CONTRIBUTING](https://github.com/spatie/.github/blob/main/CONTRIBUTING.md) for details. 69 | 70 | ## Security Vulnerabilities 71 | 72 | Please review [our security policy](../../security/policy) on how to report security vulnerabilities. 73 | 74 | ## Credits 75 | 76 | - [Freek Van der Herten](https://github.com/freekmurze) 77 | - [All Contributors](../../contributors) 78 | 79 | ## License 80 | 81 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 82 | -------------------------------------------------------------------------------- /src/Notifications/CheckFailedNotification.php: -------------------------------------------------------------------------------- 1 | $results */ 16 | public function __construct(public array $results) {} 17 | 18 | /** @return array */ 19 | public function via(): array 20 | { 21 | /** @var array $notificationChannels */ 22 | $notificationChannels = config('health.notifications.notifications.'.static::class); 23 | 24 | return array_filter($notificationChannels); 25 | } 26 | 27 | public function shouldSend(Notifiable $notifiable, string $channel): bool 28 | { 29 | if (! config('health.notifications.enabled')) { 30 | return false; 31 | } 32 | 33 | /** @var int $throttleMinutes */ 34 | $throttleMinutes = config('health.notifications.throttle_notifications_for_minutes'); 35 | 36 | if ($throttleMinutes === 0) { 37 | return true; 38 | } 39 | 40 | $cacheKey = config('health.notifications.throttle_notifications_key', 'health:latestNotificationSentAt:').$channel; 41 | 42 | /** @var \Illuminate\Cache\CacheManager $cache */ 43 | $cache = app('cache'); 44 | 45 | /** @var string $timestamp */ 46 | $timestamp = $cache->get($cacheKey); 47 | 48 | if (! $timestamp) { 49 | $cache->set($cacheKey, now()->timestamp); 50 | 51 | return true; 52 | } 53 | 54 | if (Carbon::createFromTimestamp($timestamp)->addMinutes($throttleMinutes)->isFuture()) { 55 | return false; 56 | } 57 | 58 | $cache->set($cacheKey, now()->timestamp); 59 | 60 | return true; 61 | } 62 | 63 | public function toMail(): MailMessage 64 | { 65 | return (new MailMessage) 66 | ->from(config('health.notifications.mail.from.address', config('mail.from.address')), config('health.notifications.mail.from.name', config('mail.from.name'))) 67 | ->subject(trans('health::notifications.check_failed_mail_subject', $this->transParameters())) 68 | ->markdown('health::mail.checkFailedNotification', ['results' => $this->results]); 69 | } 70 | 71 | public function toSlack(): SlackMessage 72 | { 73 | $slackMessage = (new SlackMessage) 74 | ->error() 75 | ->from(config('health.notifications.slack.username'), config('health.notifications.slack.icon')) 76 | ->to(config('health.notifications.slack.channel')) 77 | ->content(trans('health::notifications.check_failed_slack_message', $this->transParameters())); 78 | 79 | foreach ($this->results as $result) { 80 | $slackMessage->attachment(function (SlackAttachment $attachment) use ($result) { 81 | $attachment 82 | ->color(Status::from($result->status)->getSlackColor()) 83 | ->title($result->check->getLabel()) 84 | ->content($result->getNotificationMessage()); 85 | }); 86 | } 87 | 88 | return $slackMessage; 89 | } 90 | 91 | /** 92 | * @return array 93 | */ 94 | public function transParameters(): array 95 | { 96 | return [ 97 | 'application_name' => config('app.name') ?? config('app.url') ?? 'Laravel application', 98 | 'env_name' => app()->environment(), 99 | ]; 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/Checks/Check.php: -------------------------------------------------------------------------------- 1 | 33 | */ 34 | protected array $shouldRun = []; 35 | 36 | public function __construct() {} 37 | 38 | public static function new(): static 39 | { 40 | $instance = app(static::class); 41 | 42 | $instance->everyMinute(); 43 | 44 | return $instance; 45 | } 46 | 47 | public function name(string $name): static 48 | { 49 | $this->name = $name; 50 | 51 | return $this; 52 | } 53 | 54 | public function label(string $label): static 55 | { 56 | $this->label = $label; 57 | 58 | return $this; 59 | } 60 | 61 | public function getLabel(): string 62 | { 63 | if ($this->label) { 64 | return $this->label; 65 | } 66 | 67 | $name = $this->getName(); 68 | 69 | return Str::of($name)->snake()->replace('_', ' ')->title(); 70 | } 71 | 72 | public function getName(): string 73 | { 74 | if ($this->name) { 75 | return $this->name; 76 | } 77 | 78 | $baseName = class_basename(static::class); 79 | 80 | return Str::of($baseName)->beforeLast('Check'); 81 | } 82 | 83 | public function getRunConditions(): array 84 | { 85 | return $this->shouldRun; 86 | } 87 | 88 | public function shouldRun(): bool 89 | { 90 | foreach ($this->shouldRun as $shouldRun) { 91 | $shouldRun = is_callable($shouldRun) ? $shouldRun() : $shouldRun; 92 | 93 | if (! $shouldRun) { 94 | return false; 95 | } 96 | } 97 | 98 | $date = Date::now($this->timezone); 99 | 100 | return (new CronExpression($this->expression))->isDue($date->toDateTimeString()); 101 | } 102 | 103 | public function if(bool|callable $condition) 104 | { 105 | $this->shouldRun[] = $condition; 106 | 107 | return $this; 108 | } 109 | 110 | public function unless(bool|callable $condition) 111 | { 112 | $this->shouldRun[] = is_callable($condition) ? 113 | fn () => ! $condition() : 114 | ! $condition; 115 | 116 | return $this; 117 | } 118 | 119 | abstract public function run(): Result; 120 | 121 | public function markAsCrashed(): Result 122 | { 123 | return new Result(Status::crashed()); 124 | } 125 | 126 | public function onTerminate(mixed $request, mixed $response): void {} 127 | 128 | public function __serialize(): array 129 | { 130 | $vars = get_object_vars($this); 131 | 132 | $serializedShouldRun = []; 133 | foreach ($vars['shouldRun'] as $shouldRun) { 134 | if ($shouldRun instanceof \Closure) { 135 | $serializedShouldRun[] = new SerializableClosure($shouldRun); 136 | } else { 137 | $serializedShouldRun[] = $shouldRun; 138 | } 139 | } 140 | 141 | $vars['shouldRun'] = $serializedShouldRun; 142 | 143 | return $vars; 144 | } 145 | 146 | public function __unserialize(array $data): void 147 | { 148 | foreach ($data as $property => $value) { 149 | $this->$property = $value; 150 | } 151 | 152 | $deserializedShouldRun = []; 153 | 154 | foreach ($this->shouldRun as $shouldRun) { 155 | if ($shouldRun instanceof SerializableClosure) { 156 | $deserializedShouldRun[] = $shouldRun->getClosure(); 157 | } else { 158 | $deserializedShouldRun[] = $shouldRun; 159 | } 160 | } 161 | 162 | $this->shouldRun = $deserializedShouldRun; 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /config/health.php: -------------------------------------------------------------------------------- 1 | [ 10 | Spatie\Health\ResultStores\EloquentHealthResultStore::class => [ 11 | 'connection' => env('HEALTH_DB_CONNECTION', env('DB_CONNECTION')), 12 | 'model' => Spatie\Health\Models\HealthCheckResultHistoryItem::class, 13 | 'keep_history_for_days' => 5, 14 | ], 15 | 16 | /* 17 | Spatie\Health\ResultStores\CacheHealthResultStore::class => [ 18 | 'store' => 'file', 19 | ], 20 | 21 | Spatie\Health\ResultStores\JsonFileHealthResultStore::class => [ 22 | 'disk' => 's3', 23 | 'path' => 'health.json', 24 | ], 25 | 26 | Spatie\Health\ResultStores\InMemoryHealthResultStore::class, 27 | */ 28 | ], 29 | 30 | /* 31 | * You can get notified when specific events occur. Out of the box you can use 'mail' and 'slack'. 32 | * For Slack you need to install laravel/slack-notification-channel. 33 | */ 34 | 'notifications' => [ 35 | /* 36 | * Notifications will only get sent if this option is set to `true`. 37 | */ 38 | 'enabled' => true, 39 | 40 | 'notifications' => [ 41 | Spatie\Health\Notifications\CheckFailedNotification::class => ['mail'], 42 | ], 43 | 44 | /* 45 | * Here you can specify the notifiable to which the notifications should be sent. The default 46 | * notifiable will use the variables specified in this config file. 47 | */ 48 | 'notifiable' => Spatie\Health\Notifications\Notifiable::class, 49 | 50 | /* 51 | * When checks start failing, you could potentially end up getting 52 | * a notification every minute. 53 | * 54 | * With this setting, notifications are throttled. By default, you'll 55 | * only get one notification per hour. 56 | */ 57 | 'throttle_notifications_for_minutes' => 60, 58 | 'throttle_notifications_key' => 'health:latestNotificationSentAt:', 59 | 60 | 'mail' => [ 61 | 'to' => 'your@example.com', 62 | 63 | 'from' => [ 64 | 'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'), 65 | 'name' => env('MAIL_FROM_NAME', 'Example'), 66 | ], 67 | ], 68 | 69 | 'slack' => [ 70 | 'webhook_url' => env('HEALTH_SLACK_WEBHOOK_URL', ''), 71 | 72 | /* 73 | * If this is set to null the default channel of the webhook will be used. 74 | */ 75 | 'channel' => null, 76 | 77 | 'username' => null, 78 | 79 | 'icon' => null, 80 | ], 81 | ], 82 | 83 | /* 84 | * You can let Oh Dear monitor the results of all health checks. This way, you'll 85 | * get notified of any problems even if your application goes totally down. Via 86 | * Oh Dear, you can also have access to more advanced notification options. 87 | */ 88 | 'oh_dear_endpoint' => [ 89 | 'enabled' => false, 90 | 91 | /* 92 | * When this option is enabled, the checks will run before sending a response. 93 | * Otherwise, we'll send the results from the last time the checks have run. 94 | */ 95 | 'always_send_fresh_results' => true, 96 | 97 | /* 98 | * The secret that is displayed at the Application Health settings at Oh Dear. 99 | */ 100 | 'secret' => env('OH_DEAR_HEALTH_CHECK_SECRET'), 101 | 102 | /* 103 | * The URL that should be configured in the Application health settings at Oh Dear. 104 | */ 105 | 'url' => '/oh-dear-health-check-results', 106 | ], 107 | 108 | /* 109 | * You can specify a heartbeat URL for the Horizon check. 110 | * This URL will be pinged if the Horizon check is successful. 111 | * This way you can get notified if Horizon goes down. 112 | */ 113 | 'horizon' => [ 114 | 'heartbeat_url' => env('HORIZON_HEARTBEAT_URL'), 115 | ], 116 | 117 | /* 118 | * You can specify a heartbeat URL for the Schedule check. 119 | * This URL will be pinged if the Schedule check is successful. 120 | * This way you can get notified if the schedule fails to run. 121 | */ 122 | 'schedule' => [ 123 | 'heartbeat_url' => env('SCHEDULE_HEARTBEAT_URL'), 124 | ], 125 | 126 | /* 127 | * You can set a theme for the local results page 128 | * 129 | * - light: light mode 130 | * - dark: dark mode 131 | */ 132 | 'theme' => 'light', 133 | 134 | /* 135 | * When enabled, completed `HealthQueueJob`s will be displayed 136 | * in Horizon's silenced jobs screen. 137 | */ 138 | 'silence_health_queue_job' => true, 139 | 140 | /* 141 | * The response code to use for HealthCheckJsonResultsController when a health 142 | * check has failed 143 | */ 144 | 'json_results_failure_status' => 200, 145 | 146 | /* 147 | * You can specify a secret token that needs to be sent in the X-Secret-Token for secured access. 148 | */ 149 | 'secret_token' => env('HEALTH_SECRET_TOKEN'), 150 | 151 | /** 152 | * By default, conditionally skipped health checks are treated as failures. 153 | * You can override this behavior by uncommenting the configuration below. 154 | * 155 | * @link https://spatie.be/docs/laravel-health/v1/basic-usage/conditionally-running-or-modifying-checks 156 | */ 157 | // 'treat_skipped_as_failure' => false 158 | ]; 159 | -------------------------------------------------------------------------------- /src/Commands/RunHealthChecksCommand.php: -------------------------------------------------------------------------------- 1 | */ 25 | protected array $thrownExceptions = []; 26 | 27 | public function handle(): int 28 | { 29 | if (Cache::get(PauseHealthChecksCommand::CACHE_KEY)) { 30 | $this->info('Checks paused'); 31 | 32 | return self::SUCCESS; 33 | } 34 | 35 | $this->info('Running checks...'); 36 | 37 | $results = $this->runChecks(); 38 | 39 | if (! $this->option('no-notification') && config('health.notifications.enabled', false)) { 40 | $this->sendNotification($results); 41 | } 42 | 43 | if (! $this->option('do-not-store-results')) { 44 | $this->storeResults($results); 45 | } 46 | 47 | $this->line(''); 48 | $this->info('All done!'); 49 | 50 | return $this->determineCommandResult($results); 51 | } 52 | 53 | public function runCheck(Check $check): Result 54 | { 55 | event(new CheckStartingEvent($check)); 56 | 57 | try { 58 | $this->line(''); 59 | $this->line("Running check: {$check->getLabel()}..."); 60 | $result = $check->run(); 61 | } catch (Exception $exception) { 62 | $exception = CheckDidNotComplete::make($check, $exception); 63 | report($exception); 64 | 65 | $this->thrownExceptions[] = $exception; 66 | 67 | $result = $check->markAsCrashed(); 68 | } 69 | 70 | $result 71 | ->check($check) 72 | ->endedAt(now()); 73 | 74 | $this->outputResult($result, $exception ?? null); 75 | 76 | event(new CheckEndedEvent($check, $result)); 77 | 78 | return $result; 79 | } 80 | 81 | /** @return Collection */ 82 | protected function runChecks(): Collection 83 | { 84 | [$shouldRun, $skipped] = $this->splitChecksByShouldRun( 85 | app(Health::class)->registeredChecks() 86 | ); 87 | 88 | // retain original order 89 | return $skipped->mapWithKeys(fn (Check $check, $i) => [ 90 | $i => (new Result(Status::skipped()))->check($check)->endedAt(now()), 91 | ])->union( 92 | $shouldRun->mapWithKeys(fn (Check $check, $i) => [ 93 | $i => $this->runCheck($check), 94 | ]) 95 | )->sortKeys(); 96 | } 97 | 98 | /** @return array{Collection, Collection} */ 99 | protected function splitChecksByShouldRun(Collection $checks): Collection 100 | { 101 | return $checks->partition(fn (Check $check) => $check->shouldRun()); 102 | } 103 | 104 | /** @param Collection $results */ 105 | protected function storeResults(Collection $results): self 106 | { 107 | app(Health::class) 108 | ->resultStores() 109 | ->each(fn (ResultStore $store) => $store->save($results)); 110 | 111 | return $this; 112 | } 113 | 114 | protected function sendNotification(Collection $results): self 115 | { 116 | $resultsWithMessages = $results->filter(fn (Result $result) => ! empty($result->getNotificationMessage())); 117 | 118 | if ($resultsWithMessages->count() === 0) { 119 | return $this; 120 | } 121 | 122 | $notifiableClass = config('health.notifications.notifiable'); 123 | 124 | /** @var \Spatie\Health\Notifications\Notifiable $notifiable */ 125 | $notifiable = app($notifiableClass); 126 | 127 | /** @var array $results */ 128 | $results = $resultsWithMessages->toArray(); 129 | 130 | $failedNotificationClass = $this->getFailedNotificationClass(); 131 | 132 | $notification = (new $failedNotificationClass($results)); 133 | 134 | $notifiable->notify($notification); 135 | 136 | return $this; 137 | } 138 | 139 | protected function outputResult(Result $result, ?Exception $exception = null): void 140 | { 141 | $status = ucfirst((string) $result->status->value); 142 | 143 | $okMessage = $status; 144 | 145 | if (! empty($result->shortSummary)) { 146 | $okMessage .= ": {$result->shortSummary}"; 147 | } 148 | 149 | match ($result->status) { 150 | Status::ok() => $this->info($okMessage), 151 | Status::warning() => $this->comment("{$status}: {$result->getNotificationMessage()}"), 152 | Status::failed() => $this->error("{$status}: {$result->getNotificationMessage()}"), 153 | Status::crashed() => $this->error("{$status}: `{$exception?->getMessage()}`"), 154 | default => null, 155 | }; 156 | } 157 | 158 | protected function determineCommandResult(Collection $results): int 159 | { 160 | if (! $this->option('fail-command-on-failing-check')) { 161 | return self::SUCCESS; 162 | } 163 | 164 | if (count($this->thrownExceptions)) { 165 | return self::FAILURE; 166 | } 167 | 168 | $containsFailingCheck = $results->contains(function (Result $result) { 169 | return in_array($result->status, [ 170 | Status::crashed(), 171 | Status::failed(), 172 | Status::warning(), 173 | ]); 174 | }); 175 | 176 | return $containsFailingCheck 177 | ? self::FAILURE 178 | : self::SUCCESS; 179 | } 180 | 181 | /** 182 | * @return class-string 183 | */ 184 | protected function getFailedNotificationClass(): string 185 | { 186 | return array_key_first(config('health.notifications.notifications')); 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /src/Checks/Checks/BackupsCheck.php: -------------------------------------------------------------------------------- 1 | locatedAt = $globPath; 38 | 39 | return $this; 40 | } 41 | 42 | public function onDisk(string $disk): static 43 | { 44 | $this->disk = Storage::disk($disk); 45 | 46 | return $this; 47 | } 48 | 49 | public function parseModifiedFormat(string $parseModifiedFormat = 'Y-m-d_H-i-s'): self 50 | { 51 | $this->parseModifiedUsing = $parseModifiedFormat; 52 | 53 | return $this; 54 | } 55 | 56 | public function youngestBackShouldHaveBeenMadeBefore(CarbonInterface $date): self 57 | { 58 | $this->youngestShouldHaveBeenMadeBefore = $date; 59 | 60 | return $this; 61 | } 62 | 63 | public function oldestBackShouldHaveBeenMadeAfter(CarbonInterface $date): self 64 | { 65 | $this->oldestShouldHaveBeenMadeAfter = $date; 66 | 67 | return $this; 68 | } 69 | 70 | public function atLeastSizeInMb(int $minimumSizeInMegabytes, bool $onlyCheckFirstAndLast = false): self 71 | { 72 | $this->minimumSizeInMegabytes = $minimumSizeInMegabytes; 73 | $this->onlyCheckSizeOnFirstAndLast = $onlyCheckFirstAndLast; 74 | 75 | return $this; 76 | } 77 | 78 | public function onlyCheckSizeOnFirstAndLast(bool $onlyCheckSizeOnFirstAndLast = true): self 79 | { 80 | $this->onlyCheckSizeOnFirstAndLast = $onlyCheckSizeOnFirstAndLast; 81 | 82 | return $this; 83 | } 84 | 85 | public function numberOfBackups(?int $min = null, ?int $max = null): self 86 | { 87 | $this->minimumNumberOfBackups = $min; 88 | $this->maximumNumberOfBackups = $max; 89 | 90 | return $this; 91 | } 92 | 93 | public function run(): Result 94 | { 95 | $eligibleBackups = $this->getBackupFiles(); 96 | 97 | $backupCount = $eligibleBackups->count(); 98 | 99 | $result = Result::make()->meta([ 100 | 'minimum_size' => $this->minimumSizeInMegabytes.'MB', 101 | 'backup_count' => $backupCount, 102 | ]); 103 | 104 | if ($backupCount === 0) { 105 | return $result->failed('No backups found'); 106 | } 107 | 108 | if ($this->minimumNumberOfBackups && $backupCount < $this->minimumNumberOfBackups) { 109 | return $result->failed('Not enough backups found'); 110 | } 111 | 112 | if ($this->maximumNumberOfBackups && $backupCount > $this->maximumNumberOfBackups) { 113 | return $result->failed('Too many backups found'); 114 | } 115 | 116 | $youngestBackup = $this->getYoungestBackup($eligibleBackups); 117 | $oldestBackup = $this->getOldestBackup($eligibleBackups); 118 | 119 | $result->appendMeta([ 120 | 'youngest_backup' => $youngestBackup ? Carbon::createFromTimestamp($youngestBackup->lastModified())->toDateTimeString() : null, 121 | 'oldest_backup' => $oldestBackup ? Carbon::createFromTimestamp($oldestBackup->lastModified())->toDateTimeString() : null, 122 | ]); 123 | 124 | if ($this->youngestBackupIsTooOld($youngestBackup)) { 125 | return $result->failed('The youngest backup was too old'); 126 | } 127 | 128 | if ($this->oldestBackupIsTooYoung($oldestBackup)) { 129 | return $result->failed('The oldest backup was too young'); 130 | } 131 | 132 | $backupsToCheckSizeOn = $this->onlyCheckSizeOnFirstAndLast 133 | ? collect([$youngestBackup, $oldestBackup]) 134 | : $eligibleBackups; 135 | 136 | if ($backupsToCheckSizeOn->filter( 137 | fn (BackupFile $file) => $file->size() >= $this->minimumSizeInMegabytes * 1024 * 1024 138 | )->isEmpty()) { 139 | return $result->failed('Backups are not large enough'); 140 | } 141 | 142 | return $result->ok(); 143 | } 144 | 145 | protected function getBackupFiles(): Collection 146 | { 147 | return collect( 148 | $this->disk 149 | ? $this->disk->files($this->locatedAt) 150 | : File::glob($this->locatedAt ?? '') 151 | )->map(function (string $path) { 152 | return new BackupFile($path, $this->disk, $this->parseModifiedUsing); 153 | }); 154 | } 155 | 156 | protected function getYoungestBackup(Collection $backups): ?BackupFile 157 | { 158 | return $backups 159 | ->sortByDesc(fn (BackupFile $file) => $file->lastModified()) 160 | ->first(); 161 | } 162 | 163 | protected function youngestBackupIsTooOld(?BackupFile $youngestBackup): bool 164 | { 165 | if ($this->youngestShouldHaveBeenMadeBefore === null) { 166 | return false; 167 | } 168 | 169 | $threshold = $this->youngestShouldHaveBeenMadeBefore->getTimestamp(); 170 | 171 | return ! $youngestBackup || $youngestBackup->lastModified() <= $threshold; 172 | } 173 | 174 | protected function getOldestBackup(Collection $backups): ?BackupFile 175 | { 176 | return $backups 177 | ->sortBy(fn (BackupFile $file) => $file->lastModified()) 178 | ->first(); 179 | 180 | } 181 | 182 | protected function oldestBackupIsTooYoung(?BackupFile $oldestBackup): bool 183 | { 184 | if ($this->oldestShouldHaveBeenMadeAfter === null) { 185 | return false; 186 | } 187 | 188 | $threshold = $this->oldestShouldHaveBeenMadeAfter->getTimestamp(); 189 | 190 | return ! $oldestBackup || $oldestBackup->lastModified() >= $threshold; 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /resources/dist/health.min.css: -------------------------------------------------------------------------------- 1 | /*! tailwindcss v3.0.1 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid}:after,:before{--tw-content:""}html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr[title]{-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:initial}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;line-height:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button;background-color:initial;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:initial}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input:-ms-input-placeholder,textarea:-ms-input-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]{display:none}.transform{--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-transform:translateX(var(--tw-translate-x)) translateY(var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.dark\:border-t{--tw-border-opacity:1;border-color:rgb(229 231 235/var(--tw-border-opacity))}.dark\:shadow-md,.shadow-md{--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000}.filter{--tw-blur:var(--tw-empty,/*!*/ /*!*/);--tw-brightness:var(--tw-empty,/*!*/ /*!*/);--tw-contrast:var(--tw-empty,/*!*/ /*!*/);--tw-grayscale:var(--tw-empty,/*!*/ /*!*/);--tw-hue-rotate:var(--tw-empty,/*!*/ /*!*/);--tw-invert:var(--tw-empty,/*!*/ /*!*/);--tw-saturate:var(--tw-empty,/*!*/ /*!*/);--tw-sepia:var(--tw-empty,/*!*/ /*!*/);--tw-drop-shadow:var(--tw-empty,/*!*/ /*!*/);--tw-filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.static{position:static}.absolute{position:absolute}.relative{position:relative}.mx-2{margin-left:.5rem;margin-right:.5rem}.my-1{margin-top:.25rem;margin-bottom:.25rem}.mx-auto{margin-left:auto;margin-right:auto}.my-6{margin-top:1.5rem;margin-bottom:1.5rem}.mb-1{margin-bottom:.25rem}.ml-11{margin-left:2.75rem}.mt-7{margin-top:1.75rem}.-mt-1{margin-top:-.25rem}.mt-0{margin-top:0}.flex{display:flex}.table{display:table}.grid{display:grid}.h-3\.5{height:.875rem}.h-3{height:.75rem}.h-5{height:1.25rem}.w-full{width:100%}.w-1\/2{width:50%}.w-10{width:2.5rem}.w-3\.5{width:.875rem}.w-3{width:.75rem}.w-5{width:1.25rem}.max-w-7xl{max-width:80rem}.transform{transform:var(--tw-transform)}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-center{align-items:center}.justify-center{justify-content:center}.gap-2\.5{gap:.625rem}.gap-2{gap:.5rem}.space-x-1>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.25rem*var(--tw-space-x-reverse));margin-left:calc(.25rem*(1 - var(--tw-space-x-reverse)))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.75rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem*var(--tw-space-y-reverse))}.space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.5rem*var(--tw-space-x-reverse));margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)))}.overflow-hidden{overflow:hidden}.rounded-xl{border-radius:.75rem}.rounded-full{border-radius:9999px}.bg-blue-800{--tw-bg-opacity:1;background-color:rgb(30 64 175/var(--tw-bg-opacity))}.bg-gray-100{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity))}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity))}.p-0{padding:0}.py-1{padding-top:.25rem;padding-bottom:.25rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-4{padding-left:1rem;padding-right:1rem}.py-5{padding-top:1.25rem;padding-bottom:1.25rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.text-2xl{font-size:1.5rem;line-height:2rem}.text-sm{font-size:.875rem;line-height:1.25rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.uppercase{text-transform:uppercase}.text-green-600{--tw-text-opacity:1;color:rgb(22 163 74/var(--tw-text-opacity))}.text-yellow-600{--tw-text-opacity:1;color:rgb(202 138 4/var(--tw-text-opacity))}.text-blue-600{--tw-text-opacity:1;color:rgb(37 99 235/var(--tw-text-opacity))}.text-red-600{--tw-text-opacity:1;color:rgb(220 38 38/var(--tw-text-opacity))}.text-emerald-500{--tw-text-opacity:1;color:rgb(16 185 129/var(--tw-text-opacity))}.text-yellow-500{--tw-text-opacity:1;color:rgb(234 179 8/var(--tw-text-opacity))}.text-blue-500{--tw-text-opacity:1;color:rgb(59 130 246/var(--tw-text-opacity))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity))}.text-gray-900{--tw-text-opacity:1;color:rgb(17 24 39/var(--tw-text-opacity))}.text-red-400{--tw-text-opacity:1;color:rgb(248 113 113/var(--tw-text-opacity))}.text-gray-400{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity))}.text-gray-600{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity))}.text-opacity-0{--tw-text-opacity:0}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.shadow-md{--tw-shadow:0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -2px rgba(0,0,0,.1);--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color),0 2px 4px -2px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-gray-200{--tw-shadow-color:#e5e7eb;--tw-shadow:var(--tw-shadow-colored)}.filter{filter:var(--tw-filter)}.transition{transition-property:background-color,border-color,color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:background-color,border-color,color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:background-color,border-color,color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.dark .dark\:border-t{border-top-width:1px}.dark .dark\:border-gray-700{--tw-border-opacity:1;border-color:rgb(55 65 81/var(--tw-border-opacity))}.dark .dark\:bg-gray-900{--tw-bg-opacity:1;background-color:rgb(17 24 39/var(--tw-bg-opacity))}.dark .dark\:bg-gray-800{--tw-bg-opacity:1;background-color:rgb(31 41 55/var(--tw-bg-opacity))}.dark .dark\:text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity))}.dark .dark\:text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity))}.dark .dark\:text-gray-300{--tw-text-opacity:1;color:rgb(209 213 219/var(--tw-text-opacity))}.dark .dark\:shadow-md{--tw-shadow:0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -2px rgba(0,0,0,.1);--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color),0 2px 4px -2px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.dark .dark\:shadow-black\/25{--tw-shadow-color:rgba(0,0,0,.25);--tw-shadow:var(--tw-shadow-colored)}@media (min-width:640px){.sm\:gap-3{gap:.75rem}.sm\:p-6{padding:1.5rem}.sm\:px-6{padding-left:1.5rem;padding-right:1.5rem}}@media (min-width:768px){.md\:mt-12{margin-top:3rem}.md\:mt-8{margin-top:2rem}.md\:mt-1{margin-top:.25rem}.md\:min-h-\[130px\]{min-height:130px}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:gap-5{gap:1.25rem}.md\:space-x-3>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.75rem*var(--tw-space-x-reverse));margin-left:calc(.75rem*(1 - var(--tw-space-x-reverse)))}.md\:bg-emerald-100{--tw-bg-opacity:1;background-color:rgb(209 250 229/var(--tw-bg-opacity))}.md\:bg-yellow-100{--tw-bg-opacity:1;background-color:rgb(254 249 195/var(--tw-bg-opacity))}.md\:bg-blue-100{--tw-bg-opacity:1;background-color:rgb(219 234 254/var(--tw-bg-opacity))}.md\:bg-red-100{--tw-bg-opacity:1;background-color:rgb(254 226 226/var(--tw-bg-opacity))}.md\:bg-gray-100{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity))}.md\:p-2\.5{padding:.625rem}.md\:p-2{padding:.5rem}.md\:px-0{padding-left:0;padding-right:0}.md\:text-xl{font-size:1.25rem;line-height:1.75rem}.dark .md\:dark\:bg-emerald-800{--tw-bg-opacity:1;background-color:rgb(6 95 70/var(--tw-bg-opacity))}.dark .md\:dark\:bg-yellow-800{--tw-bg-opacity:1;background-color:rgb(133 77 14/var(--tw-bg-opacity))}.dark .md\:dark\:bg-blue-800{--tw-bg-opacity:1;background-color:rgb(30 64 175/var(--tw-bg-opacity))}.dark .md\:dark\:bg-red-800{--tw-bg-opacity:1;background-color:rgb(153 27 27/var(--tw-bg-opacity))}.dark .md\:dark\:bg-gray-600{--tw-bg-opacity:1;background-color:rgb(75 85 99/var(--tw-bg-opacity))}.dark .md\:dark\:bg-opacity-60{--tw-bg-opacity:0.6}}@media (min-width:1024px){.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:px-8{padding-left:2rem;padding-right:2rem}} -------------------------------------------------------------------------------- /yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | "@alloc/quick-lru@^5.2.0": 6 | version "5.2.0" 7 | resolved "https://registry.yarnpkg.com/@alloc/quick-lru/-/quick-lru-5.2.0.tgz#7bf68b20c0a350f936915fcae06f58e32007ce30" 8 | integrity sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw== 9 | 10 | "@isaacs/cliui@^8.0.2": 11 | version "8.0.2" 12 | resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550" 13 | integrity sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA== 14 | dependencies: 15 | string-width "^5.1.2" 16 | string-width-cjs "npm:string-width@^4.2.0" 17 | strip-ansi "^7.0.1" 18 | strip-ansi-cjs "npm:strip-ansi@^6.0.1" 19 | wrap-ansi "^8.1.0" 20 | wrap-ansi-cjs "npm:wrap-ansi@^7.0.0" 21 | 22 | "@jridgewell/gen-mapping@^0.3.2": 23 | version "0.3.8" 24 | resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz#4f0e06362e01362f823d348f1872b08f666d8142" 25 | integrity sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA== 26 | dependencies: 27 | "@jridgewell/set-array" "^1.2.1" 28 | "@jridgewell/sourcemap-codec" "^1.4.10" 29 | "@jridgewell/trace-mapping" "^0.3.24" 30 | 31 | "@jridgewell/resolve-uri@^3.1.0": 32 | version "3.1.2" 33 | resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz#7a0ee601f60f99a20c7c7c5ff0c80388c1189bd6" 34 | integrity sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw== 35 | 36 | "@jridgewell/set-array@^1.2.1": 37 | version "1.2.1" 38 | resolved "https://registry.yarnpkg.com/@jridgewell/set-array/-/set-array-1.2.1.tgz#558fb6472ed16a4c850b889530e6b36438c49280" 39 | integrity sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A== 40 | 41 | "@jridgewell/sourcemap-codec@^1.4.10", "@jridgewell/sourcemap-codec@^1.4.14": 42 | version "1.5.0" 43 | resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz#3188bcb273a414b0d215fd22a58540b989b9409a" 44 | integrity sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ== 45 | 46 | "@jridgewell/trace-mapping@^0.3.24": 47 | version "0.3.25" 48 | resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz#15f190e98895f3fc23276ee14bc76b675c2e50f0" 49 | integrity sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ== 50 | dependencies: 51 | "@jridgewell/resolve-uri" "^3.1.0" 52 | "@jridgewell/sourcemap-codec" "^1.4.14" 53 | 54 | "@nodelib/fs.scandir@2.1.5": 55 | version "2.1.5" 56 | resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5" 57 | integrity sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g== 58 | dependencies: 59 | "@nodelib/fs.stat" "2.0.5" 60 | run-parallel "^1.1.9" 61 | 62 | "@nodelib/fs.stat@2.0.5", "@nodelib/fs.stat@^2.0.2": 63 | version "2.0.5" 64 | resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz#5bd262af94e9d25bd1e71b05deed44876a222e8b" 65 | integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A== 66 | 67 | "@nodelib/fs.walk@^1.2.3": 68 | version "1.2.8" 69 | resolved "https://registry.yarnpkg.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz#e95737e8bb6746ddedf69c556953494f196fe69a" 70 | integrity sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg== 71 | dependencies: 72 | "@nodelib/fs.scandir" "2.1.5" 73 | fastq "^1.6.0" 74 | 75 | "@pkgjs/parseargs@^0.11.0": 76 | version "0.11.0" 77 | resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" 78 | integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg== 79 | 80 | ansi-regex@^5.0.1: 81 | version "5.0.1" 82 | resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" 83 | integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== 84 | 85 | ansi-regex@^6.0.1: 86 | version "6.1.0" 87 | resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-6.1.0.tgz#95ec409c69619d6cb1b8b34f14b660ef28ebd654" 88 | integrity sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA== 89 | 90 | ansi-styles@^4.0.0: 91 | version "4.3.0" 92 | resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937" 93 | integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== 94 | dependencies: 95 | color-convert "^2.0.1" 96 | 97 | ansi-styles@^6.1.0: 98 | version "6.2.1" 99 | resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.1.tgz#0e62320cf99c21afff3b3012192546aacbfb05c5" 100 | integrity sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug== 101 | 102 | any-promise@^1.0.0: 103 | version "1.3.0" 104 | resolved "https://registry.yarnpkg.com/any-promise/-/any-promise-1.3.0.tgz#abc6afeedcea52e809cdc0376aed3ce39635d17f" 105 | integrity sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A== 106 | 107 | anymatch@~3.1.2: 108 | version "3.1.3" 109 | resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e" 110 | integrity sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw== 111 | dependencies: 112 | normalize-path "^3.0.0" 113 | picomatch "^2.0.4" 114 | 115 | arg@^5.0.2: 116 | version "5.0.2" 117 | resolved "https://registry.yarnpkg.com/arg/-/arg-5.0.2.tgz#c81433cc427c92c4dcf4865142dbca6f15acd59c" 118 | integrity sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg== 119 | 120 | balanced-match@^1.0.0: 121 | version "1.0.2" 122 | resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" 123 | integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== 124 | 125 | binary-extensions@^2.0.0: 126 | version "2.3.0" 127 | resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.3.0.tgz#f6e14a97858d327252200242d4ccfe522c445522" 128 | integrity sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw== 129 | 130 | brace-expansion@^2.0.1: 131 | version "2.0.1" 132 | resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.1.tgz#1edc459e0f0c548486ecf9fc99f2221364b9a0ae" 133 | integrity sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA== 134 | dependencies: 135 | balanced-match "^1.0.0" 136 | 137 | braces@^3.0.3, braces@~3.0.2: 138 | version "3.0.3" 139 | resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.3.tgz#490332f40919452272d55a8480adc0c441358789" 140 | integrity sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA== 141 | dependencies: 142 | fill-range "^7.1.1" 143 | 144 | camelcase-css@^2.0.1: 145 | version "2.0.1" 146 | resolved "https://registry.yarnpkg.com/camelcase-css/-/camelcase-css-2.0.1.tgz#ee978f6947914cc30c6b44741b6ed1df7f043fd5" 147 | integrity sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA== 148 | 149 | chokidar@^3.6.0: 150 | version "3.6.0" 151 | resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.6.0.tgz#197c6cc669ef2a8dc5e7b4d97ee4e092c3eb0d5b" 152 | integrity sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw== 153 | dependencies: 154 | anymatch "~3.1.2" 155 | braces "~3.0.2" 156 | glob-parent "~5.1.2" 157 | is-binary-path "~2.1.0" 158 | is-glob "~4.0.1" 159 | normalize-path "~3.0.0" 160 | readdirp "~3.6.0" 161 | optionalDependencies: 162 | fsevents "~2.3.2" 163 | 164 | color-convert@^2.0.1: 165 | version "2.0.1" 166 | resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" 167 | integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== 168 | dependencies: 169 | color-name "~1.1.4" 170 | 171 | color-name@~1.1.4: 172 | version "1.1.4" 173 | resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" 174 | integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== 175 | 176 | commander@^4.0.0: 177 | version "4.1.1" 178 | resolved "https://registry.yarnpkg.com/commander/-/commander-4.1.1.tgz#9fd602bd936294e9e9ef46a3f4d6964044b18068" 179 | integrity sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA== 180 | 181 | cross-spawn@^7.0.6: 182 | version "7.0.6" 183 | resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" 184 | integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== 185 | dependencies: 186 | path-key "^3.1.0" 187 | shebang-command "^2.0.0" 188 | which "^2.0.1" 189 | 190 | cssesc@^3.0.0: 191 | version "3.0.0" 192 | resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee" 193 | integrity sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg== 194 | 195 | didyoumean@^1.2.2: 196 | version "1.2.2" 197 | resolved "https://registry.yarnpkg.com/didyoumean/-/didyoumean-1.2.2.tgz#989346ffe9e839b4555ecf5666edea0d3e8ad037" 198 | integrity sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw== 199 | 200 | dlv@^1.1.3: 201 | version "1.1.3" 202 | resolved "https://registry.yarnpkg.com/dlv/-/dlv-1.1.3.tgz#5c198a8a11453596e751494d49874bc7732f2e79" 203 | integrity sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA== 204 | 205 | eastasianwidth@^0.2.0: 206 | version "0.2.0" 207 | resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb" 208 | integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA== 209 | 210 | emoji-regex@^8.0.0: 211 | version "8.0.0" 212 | resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" 213 | integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== 214 | 215 | emoji-regex@^9.2.2: 216 | version "9.2.2" 217 | resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72" 218 | integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg== 219 | 220 | fast-glob@^3.3.2: 221 | version "3.3.3" 222 | resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.3.tgz#d06d585ce8dba90a16b0505c543c3ccfb3aeb818" 223 | integrity sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg== 224 | dependencies: 225 | "@nodelib/fs.stat" "^2.0.2" 226 | "@nodelib/fs.walk" "^1.2.3" 227 | glob-parent "^5.1.2" 228 | merge2 "^1.3.0" 229 | micromatch "^4.0.8" 230 | 231 | fastq@^1.6.0: 232 | version "1.19.1" 233 | resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.19.1.tgz#d50eaba803c8846a883c16492821ebcd2cda55f5" 234 | integrity sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ== 235 | dependencies: 236 | reusify "^1.0.4" 237 | 238 | fill-range@^7.1.1: 239 | version "7.1.1" 240 | resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.1.1.tgz#44265d3cac07e3ea7dc247516380643754a05292" 241 | integrity sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg== 242 | dependencies: 243 | to-regex-range "^5.0.1" 244 | 245 | foreground-child@^3.1.0: 246 | version "3.3.1" 247 | resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.3.1.tgz#32e8e9ed1b68a3497befb9ac2b6adf92a638576f" 248 | integrity sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw== 249 | dependencies: 250 | cross-spawn "^7.0.6" 251 | signal-exit "^4.0.1" 252 | 253 | fsevents@~2.3.2: 254 | version "2.3.3" 255 | resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" 256 | integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== 257 | 258 | function-bind@^1.1.2: 259 | version "1.1.2" 260 | resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c" 261 | integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== 262 | 263 | glob-parent@^5.1.2, glob-parent@~5.1.2: 264 | version "5.1.2" 265 | resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" 266 | integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== 267 | dependencies: 268 | is-glob "^4.0.1" 269 | 270 | glob-parent@^6.0.2: 271 | version "6.0.2" 272 | resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-6.0.2.tgz#6d237d99083950c79290f24c7642a3de9a28f9e3" 273 | integrity sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A== 274 | dependencies: 275 | is-glob "^4.0.3" 276 | 277 | glob@^10.3.10: 278 | version "10.4.5" 279 | resolved "https://registry.yarnpkg.com/glob/-/glob-10.4.5.tgz#f4d9f0b90ffdbab09c9d77f5f29b4262517b0956" 280 | integrity sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg== 281 | dependencies: 282 | foreground-child "^3.1.0" 283 | jackspeak "^3.1.2" 284 | minimatch "^9.0.4" 285 | minipass "^7.1.2" 286 | package-json-from-dist "^1.0.0" 287 | path-scurry "^1.11.1" 288 | 289 | hasown@^2.0.2: 290 | version "2.0.2" 291 | resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.2.tgz#003eaf91be7adc372e84ec59dc37252cedb80003" 292 | integrity sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ== 293 | dependencies: 294 | function-bind "^1.1.2" 295 | 296 | is-binary-path@~2.1.0: 297 | version "2.1.0" 298 | resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09" 299 | integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw== 300 | dependencies: 301 | binary-extensions "^2.0.0" 302 | 303 | is-core-module@^2.16.0: 304 | version "2.16.1" 305 | resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.16.1.tgz#2a98801a849f43e2add644fbb6bc6229b19a4ef4" 306 | integrity sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w== 307 | dependencies: 308 | hasown "^2.0.2" 309 | 310 | is-extglob@^2.1.1: 311 | version "2.1.1" 312 | resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" 313 | integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== 314 | 315 | is-fullwidth-code-point@^3.0.0: 316 | version "3.0.0" 317 | resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" 318 | integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== 319 | 320 | is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1: 321 | version "4.0.3" 322 | resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" 323 | integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== 324 | dependencies: 325 | is-extglob "^2.1.1" 326 | 327 | is-number@^7.0.0: 328 | version "7.0.0" 329 | resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" 330 | integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== 331 | 332 | isexe@^2.0.0: 333 | version "2.0.0" 334 | resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" 335 | integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== 336 | 337 | jackspeak@^3.1.2: 338 | version "3.4.3" 339 | resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-3.4.3.tgz#8833a9d89ab4acde6188942bd1c53b6390ed5a8a" 340 | integrity sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw== 341 | dependencies: 342 | "@isaacs/cliui" "^8.0.2" 343 | optionalDependencies: 344 | "@pkgjs/parseargs" "^0.11.0" 345 | 346 | jiti@^1.21.6: 347 | version "1.21.7" 348 | resolved "https://registry.yarnpkg.com/jiti/-/jiti-1.21.7.tgz#9dd81043424a3d28458b193d965f0d18a2300ba9" 349 | integrity sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A== 350 | 351 | lilconfig@^3.0.0, lilconfig@^3.1.3: 352 | version "3.1.3" 353 | resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-3.1.3.tgz#a1bcfd6257f9585bf5ae14ceeebb7b559025e4c4" 354 | integrity sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw== 355 | 356 | lines-and-columns@^1.1.6: 357 | version "1.2.4" 358 | resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632" 359 | integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg== 360 | 361 | lru-cache@^10.2.0: 362 | version "10.4.3" 363 | resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.4.3.tgz#410fc8a17b70e598013df257c2446b7f3383f119" 364 | integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ== 365 | 366 | merge2@^1.3.0: 367 | version "1.4.1" 368 | resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae" 369 | integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== 370 | 371 | micromatch@^4.0.8: 372 | version "4.0.8" 373 | resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.8.tgz#d66fa18f3a47076789320b9b1af32bd86d9fa202" 374 | integrity sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA== 375 | dependencies: 376 | braces "^3.0.3" 377 | picomatch "^2.3.1" 378 | 379 | minimatch@^9.0.4: 380 | version "9.0.5" 381 | resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.5.tgz#d74f9dd6b57d83d8e98cfb82133b03978bc929e5" 382 | integrity sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow== 383 | dependencies: 384 | brace-expansion "^2.0.1" 385 | 386 | "minipass@^5.0.0 || ^6.0.2 || ^7.0.0", minipass@^7.1.2: 387 | version "7.1.2" 388 | resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.2.tgz#93a9626ce5e5e66bd4db86849e7515e92340a707" 389 | integrity sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw== 390 | 391 | mz@^2.7.0: 392 | version "2.7.0" 393 | resolved "https://registry.yarnpkg.com/mz/-/mz-2.7.0.tgz#95008057a56cafadc2bc63dde7f9ff6955948e32" 394 | integrity sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q== 395 | dependencies: 396 | any-promise "^1.0.0" 397 | object-assign "^4.0.1" 398 | thenify-all "^1.0.0" 399 | 400 | nanoid@^3.3.8: 401 | version "3.3.8" 402 | resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.8.tgz#b1be3030bee36aaff18bacb375e5cce521684baf" 403 | integrity sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w== 404 | 405 | normalize-path@^3.0.0, normalize-path@~3.0.0: 406 | version "3.0.0" 407 | resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" 408 | integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== 409 | 410 | object-assign@^4.0.1: 411 | version "4.1.1" 412 | resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" 413 | integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg== 414 | 415 | object-hash@^3.0.0: 416 | version "3.0.0" 417 | resolved "https://registry.yarnpkg.com/object-hash/-/object-hash-3.0.0.tgz#73f97f753e7baffc0e2cc9d6e079079744ac82e9" 418 | integrity sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw== 419 | 420 | package-json-from-dist@^1.0.0: 421 | version "1.0.1" 422 | resolved "https://registry.yarnpkg.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz#4f1471a010827a86f94cfd9b0727e36d267de505" 423 | integrity sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw== 424 | 425 | path-key@^3.1.0: 426 | version "3.1.1" 427 | resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" 428 | integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== 429 | 430 | path-parse@^1.0.7: 431 | version "1.0.7" 432 | resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" 433 | integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== 434 | 435 | path-scurry@^1.11.1: 436 | version "1.11.1" 437 | resolved "https://registry.yarnpkg.com/path-scurry/-/path-scurry-1.11.1.tgz#7960a668888594a0720b12a911d1a742ab9f11d2" 438 | integrity sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA== 439 | dependencies: 440 | lru-cache "^10.2.0" 441 | minipass "^5.0.0 || ^6.0.2 || ^7.0.0" 442 | 443 | picocolors@^1.1.1: 444 | version "1.1.1" 445 | resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" 446 | integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== 447 | 448 | picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.3.1: 449 | version "2.3.1" 450 | resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" 451 | integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== 452 | 453 | pify@^2.3.0: 454 | version "2.3.0" 455 | resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c" 456 | integrity sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog== 457 | 458 | pirates@^4.0.1: 459 | version "4.0.6" 460 | resolved "https://registry.yarnpkg.com/pirates/-/pirates-4.0.6.tgz#3018ae32ecfcff6c29ba2267cbf21166ac1f36b9" 461 | integrity sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg== 462 | 463 | postcss-import@^15.1.0: 464 | version "15.1.0" 465 | resolved "https://registry.yarnpkg.com/postcss-import/-/postcss-import-15.1.0.tgz#41c64ed8cc0e23735a9698b3249ffdbf704adc70" 466 | integrity sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew== 467 | dependencies: 468 | postcss-value-parser "^4.0.0" 469 | read-cache "^1.0.0" 470 | resolve "^1.1.7" 471 | 472 | postcss-js@^4.0.1: 473 | version "4.0.1" 474 | resolved "https://registry.yarnpkg.com/postcss-js/-/postcss-js-4.0.1.tgz#61598186f3703bab052f1c4f7d805f3991bee9d2" 475 | integrity sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw== 476 | dependencies: 477 | camelcase-css "^2.0.1" 478 | 479 | postcss-load-config@^4.0.2: 480 | version "4.0.2" 481 | resolved "https://registry.yarnpkg.com/postcss-load-config/-/postcss-load-config-4.0.2.tgz#7159dcf626118d33e299f485d6afe4aff7c4a3e3" 482 | integrity sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ== 483 | dependencies: 484 | lilconfig "^3.0.0" 485 | yaml "^2.3.4" 486 | 487 | postcss-nested@^6.2.0: 488 | version "6.2.0" 489 | resolved "https://registry.yarnpkg.com/postcss-nested/-/postcss-nested-6.2.0.tgz#4c2d22ab5f20b9cb61e2c5c5915950784d068131" 490 | integrity sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ== 491 | dependencies: 492 | postcss-selector-parser "^6.1.1" 493 | 494 | postcss-selector-parser@^6.1.1, postcss-selector-parser@^6.1.2: 495 | version "6.1.2" 496 | resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz#27ecb41fb0e3b6ba7a1ec84fff347f734c7929de" 497 | integrity sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg== 498 | dependencies: 499 | cssesc "^3.0.0" 500 | util-deprecate "^1.0.2" 501 | 502 | postcss-value-parser@^4.0.0: 503 | version "4.2.0" 504 | resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514" 505 | integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ== 506 | 507 | postcss@^8.4.47: 508 | version "8.5.3" 509 | resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.3.tgz#1463b6f1c7fb16fe258736cba29a2de35237eafb" 510 | integrity sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A== 511 | dependencies: 512 | nanoid "^3.3.8" 513 | picocolors "^1.1.1" 514 | source-map-js "^1.2.1" 515 | 516 | queue-microtask@^1.2.2: 517 | version "1.2.3" 518 | resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" 519 | integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== 520 | 521 | read-cache@^1.0.0: 522 | version "1.0.0" 523 | resolved "https://registry.yarnpkg.com/read-cache/-/read-cache-1.0.0.tgz#e664ef31161166c9751cdbe8dbcf86b5fb58f774" 524 | integrity sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA== 525 | dependencies: 526 | pify "^2.3.0" 527 | 528 | readdirp@~3.6.0: 529 | version "3.6.0" 530 | resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7" 531 | integrity sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA== 532 | dependencies: 533 | picomatch "^2.2.1" 534 | 535 | resolve@^1.1.7, resolve@^1.22.8: 536 | version "1.22.10" 537 | resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.10.tgz#b663e83ffb09bbf2386944736baae803029b8b39" 538 | integrity sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w== 539 | dependencies: 540 | is-core-module "^2.16.0" 541 | path-parse "^1.0.7" 542 | supports-preserve-symlinks-flag "^1.0.0" 543 | 544 | reusify@^1.0.4: 545 | version "1.1.0" 546 | resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.1.0.tgz#0fe13b9522e1473f51b558ee796e08f11f9b489f" 547 | integrity sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw== 548 | 549 | run-parallel@^1.1.9: 550 | version "1.2.0" 551 | resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.2.0.tgz#66d1368da7bdf921eb9d95bd1a9229e7f21a43ee" 552 | integrity sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA== 553 | dependencies: 554 | queue-microtask "^1.2.2" 555 | 556 | shebang-command@^2.0.0: 557 | version "2.0.0" 558 | resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" 559 | integrity sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA== 560 | dependencies: 561 | shebang-regex "^3.0.0" 562 | 563 | shebang-regex@^3.0.0: 564 | version "3.0.0" 565 | resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" 566 | integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== 567 | 568 | signal-exit@^4.0.1: 569 | version "4.1.0" 570 | resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04" 571 | integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw== 572 | 573 | source-map-js@^1.2.1: 574 | version "1.2.1" 575 | resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46" 576 | integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== 577 | 578 | "string-width-cjs@npm:string-width@^4.2.0": 579 | version "4.2.3" 580 | resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" 581 | integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== 582 | dependencies: 583 | emoji-regex "^8.0.0" 584 | is-fullwidth-code-point "^3.0.0" 585 | strip-ansi "^6.0.1" 586 | 587 | string-width@^4.1.0: 588 | version "4.2.3" 589 | resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" 590 | integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== 591 | dependencies: 592 | emoji-regex "^8.0.0" 593 | is-fullwidth-code-point "^3.0.0" 594 | strip-ansi "^6.0.1" 595 | 596 | string-width@^5.0.1, string-width@^5.1.2: 597 | version "5.1.2" 598 | resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794" 599 | integrity sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA== 600 | dependencies: 601 | eastasianwidth "^0.2.0" 602 | emoji-regex "^9.2.2" 603 | strip-ansi "^7.0.1" 604 | 605 | "strip-ansi-cjs@npm:strip-ansi@^6.0.1": 606 | version "6.0.1" 607 | resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" 608 | integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== 609 | dependencies: 610 | ansi-regex "^5.0.1" 611 | 612 | strip-ansi@^6.0.0, strip-ansi@^6.0.1: 613 | version "6.0.1" 614 | resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" 615 | integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== 616 | dependencies: 617 | ansi-regex "^5.0.1" 618 | 619 | strip-ansi@^7.0.1: 620 | version "7.1.0" 621 | resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" 622 | integrity sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ== 623 | dependencies: 624 | ansi-regex "^6.0.1" 625 | 626 | sucrase@^3.35.0: 627 | version "3.35.0" 628 | resolved "https://registry.yarnpkg.com/sucrase/-/sucrase-3.35.0.tgz#57f17a3d7e19b36d8995f06679d121be914ae263" 629 | integrity sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA== 630 | dependencies: 631 | "@jridgewell/gen-mapping" "^0.3.2" 632 | commander "^4.0.0" 633 | glob "^10.3.10" 634 | lines-and-columns "^1.1.6" 635 | mz "^2.7.0" 636 | pirates "^4.0.1" 637 | ts-interface-checker "^0.1.9" 638 | 639 | supports-preserve-symlinks-flag@^1.0.0: 640 | version "1.0.0" 641 | resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" 642 | integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== 643 | 644 | tailwindcss@^3.0.1: 645 | version "3.4.17" 646 | resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-3.4.17.tgz#ae8406c0f96696a631c790768ff319d46d5e5a63" 647 | integrity sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og== 648 | dependencies: 649 | "@alloc/quick-lru" "^5.2.0" 650 | arg "^5.0.2" 651 | chokidar "^3.6.0" 652 | didyoumean "^1.2.2" 653 | dlv "^1.1.3" 654 | fast-glob "^3.3.2" 655 | glob-parent "^6.0.2" 656 | is-glob "^4.0.3" 657 | jiti "^1.21.6" 658 | lilconfig "^3.1.3" 659 | micromatch "^4.0.8" 660 | normalize-path "^3.0.0" 661 | object-hash "^3.0.0" 662 | picocolors "^1.1.1" 663 | postcss "^8.4.47" 664 | postcss-import "^15.1.0" 665 | postcss-js "^4.0.1" 666 | postcss-load-config "^4.0.2" 667 | postcss-nested "^6.2.0" 668 | postcss-selector-parser "^6.1.2" 669 | resolve "^1.22.8" 670 | sucrase "^3.35.0" 671 | 672 | thenify-all@^1.0.0: 673 | version "1.6.0" 674 | resolved "https://registry.yarnpkg.com/thenify-all/-/thenify-all-1.6.0.tgz#1a1918d402d8fc3f98fbf234db0bcc8cc10e9726" 675 | integrity sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA== 676 | dependencies: 677 | thenify ">= 3.1.0 < 4" 678 | 679 | "thenify@>= 3.1.0 < 4": 680 | version "3.3.1" 681 | resolved "https://registry.yarnpkg.com/thenify/-/thenify-3.3.1.tgz#8932e686a4066038a016dd9e2ca46add9838a95f" 682 | integrity sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw== 683 | dependencies: 684 | any-promise "^1.0.0" 685 | 686 | to-regex-range@^5.0.1: 687 | version "5.0.1" 688 | resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4" 689 | integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ== 690 | dependencies: 691 | is-number "^7.0.0" 692 | 693 | ts-interface-checker@^0.1.9: 694 | version "0.1.13" 695 | resolved "https://registry.yarnpkg.com/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz#784fd3d679722bc103b1b4b8030bcddb5db2a699" 696 | integrity sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA== 697 | 698 | util-deprecate@^1.0.2: 699 | version "1.0.2" 700 | resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" 701 | integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== 702 | 703 | which@^2.0.1: 704 | version "2.0.2" 705 | resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" 706 | integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== 707 | dependencies: 708 | isexe "^2.0.0" 709 | 710 | "wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": 711 | version "7.0.0" 712 | resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" 713 | integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== 714 | dependencies: 715 | ansi-styles "^4.0.0" 716 | string-width "^4.1.0" 717 | strip-ansi "^6.0.0" 718 | 719 | wrap-ansi@^8.1.0: 720 | version "8.1.0" 721 | resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" 722 | integrity sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ== 723 | dependencies: 724 | ansi-styles "^6.1.0" 725 | string-width "^5.0.1" 726 | strip-ansi "^7.0.1" 727 | 728 | yaml@^2.3.4: 729 | version "2.7.0" 730 | resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.7.0.tgz#aef9bb617a64c937a9a748803786ad8d3ffe1e98" 731 | integrity sha512-+hSoy/QHluxmC9kCIJyL/uyFmLmc+e5CFR5Wa+bpIhIj85LVb9ZH2nVnqrHoSvKogwODv0ClqZkmiSSaIH5LTA== 732 | --------------------------------------------------------------------------------