diff --git a/app/Actions/Notifications/SendAppriseTestNotification.php b/app/Actions/Notifications/SendAppriseTestNotification.php new file mode 100644 index 000000000..062202b06 --- /dev/null +++ b/app/Actions/Notifications/SendAppriseTestNotification.php @@ -0,0 +1,45 @@ +title('You need to add Apprise channel URLs!') + ->warning() + ->send(); + + return; + } + + foreach ($channel_urls as $row) { + $channelUrl = $row['channel_url'] ?? null; + if (! $channelUrl) { + Notification::make() + ->title('Skipping missing channel URL!') + ->warning() + ->send(); + + continue; + } + + FacadesNotification::route('apprise_urls', $channelUrl) + ->notify(new TestNotification); + } + + Notification::make() + ->title('Test Apprise notification sent.') + ->success() + ->send(); + } +} diff --git a/app/Console/Commands/TestNotification.php b/app/Console/Commands/TestNotification.php new file mode 100644 index 000000000..007d89092 --- /dev/null +++ b/app/Console/Commands/TestNotification.php @@ -0,0 +1,40 @@ +argument('type'); + $channel = $this->option('channel'); + + $this->info("Creating fake result for type: {$type}"); + + $result = Result::factory()->create([ + 'status' => 'completed', + ]); + + $this->info("Dispatching {$channel} notification..."); + + match ("{$channel}-{$type}") { + 'apprise-completed' => AppriseCompleted::dispatch($result), + 'apprise-threshold' => AppriseThreshold::dispatch($result), + }; + + $this->info('✅ Notification dispatched!'); + + return self::SUCCESS; + } +} diff --git a/app/Filament/Pages/Settings/NotificationPage.php b/app/Filament/Pages/Settings/NotificationPage.php index 83f8e03ca..75b50c3c9 100755 --- a/app/Filament/Pages/Settings/NotificationPage.php +++ b/app/Filament/Pages/Settings/NotificationPage.php @@ -2,6 +2,7 @@ namespace App\Filament\Pages\Settings; +use App\Actions\Notifications\SendAppriseTestNotification; use App\Actions\Notifications\SendDatabaseTestNotification; use App\Actions\Notifications\SendDiscordTestNotification; use App\Actions\Notifications\SendGotifyTestNotification; @@ -16,15 +17,17 @@ use Filament\Forms; use Filament\Forms\Components\Actions; use Filament\Forms\Components\Actions\Action; +use Filament\Forms\Components\Checkbox; use Filament\Forms\Components\Fieldset; use Filament\Forms\Components\Grid; use Filament\Forms\Components\Repeater; use Filament\Forms\Components\Section; +use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Toggle; use Filament\Forms\Form; use Filament\Pages\SettingsPage; -use Filament\Support\Enums\MaxWidth; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\HtmlString; class NotificationPage extends SettingsPage { @@ -50,480 +53,557 @@ public static function shouldRegisterNavigation(): bool return Auth::check() && Auth::user()->is_admin; } - public function getMaxContentWidth(): MaxWidth - { - return MaxWidth::ThreeExtraLarge; - } - public function form(Form $form): Form { return $form ->schema([ - Section::make('Database') - ->description('Notifications sent to this channel will show up under the 🔔 icon in the header.') + Grid::make([ + 'default' => 1, + 'md' => 3, + ]) ->schema([ - Toggle::make('database_enabled') - ->label('Enable database notifications') - ->reactive() - ->columnSpanFull(), Grid::make([ 'default' => 1, ]) - ->hidden(fn (Forms\Get $get) => $get('database_enabled') !== true) ->schema([ - Fieldset::make('Triggers') + Section::make('Database') + ->description('Notifications sent to this channel will show up under the 🔔 icon in the header.') ->schema([ - Toggle::make('database_on_speedtest_run') - ->label('Notify on every speedtest run') - ->columnSpanFull(), - Toggle::make('database_on_threshold_failure') - ->label('Notify on threshold failures') + Toggle::make('database_enabled') + ->label('Enable database notifications') + ->reactive() ->columnSpanFull(), + Grid::make([ + 'default' => 1, + ]) + ->hidden(fn (Forms\Get $get) => $get('database_enabled') !== true) + ->schema([ + Fieldset::make('Triggers') + ->schema([ + Toggle::make('database_on_speedtest_run') + ->label('Notify on every speedtest run') + ->columnSpanFull(), + Toggle::make('database_on_threshold_failure') + ->label('Notify on threshold failures') + ->columnSpanFull(), + ]), + Actions::make([ + Action::make('test database') + ->label('Test database channel') + ->action(fn () => SendDatabaseTestNotification::run(user: Auth::user())), + ]), + ]), + ]) + ->compact() + ->columns([ + 'default' => 1, + 'md' => 2, ]), - Actions::make([ - Action::make('test database') - ->label('Test database channel') - ->action(fn () => SendDatabaseTestNotification::run(user: Auth::user())), - ]), - ]), - ]) - ->compact() - ->columns([ - 'default' => 1, - 'md' => 2, - ]), - Section::make('Mail') - ->schema([ - Toggle::make('mail_enabled') - ->label('Enable mail notifications') - ->reactive() - ->columnSpanFull(), - Grid::make([ - 'default' => 1, - ]) - ->hidden(fn (Forms\Get $get) => $get('mail_enabled') !== true) - ->schema([ - Fieldset::make('Triggers') + Section::make('Apprise') + ->description('The Apprise Notification Library enables sending notifications to a wide range of services.') ->schema([ - Toggle::make('mail_on_speedtest_run') - ->label('Notify on every speedtest run') - ->columnSpanFull(), - Toggle::make('mail_on_threshold_failure') - ->label('Notify on threshold failures') + Toggle::make('apprise_enabled') + ->label('Enable Apprise Notifications') + ->reactive() ->columnSpanFull(), - ]), - Repeater::make('mail_recipients') - ->label('Recipients') - ->schema([ - Forms\Components\TextInput::make('email_address') - ->placeholder('your@email.com') - ->email() - ->required(), + Grid::make([ + 'default' => 1, + ]) + ->hidden(fn (Forms\Get $get) => $get('apprise_enabled') !== true) + ->schema([ + Fieldset::make('Triggers') + ->schema([ + Toggle::make('apprise_on_speedtest_run') + ->label('Notify on every speedtest run') + ->columnSpanFull(), + Toggle::make('apprise_on_threshold_failure') + ->label('Notify on threshold failures') + ->columnSpanFull(), + ]), + Fieldset::make('Apprise Sidecar') + ->schema([ + Checkbox::make('apprise_verify_ssl') + ->label('Verify SSL') + ->default(true) + ->columnSpanFull(), + ]), + Repeater::make('apprise_channel_urls') + ->label('Apprise Channels') + ->hint(new HtmlString('Apprise Documentation')) + ->schema([ + TextInput::make('channel_url') + ->label('Channel URL') + ->placeholder('discord://WebhookID/WebhookToken') + ->helperText('Provide the service endpoint URL for notifications.') + ->maxLength(2000) + ->distinct() + ->required(), + ]) + ->columnSpanFull(), + Actions::make([ + Action::make('test apprise') + ->label('Test Apprise') + ->action(fn (Forms\Get $get) => SendAppriseTestNotification::run( + channel_urls: $get('apprise_channel_urls'), + )) + ->hidden(fn (Forms\Get $get) => ! count($get('apprise_channel_urls'))), + ]), + ]), ]) - ->columnSpanFull(), - Actions::make([ - Action::make('test mail') - ->label('Test mail channel') - ->action(fn (Forms\Get $get) => SendMailTestNotification::run(recipients: $get('mail_recipients'))) - ->hidden(fn (Forms\Get $get) => ! count($get('mail_recipients'))), - ]), - ]), - ]) - ->compact() - ->columns([ - 'default' => 1, - 'md' => 2, - ]), - - Section::make('Webhook') - ->schema([ - Toggle::make('webhook_enabled') - ->label('Enable webhook notifications') - ->reactive() - ->columnSpanFull(), - Grid::make([ - 'default' => 1, - ]) - ->hidden(fn (Forms\Get $get) => $get('webhook_enabled') !== true) - ->schema([ - Fieldset::make('Triggers') - ->schema([ - Toggle::make('webhook_on_speedtest_run') - ->label('Notify on every speedtest run') - ->columnSpan(2), - Toggle::make('webhook_on_threshold_failure') - ->label('Notify on threshold failures') - ->columnSpan(2), + ->compact() + ->columns([ + 'default' => 1, + 'md' => 2, ]), - Repeater::make('webhook_urls') - ->label('Recipients') - ->schema([ - Forms\Components\TextInput::make('url') - ->placeholder('https://webhook.site/longstringofcharacters') - ->maxLength(2000) - ->required() - ->url(), - ]) - ->columnSpanFull(), - Actions::make([ - Action::make('test webhook') - ->label('Test webhook channel') - ->action(fn (Forms\Get $get) => SendWebhookTestNotification::run(webhooks: $get('webhook_urls'))) - ->hidden(fn (Forms\Get $get) => ! count($get('webhook_urls'))), - ]), - ]), - ]) - ->compact() - ->columns([ - 'default' => 1, - 'md' => 2, - ]), - Section::make('Pushover') - ->schema([ - Toggle::make('pushover_enabled') - ->label('Enable Pushover webhook notifications') - ->reactive() - ->columnSpanFull(), - Grid::make([ - 'default' => 1, - ]) - ->hidden(fn (Forms\Get $get) => $get('pushover_enabled') !== true) - ->schema([ - Fieldset::make('Triggers') + Section::make('Mail') ->schema([ - Toggle::make('pushover_on_speedtest_run') - ->label('Notify on every speedtest run') - ->columnSpanFull(), - Toggle::make('pushover_on_threshold_failure') - ->label('Notify on threshold failures') + Toggle::make('mail_enabled') + ->label('Enable Mail Notifications') + ->reactive() ->columnSpanFull(), - ]), - Repeater::make('pushover_webhooks') - ->label('Pushover Webhooks') - ->schema([ - Forms\Components\TextInput::make('url') - ->label('URL') - ->placeholder('http://api.pushover.net/1/messages.json') - ->maxLength(2000) - ->required() - ->url(), - Forms\Components\TextInput::make('user_key') - ->label('User Key') - ->placeholder('Your Pushover User Key') - ->maxLength(200) - ->required(), - Forms\Components\TextInput::make('api_token') - ->label('API Token') - ->placeholder('Your Pushover API Token') - ->maxLength(200) - ->required(), + Grid::make([ + 'default' => 1, + ]) + ->hidden(fn (Forms\Get $get) => $get('mail_enabled') !== true) + ->schema([ + Fieldset::make('Triggers') + ->schema([ + Toggle::make('mail_on_speedtest_run') + ->label('Notify on every speedtest run') + ->columnSpanFull(), + Toggle::make('mail_on_threshold_failure') + ->label('Notify on threshold failures') + ->columnSpanFull(), + ]), + Repeater::make('mail_recipients') + ->label('Recipients') + ->schema([ + TextInput::make('email_address') + ->placeholder('your@email.com') + ->email() + ->required(), + ]) + ->columnSpanFull(), + Actions::make([ + Action::make('test mail') + ->label('Test mail channel') + ->action(fn (Forms\Get $get) => SendMailTestNotification::run(recipients: $get('mail_recipients'))) + ->hidden(fn (Forms\Get $get) => ! count($get('mail_recipients'))), + ]), + ]), ]) - ->columnSpanFull(), - Actions::make([ - Action::make('test pushover') - ->label('Test Pushover webhook') - ->action(fn (Forms\Get $get) => SendPushoverTestNotification::run( - webhooks: $get('pushover_webhooks') - )) - ->hidden(fn (Forms\Get $get) => ! count($get('pushover_webhooks'))), - ]), - ]), - ]) - ->compact() - ->columns([ - 'default' => 1, - 'md' => 2, - ]), + ->compact() + ->columns([ + 'default' => 1, + 'md' => 2, + ]), - Section::make('Discord') - ->schema([ - Toggle::make('discord_enabled') - ->label('Enable Discord webhook notifications') - ->reactive() - ->columnSpanFull(), - Grid::make([ - 'default' => 1, - ]) - ->hidden(fn (Forms\Get $get) => $get('discord_enabled') !== true) - ->schema([ - Fieldset::make('Triggers') + Section::make('Webhook') ->schema([ - Toggle::make('discord_on_speedtest_run') - ->label('Notify on every speedtest run') + Toggle::make('webhook_enabled') + ->label('Enable Webhook Notifications') + ->reactive() ->columnSpanFull(), - Toggle::make('discord_on_threshold_failure') - ->label('Notify on threshold failures') - ->columnSpanFull(), - ]), - Repeater::make('discord_webhooks') - ->label('Webhooks') - ->schema([ - Forms\Components\TextInput::make('url') - ->placeholder('https://discord.com/api/webhooks/longstringofcharacters') - ->maxLength(2000) - ->required() - ->url(), + Grid::make([ + 'default' => 1, + ]) + ->hidden(fn (Forms\Get $get) => $get('webhook_enabled') !== true) + ->schema([ + Fieldset::make('Triggers') + ->schema([ + Toggle::make('webhook_on_speedtest_run') + ->label('Notify on every speedtest run') + ->columnSpan(2), + Toggle::make('webhook_on_threshold_failure') + ->label('Notify on threshold failures') + ->columnSpan(2), + ]), + Repeater::make('webhook_urls') + ->label('Recipients') + ->schema([ + TextInput::make('url') + ->placeholder('https://webhook.site/longstringofcharacters') + ->maxLength(2000) + ->required() + ->url(), + ]) + ->columnSpanFull(), + Actions::make([ + Action::make('test webhook') + ->label('Test webhook channel') + ->action(fn (Forms\Get $get) => SendWebhookTestNotification::run(webhooks: $get('webhook_urls'))) + ->hidden(fn (Forms\Get $get) => ! count($get('webhook_urls'))), + ]), + ]), ]) - ->columnSpanFull(), - Actions::make([ - Action::make('test discord') - ->label('Test Discord webhook') - ->action(fn (Forms\Get $get) => SendDiscordTestNotification::run(webhooks: $get('discord_webhooks'))) - ->hidden(fn (Forms\Get $get) => ! count($get('discord_webhooks'))), - ]), - ]), - ]) - ->compact() - ->columns([ - 'default' => 1, - 'md' => 2, - ]), + ->compact() + ->columns([ + 'default' => 1, + 'md' => 2, + ]), - Section::make('Gotify') - ->schema([ - Toggle::make('gotify_enabled') - ->label('Enable Gotify webhook notifications') - ->reactive() - ->columnSpanFull(), - Grid::make([ - 'default' => 1, - ]) - ->hidden(fn (Forms\Get $get) => $get('gotify_enabled') !== true) - ->schema([ - Fieldset::make('Triggers') + Section::make('Pushover') + ->description('⚠️ Pushover is deprecated and will be removed in a future release.') ->schema([ - Toggle::make('gotify_on_speedtest_run') - ->label('Notify on every speedtest run') + Toggle::make('pushover_enabled') + ->label('Enable Pushover webhook notifications') + ->reactive() ->columnSpanFull(), - Toggle::make('gotify_on_threshold_failure') - ->label('Notify on threshold failures') - ->columnSpanFull(), - ]), - Repeater::make('gotify_webhooks') - ->label('Webhooks') - ->schema([ - Forms\Components\TextInput::make('url') - ->placeholder('https://example.com/message?token=') - ->maxLength(2000) - ->required() - ->url(), + Grid::make([ + 'default' => 1, + ]) + ->hidden(fn (Forms\Get $get) => $get('pushover_enabled') !== true) + ->schema([ + Fieldset::make('Triggers') + ->schema([ + Toggle::make('pushover_on_speedtest_run') + ->label('Notify on every speedtest run') + ->columnSpanFull(), + Toggle::make('pushover_on_threshold_failure') + ->label('Notify on threshold failures') + ->columnSpanFull(), + ]), + Repeater::make('pushover_webhooks') + ->label('Pushover Webhooks') + ->addable(false) + ->schema([ + TextInput::make('url') + ->label('URL') + ->placeholder('http://api.pushover.net/1/messages.json') + ->maxLength(2000) + ->required() + ->url(), + TextInput::make('user_key') + ->label('User Key') + ->placeholder('Your Pushover User Key') + ->maxLength(200) + ->required(), + TextInput::make('api_token') + ->label('API Token') + ->placeholder('Your Pushover API Token') + ->maxLength(200) + ->required(), + ]) + ->columnSpanFull(), + Actions::make([ + Action::make('test pushover') + ->label('Test Pushover webhook') + ->action(fn (Forms\Get $get) => SendPushoverTestNotification::run( + webhooks: $get('pushover_webhooks') + )) + ->hidden(fn (Forms\Get $get) => ! count($get('pushover_webhooks'))), + ]), + ]), ]) - ->columnSpanFull(), - Actions::make([ - Action::make('test gotify') - ->label('Test Gotify webhook') - ->action(fn (Forms\Get $get) => SendgotifyTestNotification::run(webhooks: $get('gotify_webhooks'))) - ->hidden(fn (Forms\Get $get) => ! count($get('gotify_webhooks'))), - ]), - ]), - ]) - ->compact() - ->columns([ - 'default' => 1, - 'md' => 2, - ]), + ->compact() + ->columns([ + 'default' => 1, + 'md' => 2, + ]), - Section::make('Slack') - ->schema([ - Toggle::make('slack_enabled') - ->label('Enable Slack webhook notifications') - ->reactive() - ->columnSpanFull(), - Grid::make([ - 'default' => 1, - ]) - ->hidden(fn (Forms\Get $get) => $get('slack_enabled') !== true) - ->schema([ - Fieldset::make('Triggers') + Section::make('Discord') + ->description('⚠️ Discord is deprecated and will be removed in a future release.') ->schema([ - Toggle::make('slack_on_speedtest_run') - ->label('Notify on every speedtest run') - ->columnSpanFull(), - Toggle::make('slack_on_threshold_failure') - ->label('Notify on threshold failures') + Toggle::make('discord_enabled') + ->label('Enable Discord webhook notifications') + ->reactive() ->columnSpanFull(), - ]), - Repeater::make('slack_webhooks') - ->label('Webhooks') - ->schema([ - Forms\Components\TextInput::make('url') - ->placeholder('https://hooks.slack.com/services/abc/xyz') - ->maxLength(2000) - ->required() - ->url(), + Grid::make([ + 'default' => 1, + ]) + ->hidden(fn (Forms\Get $get) => $get('discord_enabled') !== true) + ->schema([ + Fieldset::make('Triggers') + ->schema([ + Toggle::make('discord_on_speedtest_run') + ->label('Notify on every speedtest run') + ->columnSpanFull(), + Toggle::make('discord_on_threshold_failure') + ->label('Notify on threshold failures') + ->columnSpanFull(), + ]), + Repeater::make('discord_webhooks') + ->label('Webhooks') + ->addable(false) + ->schema([ + TextInput::make('url') + ->placeholder('https://discord.com/api/webhooks/longstringofcharacters') + ->maxLength(2000) + ->required() + ->url(), + ]) + ->columnSpanFull(), + Actions::make([ + Action::make('test discord') + ->label('Test Discord webhook') + ->action(fn (Forms\Get $get) => SendDiscordTestNotification::run(webhooks: $get('discord_webhooks'))) + ->hidden(fn (Forms\Get $get) => ! count($get('discord_webhooks'))), + ]), + ]), ]) - ->columnSpanFull(), - Actions::make([ - Action::make('test Slack') - ->label('Test slack webhook') - ->action(fn (Forms\Get $get) => SendSlackTestNotification::run(webhooks: $get('slack_webhooks'))) - ->hidden(fn (Forms\Get $get) => ! count($get('slack_webhooks'))), - ]), - ]), - ]) - ->compact() - ->columns([ - 'default' => 1, - 'md' => 2, - ]), + ->compact() + ->columns([ + 'default' => 1, + 'md' => 2, + ]), - Section::make('Ntfy') - ->schema([ - Toggle::make('ntfy_enabled') - ->label('Enable Ntfy webhook notifications') - ->reactive() - ->columnSpanFull(), - Grid::make([ - 'default' => 1, - ]) - ->hidden(fn (Forms\Get $get) => $get('ntfy_enabled') !== true) - ->schema([ - Fieldset::make('Triggers') + Section::make('Gotify') + ->description('⚠️ Gotify is deprecated and will be removed in a future release.') ->schema([ - Toggle::make('ntfy_on_speedtest_run') - ->label('Notify on every speedtest run') + Toggle::make('gotify_enabled') + ->label('Enable Gotify webhook notifications') + ->reactive() ->columnSpanFull(), - Toggle::make('ntfy_on_threshold_failure') - ->label('Notify on threshold failures') - ->columnSpanFull(), - ]), - Repeater::make('ntfy_webhooks') - ->label('Webhooks') - ->schema([ - Forms\Components\TextInput::make('url') - ->maxLength(2000) - ->placeholder('Your ntfy server url') - ->required() - ->url(), - Forms\Components\TextInput::make('topic') - ->label('Topic') - ->placeholder('Your ntfy Topic') - ->maxLength(200) - ->required(), - Forms\Components\TextInput::make('username') - ->label('Username') - ->placeholder('Username for Basic Auth (optional)') - ->maxLength(200), - Forms\Components\TextInput::make('password') - ->label('Password') - ->placeholder('Password for Basic Auth (optional)') - ->password() - ->maxLength(200), + Grid::make([ + 'default' => 1, + ]) + ->hidden(fn (Forms\Get $get) => $get('gotify_enabled') !== true) + ->schema([ + Fieldset::make('Triggers') + ->schema([ + Toggle::make('gotify_on_speedtest_run') + ->label('Notify on every speedtest run') + ->columnSpanFull(), + Toggle::make('gotify_on_threshold_failure') + ->label('Notify on threshold failures') + ->columnSpanFull(), + ]), + Repeater::make('gotify_webhooks') + ->label('Webhooks') + ->addable(false) + ->schema([ + TextInput::make('url') + ->placeholder('https://example.com/message?token=') + ->maxLength(2000) + ->required() + ->url(), + ]) + ->columnSpanFull(), + Actions::make([ + Action::make('test gotify') + ->label('Test Gotify webhook') + ->action(fn (Forms\Get $get) => SendgotifyTestNotification::run(webhooks: $get('gotify_webhooks'))) + ->hidden(fn (Forms\Get $get) => ! count($get('gotify_webhooks'))), + ]), + ]), ]) - ->columnSpanFull(), - Actions::make([ - Action::make('test ntfy') - ->label('Test Ntfy webhook') - ->action(fn (Forms\Get $get) => SendNtfyTestNotification::run(webhooks: $get('ntfy_webhooks'))) - ->hidden(fn (Forms\Get $get) => ! count($get('ntfy_webhooks'))), - ]), - ]), - ]) - ->compact() - ->columns([ - 'default' => 1, - 'md' => 2, - ]), + ->compact() + ->columns([ + 'default' => 1, + 'md' => 2, + ]), - Section::make('Healthcheck.io') - ->schema([ - Toggle::make('healthcheck_enabled') - ->label('Enable healthcheck.io webhook notifications') - ->reactive() - ->columnSpanFull(), - Grid::make([ - 'default' => 1, - ]) - ->hidden(fn (Forms\Get $get) => $get('healthcheck_enabled') !== true) - ->schema([ - Fieldset::make('Triggers') + Section::make('Slack') + ->description('⚠️ Slack is deprecated and will be removed in a future release.') ->schema([ - Toggle::make('healthcheck_on_speedtest_run') - ->label('Notify on every speedtest run') - ->columnSpanFull(), - Toggle::make('healthcheck_on_threshold_failure') - ->label('Notify on threshold failures') - ->helperText('Threshold notifications will be sent to the /fail path of the URL.') + Toggle::make('slack_enabled') + ->label('Enable Slack webhook notifications') + ->reactive() ->columnSpanFull(), - ]), - Repeater::make('healthcheck_webhooks') - ->label('webhooks') - ->schema([ - Forms\Components\TextInput::make('url') - ->placeholder('https://hc-ping.com/your-uuid-here') - ->maxLength(2000) - ->required() - ->url(), + Grid::make([ + 'default' => 1, + ]) + ->hidden(fn (Forms\Get $get) => $get('slack_enabled') !== true) + ->schema([ + Fieldset::make('Triggers') + ->schema([ + Toggle::make('slack_on_speedtest_run') + ->label('Notify on every speedtest run') + ->columnSpanFull(), + Toggle::make('slack_on_threshold_failure') + ->label('Notify on threshold failures') + ->columnSpanFull(), + ]), + Repeater::make('slack_webhooks') + ->label('Webhooks') + ->addable(false) + ->schema([ + TextInput::make('url') + ->placeholder('https://hooks.slack.com/services/abc/xyz') + ->maxLength(2000) + ->required() + ->url(), + ]) + ->columnSpanFull(), + Actions::make([ + Action::make('test Slack') + ->label('Test slack webhook') + ->action(fn (Forms\Get $get) => SendSlackTestNotification::run(webhooks: $get('slack_webhooks'))) + ->hidden(fn (Forms\Get $get) => ! count($get('slack_webhooks'))), + ]), + ]), ]) - ->columnSpanFull(), - Actions::make([ - Action::make('test healthcheck') - ->label('Test healthcheck.io webhook') - ->action(fn (Forms\Get $get) => SendHealthCheckTestNotification::run(webhooks: $get('healthcheck_webhooks'))) - ->hidden(fn (Forms\Get $get) => ! count($get('healthcheck_webhooks'))), - ]), - ]), - ]) - ->compact() - ->columns([ - 'default' => 1, - 'md' => 2, - ]), + ->compact() + ->columns([ + 'default' => 1, + 'md' => 2, + ]), - Section::make('Telegram') - ->schema([ - Toggle::make('telegram_enabled') - ->label('Enable telegram notifications') - ->reactive() - ->columnSpanFull(), - Grid::make([ - 'default' => 1, - ]) - ->hidden(fn (Forms\Get $get) => $get('telegram_enabled') !== true) - ->schema([ - Fieldset::make('Options') + Section::make('Ntfy') + ->description('⚠️ Ntfy is deprecated and will be removed in a future release.') ->schema([ - Toggle::make('telegram_disable_notification') - ->label('Send the message silently to the user') + Toggle::make('ntfy_enabled') + ->label('Enable Ntfy webhook notifications') + ->reactive() ->columnSpanFull(), + Grid::make([ + 'default' => 1, + ]) + ->hidden(fn (Forms\Get $get) => $get('ntfy_enabled') !== true) + ->schema([ + Fieldset::make('Triggers') + ->schema([ + Toggle::make('ntfy_on_speedtest_run') + ->label('Notify on every speedtest run') + ->columnSpanFull(), + Toggle::make('ntfy_on_threshold_failure') + ->label('Notify on threshold failures') + ->columnSpanFull(), + ]), + Repeater::make('ntfy_webhooks') + ->label('Webhooks') + ->addable(false) + ->schema([ + TextInput::make('url') + ->maxLength(2000) + ->placeholder('Your ntfy server url') + ->required() + ->url(), + TextInput::make('topic') + ->label('Topic') + ->placeholder('Your ntfy Topic') + ->maxLength(200) + ->required(), + TextInput::make('username') + ->label('Username') + ->placeholder('Username for Basic Auth (optional)') + ->maxLength(200), + TextInput::make('password') + ->label('Password') + ->placeholder('Password for Basic Auth (optional)') + ->password() + ->maxLength(200), + ]) + ->columnSpanFull(), + Actions::make([ + Action::make('test ntfy') + ->label('Test Ntfy webhook') + ->action(fn (Forms\Get $get) => SendNtfyTestNotification::run(webhooks: $get('ntfy_webhooks'))) + ->hidden(fn (Forms\Get $get) => ! count($get('ntfy_webhooks'))), + ]), + ]), + ]) + ->compact() + ->columns([ + 'default' => 1, + 'md' => 2, ]), - Fieldset::make('Triggers') + + Section::make('Healthcheck.io') + ->description('⚠️ Healthcheck.io is deprecated and will be removed in a future release.') ->schema([ - Toggle::make('telegram_on_speedtest_run') - ->label('Notify on every speedtest run') - ->columnSpanFull(), - Toggle::make('telegram_on_threshold_failure') - ->label('Notify on threshold failures') + Toggle::make('healthcheck_enabled') + ->label('Enable healthcheck.io webhook notifications') + ->reactive() ->columnSpanFull(), + Grid::make([ + 'default' => 1, + ]) + ->hidden(fn (Forms\Get $get) => $get('healthcheck_enabled') !== true) + ->schema([ + Fieldset::make('Triggers') + ->schema([ + Toggle::make('healthcheck_on_speedtest_run') + ->label('Notify on every speedtest run') + ->columnSpanFull(), + Toggle::make('healthcheck_on_threshold_failure') + ->label('Notify on threshold failures') + ->helperText('Threshold notifications will be sent to the /fail path of the URL.') + ->columnSpanFull(), + ]), + Repeater::make('healthcheck_webhooks') + ->label('webhooks') + ->addable(false) + ->schema([ + TextInput::make('url') + ->placeholder('https://hc-ping.com/your-uuid-here') + ->maxLength(2000) + ->required() + ->url(), + ]) + ->columnSpanFull(), + Actions::make([ + Action::make('test healthcheck') + ->label('Test healthcheck.io webhook') + ->action(fn (Forms\Get $get) => SendHealthCheckTestNotification::run(webhooks: $get('healthcheck_webhooks'))) + ->hidden(fn (Forms\Get $get) => ! count($get('healthcheck_webhooks'))), + ]), + ]), + ]) + ->compact() + ->columns([ + 'default' => 1, + 'md' => 2, ]), - Repeater::make('telegram_recipients') - ->label('Recipients') + + Section::make('Telegram') + ->description('⚠️ Telegram is deprecated and will be removed in a future release.') ->schema([ - Forms\Components\TextInput::make('telegram_chat_id') - ->placeholder('12345678910') - ->label('Telegram Chat ID') - ->maxLength(50) - ->required(), + Toggle::make('telegram_enabled') + ->label('Enable telegram notifications') + ->reactive() + ->columnSpanFull(), + Grid::make([ + 'default' => 1, + ]) + ->hidden(fn (Forms\Get $get) => $get('telegram_enabled') !== true) + ->schema([ + Fieldset::make('Options') + ->schema([ + Toggle::make('telegram_disable_notification') + ->label('Send the message silently to the user') + ->columnSpanFull(), + ]), + Fieldset::make('Triggers') + ->schema([ + Toggle::make('telegram_on_speedtest_run') + ->label('Notify on every speedtest run') + ->columnSpanFull(), + Toggle::make('telegram_on_threshold_failure') + ->label('Notify on threshold failures') + ->columnSpanFull(), + ]), + Repeater::make('telegram_recipients') + ->label('Recipients') + ->addable(false) + ->schema([ + TextInput::make('telegram_chat_id') + ->placeholder('12345678910') + ->label('Telegram Chat ID') + ->maxLength(50) + ->required(), + ]) + ->columnSpanFull(), + Actions::make([ + Action::make('test telegram') + ->label('Test Telegram channel') + ->action(fn (Forms\Get $get) => SendTelegramTestNotification::run(recipients: $get('telegram_recipients'))) + ->hidden(fn (Forms\Get $get) => ! count($get('telegram_recipients')) || blank(config('telegram.bot'))), + ]), + ]), ]) - ->columnSpanFull(), - Actions::make([ - Action::make('test telegram') - ->label('Test Telegram channel') - ->action(fn (Forms\Get $get) => SendTelegramTestNotification::run(recipients: $get('telegram_recipients'))) - ->hidden(fn (Forms\Get $get) => ! count($get('telegram_recipients')) || blank(config('telegram.bot'))), - ]), + ->compact() + ->columns([ + 'default' => 1, + 'md' => 2, + ]), + ]) + ->columnSpan([ + 'md' => 2, ]), - ]) - ->compact() - ->columns([ - 'default' => 1, - 'md' => 2, ]), - ]) - ->columns([ - 'default' => 1, ]); } } diff --git a/app/Listeners/Apprise/SendSpeedtestCompletedNotification.php b/app/Listeners/Apprise/SendSpeedtestCompletedNotification.php new file mode 100644 index 000000000..ca10b4e90 --- /dev/null +++ b/app/Listeners/Apprise/SendSpeedtestCompletedNotification.php @@ -0,0 +1,54 @@ +apprise_enabled) { + return; + } + + if (! $notificationSettings->apprise_on_speedtest_run) { + return; + } + + if (empty($notificationSettings->apprise_channel_urls) || ! is_array($notificationSettings->apprise_channel_urls)) { + Log::warning('Apprise service URLs not found; check Apprise notification settings.'); + + return; + } + + // Build the speedtest data + $data = SpeedtestNotificationData::make($event->result); + + $body = view('apprise.speedtest-completed', $data)->render(); + $title = 'Speedtest Completed – #'.$event->result->id; + + // Send notification to each configured channel URL + foreach ($notificationSettings->apprise_channel_urls as $row) { + $channelUrl = $row['channel_url'] ?? null; + if (! $channelUrl) { + Log::warning('Skipping entry with missing channel_url.'); + + continue; + } + + Notification::route('apprise_urls', $channelUrl) + ->notify(new SpeedtestNotification($title, $body, 'info')); + } + } +} diff --git a/app/Listeners/Apprise/SendSpeedtestThresholdNotification.php b/app/Listeners/Apprise/SendSpeedtestThresholdNotification.php new file mode 100644 index 000000000..ca1600486 --- /dev/null +++ b/app/Listeners/Apprise/SendSpeedtestThresholdNotification.php @@ -0,0 +1,139 @@ +apprise_enabled) { + return; + } + + if (! $notificationSettings->apprise_on_threshold_failure) { + return; + } + + if (empty($notificationSettings->apprise_channel_urls) || ! is_array($notificationSettings->apprise_channel_urls)) { + Log::warning('Apprise service URLs not found; check Apprise notification settings.'); + + return; + } + + $thresholdSettings = new ThresholdSettings; + + if (! $thresholdSettings->absolute_enabled) { + return; + } + + $failed = []; + + if ($thresholdSettings->absolute_download > 0) { + array_push($failed, $this->absoluteDownloadThreshold(event: $event, thresholdSettings: $thresholdSettings)); + } + + if ($thresholdSettings->absolute_upload > 0) { + array_push($failed, $this->absoluteUploadThreshold(event: $event, thresholdSettings: $thresholdSettings)); + } + + if ($thresholdSettings->absolute_ping > 0) { + array_push($failed, $this->absolutePingThreshold(event: $event, thresholdSettings: $thresholdSettings)); + } + + $failed = array_filter($failed); + + if (! count($failed)) { + Log::warning('Failed Apprise thresholds not found, won\'t send notification.'); + + return; + } + + $body = view('apprise.speedtest-threshold', [ + 'id' => $event->result->id, + 'service' => Str::title($event->result->service->getLabel()), + 'serverName' => $event->result->server_name, + 'serverId' => $event->result->server_id, + 'isp' => $event->result->isp, + 'metrics' => $failed, + 'speedtest_url' => $event->result->result_url, + 'url' => url('/admin/results'), + ])->render(); + + $title = 'Speedtest Threshold Breach – #'.$event->result->id; + + // Send notification to each configured channel URL + foreach ($notificationSettings->apprise_channel_urls as $row) { + $channelUrl = $row['channel_url'] ?? null; + if (! $channelUrl) { + Log::warning('Skipping entry with missing channel_url.'); + + continue; + } + + Notification::route('apprise_urls', $channelUrl) + ->notify(new SpeedtestNotification($title, $body, 'warning')); + } + } + + /** + * Build Apprise notification if absolute download threshold is breached. + */ + protected function absoluteDownloadThreshold(SpeedtestCompleted $event, ThresholdSettings $thresholdSettings): bool|array + { + if (! absoluteDownloadThresholdFailed($thresholdSettings->absolute_download, $event->result->download)) { + return false; + } + + return [ + 'name' => 'Download', + 'threshold' => $thresholdSettings->absolute_download.' Mbps', + 'value' => Number::toBitRate(bits: $event->result->download_bits, precision: 2), + ]; + } + + /** + * Build Apprise notification if absolute upload threshold is breached. + */ + protected function absoluteUploadThreshold(SpeedtestCompleted $event, ThresholdSettings $thresholdSettings): bool|array + { + if (! absoluteUploadThresholdFailed($thresholdSettings->absolute_upload, $event->result->upload)) { + return false; + } + + return [ + 'name' => 'Upload', + 'threshold' => $thresholdSettings->absolute_upload.' Mbps', + 'value' => Number::toBitRate(bits: $event->result->upload_bits, precision: 2), + ]; + } + + /** + * Build Apprise notification if absolute ping threshold is breached. + */ + protected function absolutePingThreshold(SpeedtestCompleted $event, ThresholdSettings $thresholdSettings): bool|array + { + if (! absolutePingThresholdFailed($thresholdSettings->absolute_ping, $event->result->ping)) { + return false; + } + + return [ + 'name' => 'Ping', + 'threshold' => $thresholdSettings->absolute_ping.' ms', + 'value' => round($event->result->ping, 2).' ms', + ]; + } +} diff --git a/app/Listeners/Database/SendSpeedtestCompletedNotification.php b/app/Listeners/Database/SendSpeedtestCompletedNotification.php index 14ea66605..7908d3db7 100644 --- a/app/Listeners/Database/SendSpeedtestCompletedNotification.php +++ b/app/Listeners/Database/SendSpeedtestCompletedNotification.php @@ -6,6 +6,7 @@ use App\Models\User; use App\Settings\NotificationSettings; use Filament\Notifications\Notification; +use Illuminate\Support\Facades\Log; class SendSpeedtestCompletedNotification { @@ -25,6 +26,7 @@ public function handle(SpeedtestCompleted $event): void } foreach (User::all() as $user) { + Log::info('Notifying user', ['id' => $user->id, 'email' => $user->email]); Notification::make() ->title('Speedtest completed') ->success() diff --git a/app/Listeners/SpeedtestEventSubscriber.php b/app/Listeners/SpeedtestEventSubscriber.php index 7e81bc30e..4db7c2fa5 100644 --- a/app/Listeners/SpeedtestEventSubscriber.php +++ b/app/Listeners/SpeedtestEventSubscriber.php @@ -29,6 +29,7 @@ public function handleSpeedtestCompleted(SpeedtestCompleted $event): void { $settings = app(DataIntegrationSettings::class); + // Write to InfluxDB if enabled if ($settings->influxdb_v2_enabled) { WriteResult::dispatch($event->result); } diff --git a/app/Notifications/Apprise/AppriseMessage.php b/app/Notifications/Apprise/AppriseMessage.php new file mode 100644 index 000000000..a510ded7b --- /dev/null +++ b/app/Notifications/Apprise/AppriseMessage.php @@ -0,0 +1,66 @@ +urls = $urls; + + return $this; + } + + public function title(string $title): self + { + $this->title = $title; + + return $this; + } + + public function body(string $body): self + { + $this->body = $body; + + return $this; + } + + public function type(string $type): self + { + $this->type = $type; + + return $this; + } + + public function format(string $format): self + { + $this->format = $format; + + return $this; + } + + public function tag(string $tag): self + { + $this->tag = $tag; + + return $this; + } +} diff --git a/app/Notifications/Apprise/SpeedtestNotification.php b/app/Notifications/Apprise/SpeedtestNotification.php new file mode 100644 index 000000000..3c2ffb3cd --- /dev/null +++ b/app/Notifications/Apprise/SpeedtestNotification.php @@ -0,0 +1,40 @@ + + */ + public function via(object $notifiable): array + { + return ['apprise']; + } + + /** + * Get the Apprise message representation of the notification. + */ + public function toApprise(object $notifiable): AppriseMessage + { + return AppriseMessage::create() + ->urls($notifiable->routes['apprise_urls']) + ->title($this->title) + ->body($this->body) + ->type($this->type); + } +} diff --git a/app/Notifications/Apprise/TestNotification.php b/app/Notifications/Apprise/TestNotification.php new file mode 100644 index 000000000..f07810fcc --- /dev/null +++ b/app/Notifications/Apprise/TestNotification.php @@ -0,0 +1,34 @@ + + */ + public function via(object $notifiable): array + { + return ['apprise']; + } + + /** + * Get the Apprise message representation of the notification. + */ + public function toApprise(object $notifiable): AppriseMessage + { + return AppriseMessage::create() + ->urls($notifiable->routes['apprise_urls']) + ->title('Test Notification') + ->body('👋 Testing the Apprise notification channel.') + ->type('info'); + } +} diff --git a/app/Notifications/AppriseChannel.php b/app/Notifications/AppriseChannel.php new file mode 100644 index 000000000..759ea9586 --- /dev/null +++ b/app/Notifications/AppriseChannel.php @@ -0,0 +1,61 @@ +toApprise($notifiable); + + if (! $message) { + return; + } + + $appriseUrl = rtrim(config('services.apprise.url'), '/'); + $settings = app(NotificationSettings::class); + + try { + $request = Http::timeout(5) + ->withHeaders([ + 'Content-Type' => 'application/json', + ]); + + // If SSL verification is disabled in settings, skip it + if (! $settings->apprise_verify_ssl) { + $request = $request->withoutVerifying(); + } + + $response = $request->post("{$appriseUrl}/notify", [ + 'urls' => $message->urls, + 'title' => $message->title, + 'body' => $message->body, + 'type' => $message->type ?? 'info', + 'format' => $message->format ?? 'text', + 'tag' => $message->tag ?? null, + ]); + + if ($response->failed()) { + Log::error('Apprise notification failed', [ + 'status' => $response->status(), + 'body' => $response->body(), + ]); + } else { + Log::info("Apprise notification sent → instance: {$appriseUrl}"); + } + } catch (\Exception $e) { + Log::error('Apprise notification exception', [ + 'message' => $e->getMessage(), + ]); + } + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index e4ea6209f..ba434c79e 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -4,11 +4,14 @@ use App\Enums\UserRole; use App\Models\User; +use App\Notifications\AppriseChannel; use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Foundation\Console\AboutCommand; use Illuminate\Http\Request; +use Illuminate\Notifications\ChannelManager; use Illuminate\Support\Facades\Blade; use Illuminate\Support\Facades\Gate; +use Illuminate\Support\Facades\Notification; use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\URL; use Illuminate\Support\ServiceProvider; @@ -44,12 +47,25 @@ public function boot(): void $this->defineGates(); $this->forceHttps(); $this->setApiRateLimit(); + $this->registerNotificationChannels(); AboutCommand::add('Speedtest Tracker', fn () => [ 'Version' => config('speedtest.build_version'), ]); } + /** + * Register custom notification channels. + */ + protected function registerNotificationChannels(): void + { + Notification::resolved(function (ChannelManager $service) { + $service->extend('apprise', function ($app) { + return new AppriseChannel; + }); + }); + } + /** * Define custom if statements, these were added to make the blade templates more readable. * diff --git a/app/Services/Notifications/AppriseService.php b/app/Services/Notifications/AppriseService.php new file mode 100644 index 000000000..c57457b85 --- /dev/null +++ b/app/Services/Notifications/AppriseService.php @@ -0,0 +1,71 @@ +apprise_channel_urls) || + ! is_array($settings->apprise_channel_urls) + ) { + Log::warning('Apprise service URLs not found; check Apprise settings.'); + + return; + } + + $instance = rtrim($settings->apprise_url, '/'); + + foreach ($settings->apprise_channel_urls as $row) { + $channelUrl = $row['channel_url'] ?? null; + if (! $channelUrl) { + Log::warning('Skipping entry with missing channel_url.'); + + continue; + } + + // Merge the channel into the payload + $payload['urls'] = $channelUrl; + + try { + $request = Http::withHeaders([ + 'Content-Type' => 'application/json', + ]); + + // If SSL verification is disabled in settings, skip it + if (! $settings->apprise_verify_ssl) { + $request = $request->withoutVerifying(); + } + + $request->post($instance, $payload)->throw(); + + Log::info("Apprise notification sent → instance: {$instance} service: {$channelUrl}"); + } catch (\Throwable $e) { + Log::error("Apprise notification failed for channel {$channelUrl} via {$instance}: ".$e->getMessage()); + + $admins = User::where('role', UserRole::Admin)->get(); + Notification::make() + ->title('Apprise Notification Failure') + ->danger() + ->body("Failed to send notification to {$channelUrl}. Check logs for details.") + ->sendToDatabase($admins); + } + } + } +} diff --git a/app/Services/Notifications/SpeedtestNotificationData.php b/app/Services/Notifications/SpeedtestNotificationData.php new file mode 100644 index 000000000..aa4441a11 --- /dev/null +++ b/app/Services/Notifications/SpeedtestNotificationData.php @@ -0,0 +1,48 @@ + $result->id, + 'service' => Str::title($result->service->getLabel()), + 'serverName' => $result->server_name, + 'serverId' => $result->server_id, + 'isp' => $result->isp, + 'ping' => round($result->ping, 2).' ms', + 'download' => Number::toBitRate(bits: $result->download_bits, precision: 2), + 'upload' => Number::toBitRate(bits: $result->upload_bits, precision: 2), + 'packetLoss' => is_numeric($result->packet_loss) ? $result->packet_loss : 'n/a'.' %', + 'pingJitter' => $result->ping_jitter.' ms', + 'pingLow' => $result->ping_low.' ms', + 'pingHigh' => $result->ping_high.' ms', + 'downloadBytes' => $result->download_bytes, + 'downloadLatencyIqm' => $result->download_latency_iqm.' ms', + 'downloadLatencyLow' => $result->download_latency_low.' ms', + 'downloadLatencyHigh' => $result->download_latency_high.' ms', + 'downloadJitter' => $result->download_latency_jitter.' ms', + 'uploadBytes' => $result->upload_bytes, + 'uploadLatencyIqm' => $result->upload_latency_iqm.' ms', + 'uploadLatencyLow' => $result->upload_latency_low.' ms', + 'uploadLatencyHigh' => $result->upload_latency_high.' ms', + 'uploadJitter' => $result->upload_latency_jitter.' ms', + 'externalIp' => $result->ip_address, + 'serverHost' => $result->server_host, + 'serverPort' => $result->server_port, + 'serverLocation' => $result->server_location, + 'serverCountry' => $result->server_country, + 'serverIp' => $result->server_ip, + 'speedtest_url' => $result->result_url, + 'url' => url('/admin/results'), + 'metrics' => $failed, + 'app_name' => config('app.name'), + ]; + } +} diff --git a/app/Settings/NotificationSettings.php b/app/Settings/NotificationSettings.php index 0796be61a..08e58784d 100644 --- a/app/Settings/NotificationSettings.php +++ b/app/Settings/NotificationSettings.php @@ -86,6 +86,16 @@ class NotificationSettings extends Settings public ?array $gotify_webhooks; + public bool $apprise_enabled; + + public bool $apprise_on_speedtest_run; + + public bool $apprise_on_threshold_failure; + + public bool $apprise_verify_ssl; + + public ?array $apprise_channel_urls; + public static function group(): string { return 'notification'; diff --git a/config/services.php b/config/services.php index cf7db308c..20a57409b 100644 --- a/config/services.php +++ b/config/services.php @@ -2,6 +2,10 @@ return [ + 'apprise' => [ + 'url' => env('APPRISE_URL', 'http://apprise:8000'), + ], + 'telegram-bot-api' => [ 'token' => env('TELEGRAM_BOT_TOKEN'), ], diff --git a/database/settings/2024_12_31_164343_create_apprise_notification.php b/database/settings/2024_12_31_164343_create_apprise_notification.php new file mode 100644 index 000000000..8bd003475 --- /dev/null +++ b/database/settings/2024_12_31_164343_create_apprise_notification.php @@ -0,0 +1,15 @@ +migrator->add('notification.apprise_enabled', false); + $this->migrator->add('notification.apprise_on_speedtest_run', false); + $this->migrator->add('notification.apprise_on_threshold_failure', false); + $this->migrator->add('notification.apprise_verify_ssl', true); + $this->migrator->add('notification.apprise_channel_urls', null); + } +}; diff --git a/resources/views/apprise/speedtest-completed.blade.php b/resources/views/apprise/speedtest-completed.blade.php new file mode 100644 index 000000000..6363ee642 --- /dev/null +++ b/resources/views/apprise/speedtest-completed.blade.php @@ -0,0 +1,11 @@ +A new speedtest on {{ config('app.name') }} was completed using {{ $service }}. + +Server name: {{ $serverName }} +Server ID: {{ $serverId }} +ISP: {{ $isp }} +Ping: {{ $ping }} +Download: {{ $download }} +Upload: {{ $upload }} +Packet Loss: {{ $packetLoss }} % +Ookla Speedtest: {{ $speedtest_url }} +URL: {{ $url }} diff --git a/resources/views/apprise/speedtest-threshold.blade.php b/resources/views/apprise/speedtest-threshold.blade.php new file mode 100644 index 000000000..6d0bb4926 --- /dev/null +++ b/resources/views/apprise/speedtest-threshold.blade.php @@ -0,0 +1,7 @@ +A new speedtest on **{{ config('app.name') }}** was completed using **{{ $service }}** on **{{ $isp }}** but a threshold was breached. + +@foreach ($metrics as $item) +- **{{ $item['name'] }}** {{ $item['threshold'] }}: {{ $item['value'] }} +@endforeach +- **Ookla Speedtest:** {{ $speedtest_url }} +- **URL:** {{ $url }}