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 }}