Health page (#469)

* add spatie health

* change slug for health page

* add check for panel version

* only check for debug mode if env isn't local

* add check for node versions

* improve short summary

* fix outdated check

* run pint

* fix health checks during tests

* add count to ok message

* fix typo

* temp fix for phpstan job

* fix pint...

* improve "outdated" count

Co-authored-by: MartinOscar <40749467+RMartinOscar@users.noreply.github.com>

* run pint

* skip node versions check if no nodes are created

* auto run health checks if they didn't run before

* small refactor

* update navigation

Co-authored-by: Charles <sir3lit@gmail.com>

* fix errors if tests didn't run yet

* fix disk usage check

* remove plugin and use own page

* use health status indicator from spatie

* fix after merge

* update icon

* update color classes

* fix after merge

* add back imports

oops...

* wrong import

oops²...

* update spatie/laravel-health to latest

* move Health page to correct namespace

* update NodeVersionsCheck

* use style instead of tailwind classes

workaround until we have vite

* cleanup custom checks

---------

Co-authored-by: MartinOscar <40749467+RMartinOscar@users.noreply.github.com>
Co-authored-by: Charles <sir3lit@gmail.com>
This commit is contained in:
Boy132 2024-12-24 19:09:16 +01:00 committed by GitHub
parent 02a0c5c3eb
commit 00644c2c60
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 669 additions and 3 deletions

View File

@ -0,0 +1,43 @@
<?php
namespace App\Checks;
use App\Models\Node;
use App\Services\Helpers\SoftwareVersionService;
use Spatie\Health\Checks\Check;
use Spatie\Health\Checks\Result;
use Spatie\Health\Enums\Status;
class NodeVersionsCheck extends Check
{
public function __construct(private SoftwareVersionService $versionService) {}
public function run(): Result
{
$all = Node::query()->count();
if ($all === 0) {
$result = Result::make()->notificationMessage('No Nodes created')->shortSummary('No Nodes');
$result->status = Status::skipped();
return $result;
}
$latestVersion = $this->versionService->latestWingsVersion();
$outdated = Node::query()->get()
->filter(fn (Node $node) => !isset($node->systemInformation()['exception']) && $node->systemInformation()['version'] !== $latestVersion)
->count();
$result = Result::make()
->meta([
'all' => $all,
'outdated' => $outdated,
])
->shortSummary($outdated === 0 ? 'All up-to-date' : "{$outdated}/{$all} outdated");
return $outdated === 0
? $result->ok('All Nodes are up-to-date.')
: $result->failed(':outdated/:all Nodes are outdated.');
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace App\Checks;
use App\Services\Helpers\SoftwareVersionService;
use Spatie\Health\Checks\Check;
use Spatie\Health\Checks\Result;
class PanelVersionCheck extends Check
{
public function __construct(private SoftwareVersionService $versionService) {}
public function run(): Result
{
$isLatest = $this->versionService->isLatestPanel();
$currentVersion = $this->versionService->currentPanelVersion();
$latestVersion = $this->versionService->latestPanelVersion();
$result = Result::make()
->meta([
'isLatest' => $isLatest,
'currentVersion' => $currentVersion,
'latestVersion' => $latestVersion,
])
->shortSummary($isLatest ? 'up-to-date' : 'outdated');
return $isLatest
? $result->ok('Panel is up-to-date.')
: $result->failed('Installed version is `:currentVersion` but latest is `:latestVersion`.');
}
}

View File

@ -0,0 +1,16 @@
<?php
namespace App\Checks;
use Spatie\Health\Checks\Checks\UsedDiskSpaceCheck as BaseCheck;
class UsedDiskSpaceCheck extends BaseCheck
{
protected function getDiskUsagePercentage(): int
{
$freeSpace = disk_free_space($this->filesystemName ?? '/');
$totalSpace = disk_total_space($this->filesystemName ?? '/');
return 100 - ($freeSpace * 100 / $totalSpace);
}
}

View File

@ -13,6 +13,8 @@ use App\Models\Webhook;
use Illuminate\Console\Scheduling\Schedule; use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Database\Console\PruneCommand; use Illuminate\Database\Console\PruneCommand;
use Illuminate\Foundation\Console\Kernel as ConsoleKernel; use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
use Spatie\Health\Commands\RunHealthChecksCommand;
use Spatie\Health\Commands\ScheduleCheckHeartbeatCommand;
class Kernel extends ConsoleKernel class Kernel extends ConsoleKernel
{ {
@ -53,5 +55,8 @@ class Kernel extends ConsoleKernel
if (config('panel.webhook.prune_days')) { if (config('panel.webhook.prune_days')) {
$schedule->command(PruneCommand::class, ['--model' => [Webhook::class]])->daily(); $schedule->command(PruneCommand::class, ['--model' => [Webhook::class]])->daily();
} }
$schedule->command(ScheduleCheckHeartbeatCommand::class)->everyMinute();
$schedule->command(RunHealthChecksCommand::class)->everyFiveMinutes();
} }
} }

View File

@ -0,0 +1,120 @@
<?php
namespace App\Filament\Admin\Pages;
use Carbon\Carbon;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Illuminate\Support\Facades\Artisan;
use Spatie\Health\Commands\RunHealthChecksCommand;
use Spatie\Health\ResultStores\ResultStore;
class Health extends Page
{
protected static ?string $navigationIcon = 'tabler-heart';
protected static ?string $navigationGroup = 'Advanced';
protected static string $view = 'filament.pages.health';
// @phpstan-ignore-next-line
protected $listeners = [
'refresh-component' => '$refresh',
];
protected function getActions(): array
{
return [
Action::make('refresh')
->button()
->action('refresh'),
];
}
protected function getViewData(): array
{
// @phpstan-ignore-next-line
$checkResults = app(ResultStore::class)->latestResults();
if ($checkResults === null) {
Artisan::call(RunHealthChecksCommand::class);
$this->dispatch('refresh-component');
}
return [
'lastRanAt' => new Carbon($checkResults?->finishedAt),
'checkResults' => $checkResults,
];
}
public function refresh(): void
{
Artisan::call(RunHealthChecksCommand::class);
$this->dispatch('refresh-component');
Notification::make()
->title('Health check results refreshed')
->success()
->send();
}
public static function getNavigationBadge(): ?string
{
// @phpstan-ignore-next-line
$results = app(ResultStore::class)->latestResults();
if ($results === null) {
return null;
}
$results = json_decode($results->toJson(), true);
$failed = array_reduce($results['checkResults'], function ($numFailed, $result) {
return $numFailed + ($result['status'] === 'failed' ? 1 : 0);
}, 0);
return $failed === 0 ? null : (string) $failed;
}
public static function getNavigationBadgeColor(): string
{
return self::getNavigationBadge() > null ? 'danger' : '';
}
public static function getNavigationBadgeTooltip(): ?string
{
// @phpstan-ignore-next-line
$results = app(ResultStore::class)->latestResults();
if ($results === null) {
return null;
}
$results = json_decode($results->toJson(), true);
$failedNames = array_reduce($results['checkResults'], function ($carry, $result) {
if ($result['status'] === 'failed') {
$carry[] = $result['name'];
}
return $carry;
}, []);
return 'Failed: ' . implode(', ', $failedNames);
}
public static function getNavigationIcon(): string
{
// @phpstan-ignore-next-line
$results = app(ResultStore::class)->latestResults();
if ($results === null) {
return 'tabler-heart-question';
}
return $results->containsFailingCheck() ? 'tabler-heart-exclamation' : 'tabler-heart-check';
}
}

View File

@ -2,6 +2,9 @@
namespace App\Providers; namespace App\Providers;
use App\Checks\NodeVersionsCheck;
use App\Checks\PanelVersionCheck;
use App\Checks\UsedDiskSpaceCheck;
use App\Filament\Server\Pages\Console; use App\Filament\Server\Pages\Console;
use App\Models; use App\Models;
use App\Models\ApiKey; use App\Models\ApiKey;
@ -26,6 +29,12 @@ use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Laravel\Sanctum\Sanctum; use Laravel\Sanctum\Sanctum;
use SocialiteProviders\Manager\SocialiteWasCalled; use SocialiteProviders\Manager\SocialiteWasCalled;
use Spatie\Health\Checks\Checks\CacheCheck;
use Spatie\Health\Checks\Checks\DatabaseCheck;
use Spatie\Health\Checks\Checks\DebugModeCheck;
use Spatie\Health\Checks\Checks\EnvironmentCheck;
use Spatie\Health\Checks\Checks\ScheduleCheck;
use Spatie\Health\Facades\Health;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
{ {
@ -103,6 +112,20 @@ class AppServiceProvider extends ServiceProvider
scopes: Console::class, scopes: Console::class,
); );
// Don't run any health checks during tests
if (!$app->runningUnitTests()) {
Health::checks([
DebugModeCheck::new()->if($app->isProduction()),
EnvironmentCheck::new(),
CacheCheck::new(),
DatabaseCheck::new(),
ScheduleCheck::new(),
UsedDiskSpaceCheck::new(),
PanelVersionCheck::new(),
NodeVersionsCheck::new(),
]);
}
Gate::before(function (User $user, $ability) { Gate::before(function (User $user, $ability) {
return $user->isRootAdmin() ? true : null; return $user->isRootAdmin() ? true : null;
}); });

View File

@ -35,6 +35,7 @@
"socialiteproviders/discord": "^4.2", "socialiteproviders/discord": "^4.2",
"socialiteproviders/steam": "^4.2", "socialiteproviders/steam": "^4.2",
"spatie/laravel-fractal": "^6.2", "spatie/laravel-fractal": "^6.2",
"spatie/laravel-health": "^1.30",
"spatie/laravel-permission": "^6.9", "spatie/laravel-permission": "^6.9",
"spatie/laravel-query-builder": "^5.8.1", "spatie/laravel-query-builder": "^5.8.1",
"spatie/temporary-directory": "^2.2", "spatie/temporary-directory": "^2.2",
@ -92,4 +93,4 @@
}, },
"minimum-stability": "stable", "minimum-stability": "stable",
"prefer-stable": true "prefer-stable": true
} }

237
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "d3b70b68927bf912d19adea9705d70ff", "content-hash": "2fddfe7f5db269cdf9e906b83c815756",
"packages": [ "packages": [
{ {
"name": "abdelhamiderrahmouni/filament-monaco-editor", "name": "abdelhamiderrahmouni/filament-monaco-editor",
@ -7162,6 +7162,82 @@
], ],
"time": "2024-09-20T14:00:15+00:00" "time": "2024-09-20T14:00:15+00:00"
}, },
{
"name": "spatie/enum",
"version": "3.13.0",
"source": {
"type": "git",
"url": "https://github.com/spatie/enum.git",
"reference": "f1a0f464ba909491a53e60a955ce84ad7cd93a2c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/spatie/enum/zipball/f1a0f464ba909491a53e60a955ce84ad7cd93a2c",
"reference": "f1a0f464ba909491a53e60a955ce84ad7cd93a2c",
"shasum": ""
},
"require": {
"ext-json": "*",
"php": "^8.0"
},
"require-dev": {
"fakerphp/faker": "^1.9.1",
"larapack/dd": "^1.1",
"phpunit/phpunit": "^9.0",
"vimeo/psalm": "^4.3"
},
"suggest": {
"fakerphp/faker": "To use the enum faker provider",
"phpunit/phpunit": "To use the enum assertions"
},
"type": "library",
"autoload": {
"psr-4": {
"Spatie\\Enum\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Brent Roose",
"email": "brent@spatie.be",
"homepage": "https://spatie.be",
"role": "Developer"
},
{
"name": "Tom Witkowski",
"email": "dev@gummibeer.de",
"homepage": "https://gummibeer.de",
"role": "Developer"
}
],
"description": "PHP Enums",
"homepage": "https://github.com/spatie/enum",
"keywords": [
"enum",
"enumerable",
"spatie"
],
"support": {
"docs": "https://docs.spatie.be/enum",
"issues": "https://github.com/spatie/enum/issues",
"source": "https://github.com/spatie/enum"
},
"funding": [
{
"url": "https://spatie.be/open-source/support-us",
"type": "custom"
},
{
"url": "https://github.com/spatie",
"type": "github"
}
],
"time": "2022-04-22T08:51:55+00:00"
},
{ {
"name": "spatie/fractalistic", "name": "spatie/fractalistic",
"version": "2.9.5", "version": "2.9.5",
@ -7363,6 +7439,100 @@
], ],
"time": "2024-06-04T09:33:08+00:00" "time": "2024-06-04T09:33:08+00:00"
}, },
{
"name": "spatie/laravel-health",
"version": "1.30.1",
"source": {
"type": "git",
"url": "https://github.com/spatie/laravel-health.git",
"reference": "98e91b8a4b5ffc9086cf5d9cd2e7fa9cf1be0661"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/spatie/laravel-health/zipball/98e91b8a4b5ffc9086cf5d9cd2e7fa9cf1be0661",
"reference": "98e91b8a4b5ffc9086cf5d9cd2e7fa9cf1be0661",
"shasum": ""
},
"require": {
"dragonmantank/cron-expression": "^3.3.1",
"guzzlehttp/guzzle": "^6.5|^7.4.5|^7.2",
"illuminate/console": "^8.75|^9.0|^10.0|^11.0",
"illuminate/contracts": "^8.75|^9.0|^10.0|^11.0",
"illuminate/database": "^8.75|^9.0|^10.0|^11.0",
"illuminate/notifications": "^8.75|^9.0|^10.0|^11.0",
"illuminate/support": "^8.75|^9.0|^10.0|^11.0",
"laravel/serializable-closure": "^1.3",
"nunomaduro/termwind": "^1.0|^2.0",
"php": "^8.0",
"spatie/enum": "^3.13",
"spatie/laravel-package-tools": "^1.12.1",
"spatie/regex": "^3.1.1|^3.1",
"spatie/temporary-directory": "^2.2",
"symfony/process": "^5.4|^6.0|^7.0"
},
"require-dev": {
"larastan/larastan": "^1.0.3|^2.4",
"laravel/horizon": "^5.9.10",
"laravel/slack-notification-channel": "^2.4|^3.2",
"nunomaduro/collision": "^5.10|^6.2.1|^6.1|^8.0",
"orchestra/testbench": "^6.23|^7.6|^8.0|^9.0",
"pestphp/pest": "^1.21.3|^2.34",
"pestphp/pest-plugin-laravel": "^1.2|^2.3",
"phpstan/extension-installer": "^1.1",
"phpstan/phpstan-deprecation-rules": "^1.0",
"phpstan/phpstan-phpunit": "^1.1.1",
"phpunit/phpunit": "^9.5.21|^9.5.10|^10.5",
"spatie/laravel-ray": "^1.30",
"spatie/pest-plugin-snapshots": "^1.1|^2.1",
"spatie/pest-plugin-test-time": "^1.1.1|^1.1|^2.0",
"spatie/test-time": "^1.3"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Spatie\\Health\\HealthServiceProvider"
],
"aliases": {
"Health": "Spatie\\Health\\Facades\\Health"
}
}
},
"autoload": {
"psr-4": {
"Spatie\\Health\\": "src",
"Spatie\\Health\\Database\\Factories\\": "database/factories"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Freek Van der Herten",
"email": "freek@spatie.be",
"role": "Developer"
}
],
"description": "Monitor the health of a Laravel application",
"homepage": "https://github.com/spatie/laravel-health",
"keywords": [
"laravel",
"laravel-health",
"spatie"
],
"support": {
"source": "https://github.com/spatie/laravel-health/tree/1.30.1"
},
"funding": [
{
"url": "https://github.com/spatie",
"type": "github"
}
],
"time": "2024-08-02T14:01:48+00:00"
},
{ {
"name": "spatie/laravel-package-tools", "name": "spatie/laravel-package-tools",
"version": "1.16.5", "version": "1.16.5",
@ -7579,6 +7749,69 @@
], ],
"time": "2024-05-10T08:19:35+00:00" "time": "2024-05-10T08:19:35+00:00"
}, },
{
"name": "spatie/regex",
"version": "3.1.1",
"source": {
"type": "git",
"url": "https://github.com/spatie/regex.git",
"reference": "d543de2019a0068e7b80da0ba24f1c51c7469303"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/spatie/regex/zipball/d543de2019a0068e7b80da0ba24f1c51c7469303",
"reference": "d543de2019a0068e7b80da0ba24f1c51c7469303",
"shasum": ""
},
"require": {
"php": "^8.0|^8.1"
},
"require-dev": {
"phpunit/phpunit": "^9.3"
},
"type": "library",
"autoload": {
"psr-4": {
"Spatie\\Regex\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Sebastian De Deyne",
"email": "sebastian@spatie.be",
"homepage": "https://spatie.be",
"role": "Developer"
}
],
"description": "A sane interface for php's built in preg_* functions",
"homepage": "https://github.com/spatie/regex",
"keywords": [
"expression",
"expressions",
"regex",
"regular",
"spatie"
],
"support": {
"issues": "https://github.com/spatie/regex/issues",
"source": "https://github.com/spatie/regex/tree/3.1.1"
},
"funding": [
{
"url": "https://spatie.be/open-source/support-us",
"type": "custom"
},
{
"url": "https://github.com/spatie",
"type": "github"
}
],
"time": "2021-11-30T21:13:59+00:00"
},
{ {
"name": "spatie/temporary-directory", "name": "spatie/temporary-directory",
"version": "2.2.1", "version": "2.2.1",
@ -13756,5 +13989,5 @@
"ext-zip": "*" "ext-zip": "*"
}, },
"platform-dev": [], "platform-dev": [],
"plugin-api-version": "2.3.0" "plugin-api-version": "2.6.0"
} }

123
config/health.php Normal file
View File

@ -0,0 +1,123 @@
<?php
return [
/*
* A result store is responsible for saving the results of the checks. The
* `EloquentHealthResultStore` will save results in the database. You
* can use multiple stores at the same time.
*/
'result_stores' => [
/*
Spatie\Health\ResultStores\EloquentHealthResultStore::class => [
'connection' => env('HEALTH_DB_CONNECTION', env('DB_CONNECTION')),
'model' => Spatie\Health\Models\HealthCheckResultHistoryItem::class,
'keep_history_for_days' => 5,
],
*/
Spatie\Health\ResultStores\CacheHealthResultStore::class => [
'store' => 'file',
],
/*
Spatie\Health\ResultStores\JsonFileHealthResultStore::class => [
'disk' => 's3',
'path' => 'health.json',
],
Spatie\Health\ResultStores\InMemoryHealthResultStore::class,
*/
],
/*
* You can get notified when specific events occur. Out of the box you can use 'mail' and 'slack'.
* For Slack you need to install laravel/slack-notification-channel.
*/
'notifications' => [
/*
* Notifications will only get sent if this option is set to `true`.
*/
'enabled' => false,
'notifications' => [
Spatie\Health\Notifications\CheckFailedNotification::class => ['mail'],
],
/*
* Here you can specify the notifiable to which the notifications should be sent. The default
* notifiable will use the variables specified in this config file.
*/
'notifiable' => Spatie\Health\Notifications\Notifiable::class,
/*
* When checks start failing, you could potentially end up getting
* a notification every minute.
*
* With this setting, notifications are throttled. By default, you'll
* only get one notification per hour.
*/
'throttle_notifications_for_minutes' => 60,
'throttle_notifications_key' => 'health:latestNotificationSentAt:',
'mail' => [
'to' => 'your@example.com',
'from' => [
'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'),
'name' => env('MAIL_FROM_NAME', 'Example'),
],
],
'slack' => [
'webhook_url' => env('HEALTH_SLACK_WEBHOOK_URL', ''),
/*
* If this is set to null the default channel of the webhook will be used.
*/
'channel' => null,
'username' => null,
'icon' => null,
],
],
/*
* You can let Oh Dear monitor the results of all health checks. This way, you'll
* get notified of any problems even if your application goes totally down. Via
* Oh Dear, you can also have access to more advanced notification options.
*/
'oh_dear_endpoint' => [
'enabled' => false,
/*
* When this option is enabled, the checks will run before sending a response.
* Otherwise, we'll send the results from the last time the checks have run.
*/
'always_send_fresh_results' => true,
/*
* The secret that is displayed at the Application Health settings at Oh Dear.
*/
'secret' => env('OH_DEAR_HEALTH_CHECK_SECRET'),
/*
* The URL that should be configured in the Application health settings at Oh Dear.
*/
'url' => '/oh-dear-health-check-results',
],
/*
* You can set a theme for the local results page
*
* - light: light mode
* - dark: dark mode
*/
'theme' => 'light',
/*
* When enabled, completed `HealthQueueJob`s will be displayed
* in Horizon's silenced jobs screen.
*/
'silence_health_queue_job' => true,
];

View File

@ -0,0 +1,71 @@
@php
if(! function_exists('backgroundColor')) {
function backgroundColor($status) {
return match ($status) {
Spatie\Health\Enums\Status::ok()->value => 'background-color: rgb(209 250 229);', // bg-emerald-100
Spatie\Health\Enums\Status::warning()->value => 'background-color: rgb(254 249 195);', // bg-yellow-100
Spatie\Health\Enums\Status::skipped()->value => 'background-color: rgb(219 234 254);', // bg-blue-100
Spatie\Health\Enums\Status::failed()->value, Spatie\Health\Enums\Status::crashed()->value => 'background-color: rgb(254 226 226);', // bg-red-100
default => 'background-color: rgb(243 244 246);' // bg-gray-100
};
}
}
if(! function_exists('iconColor')) {
function iconColor($status)
{
return match ($status) {
Spatie\Health\Enums\Status::ok()->value => 'color: rgb(16 185 129);', // text-emerald-500
Spatie\Health\Enums\Status::warning()->value => 'color: rgb(234 179 8);', // text-yellow-500
Spatie\Health\Enums\Status::skipped()->value => 'color: rgb(59 130 246);', // text-blue-500
Spatie\Health\Enums\Status::failed()->value, Spatie\Health\Enums\Status::crashed()->value => 'color: rgb(239 68 68);', // text-red-500
default => 'color: rgb(107 114 128);' // text-gray-500
};
}
}
if(! function_exists('icon')) {
function icon($status)
{
return match ($status) {
Spatie\Health\Enums\Status::ok()->value => 'tabler-circle-check',
Spatie\Health\Enums\Status::warning()->value => 'tabler-exclamation-circle',
Spatie\Health\Enums\Status::skipped()->value => 'tabler-circle-chevron-right',
Spatie\Health\Enums\Status::failed()->value, Spatie\Health\Enums\Status::crashed()->value => 'tabler-circle-x',
default => 'tabler-help-circle'
};
}
}
@endphp
<x-filament-panels::page>
@if (count($checkResults?->storedCheckResults ?? []))
<x-filament::grid default="1" sm="2" class="gap-6 mb-5">
@foreach ($checkResults->storedCheckResults as $result)
<div class="flex items-start px-4 space-x-2 overflow-hidden py-5 text-opacity-0 transition transform bg-white shadow-md shadow-gray-200 dark:shadow-black/25 dark:shadow-md dark:bg-gray-800 rounded-xl sm:p-6 md:space-x-3 md:min-h-[130px] dark:border-t dark:border-gray-700">
<div class="flex justify-center items-center rounded-full p-2" style="margin-right: 0.5rem; {{ backgroundColor($result->status) }}">
<x-filament::icon icon="{{ icon($result->status) }}" class="h-6 w-6" style="{{ iconColor($result->status) }}" />
</div>
<div>
<dd class="-mt-1 font-bold md:mt-1 md:text-xl" style="color: light-dark(rgb(17 24 39), rgb(255 255 255));">
{{ $result->label }}
</dd>
<dt class="mt-0 text-sm font-medium md:mt-1" style="color: light-dark(rgb(75 85 99), rgb(209 213 219));">
@if (!empty($result->notificationMessage))
{{ $result->notificationMessage }}
@else
{{ $result->shortSummary }}
@endif
</dt>
</div>
</div>
@endforeach
</x-filament::grid>
@endif
@if ($lastRanAt)
<div class="text-md text-center font-medium" style="{{ $lastRanAt->diffInMinutes() > 5 ? 'color: rgb(239 68 68);' : 'color: light-dark(rgb(156 163 175), rgb(229 231 235));' }}">
Check results from {{ $lastRanAt->diffForHumans() }}
</div>
@endif
</x-filament-panels::page>