Refactored database, mail and webhook notifications (#2439)

Co-authored-by: Alex Justesen <1144087+alexjustesen@users.noreply.github.com>
This commit is contained in:
Alex Justesen
2025-11-25 14:41:58 -06:00
committed by GitHub
parent ecb551d7e5
commit 4d28618663
28 changed files with 653 additions and 689 deletions
@@ -2,7 +2,7 @@
namespace App\Actions\Notifications;
use App\Mail\Test as TestMail;
use App\Mail\TestMail;
use Filament\Notifications\Notification;
use Illuminate\Support\Facades\Mail;
use Lorisleiva\Actions\Concerns\AsAction;
+135 -113
View File
@@ -13,7 +13,9 @@ use App\Actions\Notifications\SendSlackTestNotification;
use App\Actions\Notifications\SendTelegramTestNotification;
use App\Actions\Notifications\SendWebhookTestNotification;
use App\Settings\NotificationSettings;
use CodeWithDennis\SimpleAlert\Components\SimpleAlert;
use Filament\Actions\Action;
use Filament\Forms\Components\Checkbox;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
@@ -22,13 +24,16 @@ use Filament\Schemas\Components\Actions;
use Filament\Schemas\Components\Fieldset;
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Tabs;
use Filament\Schemas\Components\Tabs\Tab;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Illuminate\Support\Facades\Auth;
class Notification extends SettingsPage
{
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-bell';
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-bell-alert';
protected static string|\UnitEnum|null $navigationGroup = 'Settings';
@@ -60,6 +65,135 @@ class Notification extends SettingsPage
{
return $schema
->components([
Tabs::make()
->schema([
Tab::make(__('settings/notifications.database'))
->icon(Heroicon::OutlinedCircleStack)
->schema([
Toggle::make('database_enabled')
->label(__('general.enable'))
->live(),
Grid::make([
'default' => 1,
])
->hidden(fn (Get $get) => $get('database_enabled') !== true)
->schema([
Fieldset::make(__('settings.triggers'))
->columns(1)
->schema([
Checkbox::make('database_on_speedtest_run')
->label(__('settings/notifications.database_on_speedtest_run')),
Checkbox::make('database_on_threshold_failure')
->label(__('settings/notifications.database_on_threshold_failure')),
]),
Actions::make([
Action::make('test database')
->label(__('settings/notifications.test_database_channel'))
->action(fn () => SendDatabaseTestNotification::run(user: Auth::user())),
]),
]),
// ...
]),
Tab::make(__('settings/notifications.mail'))
->icon(Heroicon::OutlinedEnvelope)
->schema([
Toggle::make('mail_enabled')
->label(__('general.enable'))
->live(),
Grid::make([
'default' => 1,
])
->hidden(fn (Get $get) => $get('mail_enabled') !== true)
->schema([
Fieldset::make(__('settings.triggers'))
->columns(1)
->schema([
Checkbox::make('mail_on_speedtest_run')
->label(__('settings/notifications.mail_on_speedtest_run')),
Checkbox::make('mail_on_threshold_failure')
->label(__('settings/notifications.mail_on_threshold_failure')),
]),
Repeater::make('mail_recipients')
->label(__('settings/notifications.recipients'))
->schema([
TextInput::make('email_address')
->placeholder('your@email.com')
->email()
->required(),
]),
Actions::make([
Action::make('test mail')
->label(__('settings/notifications.test_mail_channel'))
->action(fn (Get $get) => SendMailTestNotification::run(recipients: $get('mail_recipients')))
->hidden(fn (Get $get) => ! count($get('mail_recipients'))),
]),
]),
// ...
]),
Tab::make(__('settings/notifications.webhook'))
->icon(Heroicon::OutlinedGlobeAlt)
->schema([
Toggle::make('webhook_enabled')
->label(__('general.enable'))
->live(),
Grid::make([
'default' => 1,
])
->hidden(fn (Get $get) => $get('webhook_enabled') !== true)
->schema([
Fieldset::make(__('settings.triggers'))
->columns(1)
->schema([
Checkbox::make('webhook_on_speedtest_run')
->label(__('settings/notifications.webhook_on_speedtest_run')),
Checkbox::make('webhook_on_threshold_failure')
->label(__('settings/notifications.webhook_on_threshold_failure')),
]),
Repeater::make('webhook_urls')
->label(__('settings/notifications.recipients'))
->schema([
TextInput::make('url')
->placeholder('https://webhook.site/longstringofcharacters')
->maxLength(2000)
->required()
->url(),
]),
Actions::make([
Action::make('test webhook')
->label(__('settings/notifications.test_webhook_channel'))
->action(fn (Get $get) => SendWebhookTestNotification::run(webhooks: $get('webhook_urls')))
->hidden(fn (Get $get) => ! count($get('webhook_urls'))),
]),
]),
// ...
]),
])
->columnSpanFull(),
// ! DEPRECATED CHANNELS
SimpleAlert::make('deprecation_warning')
->title('Deprecated Notification Channels')
->description('The following notification channels are deprecated and will be removed in a future release!')
->border()
->warning()
->columnSpanFull(),
Grid::make([
'default' => 1,
'md' => 3,
@@ -70,118 +204,6 @@ class Notification extends SettingsPage
'default' => 1,
])
->schema([
Section::make(__('settings/notifications.database'))
->description(__('settings/notifications.database_description'))
->schema([
Toggle::make('database_enabled')
->label(__('settings/notifications.enable_database_notifications'))
->reactive()
->columnSpanFull(),
Grid::make([
'default' => 1,
])
->hidden(fn (Get $get) => $get('database_enabled') !== true)
->schema([
Fieldset::make(__('settings.triggers'))
->schema([
Toggle::make('database_on_speedtest_run')
->label(__('settings/notifications.database_on_speedtest_run'))
->columnSpanFull(),
Toggle::make('database_on_threshold_failure')
->label(__('settings/notifications.database_on_threshold_failure'))
->columnSpanFull(),
]),
Actions::make([
Action::make('test database')
->label(__('settings/notifications.test_database_channel'))
->action(fn () => SendDatabaseTestNotification::run(user: Auth::user())),
]),
]),
])
->compact()
->columnSpan('full'),
Section::make(__('settings/notifications.mail'))
->schema([
Toggle::make('mail_enabled')
->label(__('settings/notifications.enable_mail_notifications'))
->reactive()
->columnSpanFull(),
Grid::make([
'default' => 1,
])
->hidden(fn (Get $get) => $get('mail_enabled') !== true)
->schema([
Fieldset::make(__('settings.triggers'))
->schema([
Toggle::make('mail_on_speedtest_run')
->label(__('settings/notifications.mail_on_speedtest_run'))
->columnSpanFull(),
Toggle::make('mail_on_threshold_failure')
->label(__('settings/notifications.mail_on_threshold_failure'))
->columnSpanFull(),
]),
Repeater::make('mail_recipients')
->label(__('settings/notifications.recipients'))
->schema([
TextInput::make('email_address')
->placeholder('your@email.com')
->email()
->required(),
])
->columnSpanFull(),
Actions::make([
Action::make('test mail')
->label(__('settings/notifications.test_mail_channel'))
->action(fn (Get $get) => SendMailTestNotification::run(recipients: $get('mail_recipients')))
->hidden(fn (Get $get) => ! count($get('mail_recipients'))),
]),
]),
])
->compact()
->columnSpan('full'),
Section::make(__('settings/notifications.webhook'))
->schema([
Toggle::make('webhook_enabled')
->label(__('settings/notifications.enable_webhook_notifications'))
->reactive()
->columnSpanFull(),
Grid::make([
'default' => 1,
])
->hidden(fn (Get $get) => $get('webhook_enabled') !== true)
->schema([
Fieldset::make(__('settings.triggers'))
->schema([
Toggle::make('webhook_on_speedtest_run')
->label(__('settings/notifications.webhook_on_speedtest_run'))
->columnSpan(2),
Toggle::make('webhook_on_threshold_failure')
->label(__('settings/notifications.webhook_on_threshold_failure'))
->columnSpan(2),
]),
Repeater::make('webhook_urls')
->label(__('settings/notifications.recipients'))
->schema([
TextInput::make('url')
->placeholder('https://webhook.site/longstringofcharacters')
->maxLength(2000)
->required()
->url(),
])
->columnSpanFull(),
Actions::make([
Action::make('test webhook')
->label(__('settings/notifications.test_webhook_channel'))
->action(fn (Get $get) => SendWebhookTestNotification::run(webhooks: $get('webhook_urls')))
->hidden(fn (Get $get) => ! count($get('webhook_urls'))),
]),
]),
])
->compact()
->columnSpan('full'),
Section::make('Pushover')
->description('⚠️ Pushover is deprecated and will be removed in a future release.')
->schema([
@@ -1,34 +0,0 @@
<?php
namespace App\Listeners\Database;
use App\Events\SpeedtestCompleted;
use App\Models\User;
use App\Settings\NotificationSettings;
use Filament\Notifications\Notification;
class SendSpeedtestCompletedNotification
{
/**
* Handle the event.
*/
public function handle(SpeedtestCompleted $event): void
{
$notificationSettings = new NotificationSettings;
if (! $notificationSettings->database_enabled) {
return;
}
if (! $notificationSettings->database_on_speedtest_run) {
return;
}
foreach (User::all() as $user) {
Notification::make()
->title(__('results.speedtest_completed'))
->success()
->sendToDatabase($user);
}
}
}
@@ -1,101 +0,0 @@
<?php
namespace App\Listeners\Database;
use App\Events\SpeedtestCompleted;
use App\Helpers\Number;
use App\Models\User;
use App\Settings\NotificationSettings;
use App\Settings\ThresholdSettings;
use Filament\Notifications\Notification;
class SendSpeedtestThresholdNotification
{
/**
* Handle the event.
*/
public function handle(SpeedtestCompleted $event): void
{
$notificationSettings = new NotificationSettings;
if (! $notificationSettings->database_enabled) {
return;
}
if (! $notificationSettings->database_on_threshold_failure) {
return;
}
$thresholdSettings = new ThresholdSettings;
if (! $thresholdSettings->absolute_enabled) {
return;
}
if ($thresholdSettings->absolute_download > 0) {
$this->absoluteDownloadThreshold(event: $event, thresholdSettings: $thresholdSettings);
}
if ($thresholdSettings->absolute_upload > 0) {
$this->absoluteUploadThreshold(event: $event, thresholdSettings: $thresholdSettings);
}
if ($thresholdSettings->absolute_ping > 0) {
$this->absolutePingThreshold(event: $event, thresholdSettings: $thresholdSettings);
}
}
/**
* Send database notification if absolute download threshold is breached.
*/
protected function absoluteDownloadThreshold(SpeedtestCompleted $event, ThresholdSettings $thresholdSettings): void
{
if (! absoluteDownloadThresholdFailed($thresholdSettings->absolute_download, $event->result->download)) {
return;
}
foreach (User::all() as $user) {
Notification::make()
->title(__('results.download_threshold_breached'))
->body('Speedtest #'.$event->result->id.' breached the download threshold of '.$thresholdSettings->absolute_download.' Mbps at '.Number::toBitRate($event->result->download_bits).'.')
->warning()
->sendToDatabase($user);
}
}
/**
* Send database notification if absolute upload threshold is breached.
*/
protected function absoluteUploadThreshold(SpeedtestCompleted $event, ThresholdSettings $thresholdSettings): void
{
if (! absoluteUploadThresholdFailed($thresholdSettings->absolute_upload, $event->result->upload)) {
return;
}
foreach (User::all() as $user) {
Notification::make()
->title(__('results.upload_threshold_breached'))
->body('Speedtest #'.$event->result->id.' breached the upload threshold of '.$thresholdSettings->absolute_upload.' Mbps at '.Number::toBitRate($event->result->upload_bits).'.')
->warning()
->sendToDatabase($user);
}
}
/**
* Send database notification if absolute upload threshold is breached.
*/
protected function absolutePingThreshold(SpeedtestCompleted $event, ThresholdSettings $thresholdSettings): void
{
if (! absolutePingThresholdFailed($thresholdSettings->absolute_ping, $event->result->ping)) {
return;
}
foreach (User::all() as $user) {
Notification::make()
->title(__('results.ping_threshold_breached'))
->body('Speedtest #'.$event->result->id.' breached the ping threshold of '.$thresholdSettings->absolute_ping.'ms at '.$event->result->ping.'ms.')
->warning()
->sendToDatabase($user);
}
}
}
@@ -1,39 +0,0 @@
<?php
namespace App\Listeners\Mail;
use App\Events\SpeedtestCompleted;
use App\Mail\SpeedtestCompletedMail;
use App\Settings\NotificationSettings;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
class SendSpeedtestCompletedNotification
{
/**
* Handle the event.
*/
public function handle(SpeedtestCompleted $event): void
{
$notificationSettings = new NotificationSettings;
if (! $notificationSettings->mail_enabled) {
return;
}
if (! $notificationSettings->mail_on_speedtest_run) {
return;
}
if (! count($notificationSettings->mail_recipients)) {
Log::warning('Mail recipients not found, check mail notification channel settings.');
return;
}
foreach ($notificationSettings->mail_recipients as $recipient) {
Mail::to($recipient)
->send(new SpeedtestCompletedMail($event->result));
}
}
}
@@ -1,117 +0,0 @@
<?php
namespace App\Listeners\Mail;
use App\Events\SpeedtestCompleted;
use App\Helpers\Number;
use App\Mail\SpeedtestThresholdMail;
use App\Settings\NotificationSettings;
use App\Settings\ThresholdSettings;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
class SendSpeedtestThresholdNotification
{
/**
* Handle the event.
*/
public function handle(SpeedtestCompleted $event): void
{
$notificationSettings = new NotificationSettings;
if (! $notificationSettings->mail_enabled) {
return;
}
if (! $notificationSettings->mail_on_threshold_failure) {
return;
}
if (! count($notificationSettings->mail_recipients) > 0) {
Log::warning('Mail recipients not found, check mail notification channel 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 mail thresholds not found, won\'t send notification.');
return;
}
foreach ($notificationSettings->mail_recipients as $recipient) {
Mail::to($recipient)
->send(new SpeedtestThresholdMail($event->result, $failed));
}
}
/**
* Build mail 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 mail 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 mail 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',
];
}
}
+129 -4
View File
@@ -3,13 +3,23 @@
namespace App\Listeners;
use App\Events\SpeedtestCompleted;
use App\Mail\CompletedSpeedtestMail;
use App\Models\Result;
use App\Models\User;
use App\Settings\NotificationSettings;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Spatie\WebhookServer\WebhookCall;
class ProcessCompletedSpeedtest
{
public function __construct(
public NotificationSettings $notificationSettings,
) {}
/**
* Handle the event.
*/
@@ -17,8 +27,53 @@ class ProcessCompletedSpeedtest
{
$result = $event->result;
if ($result->dispatched_by && ! $result->scheduled) {
$this->notifyDispatchingUser($result);
$result->loadMissing(['dispatchedBy']);
// $this->notifyAppriseChannels($result);
$this->notifyDatabaseChannels($result);
$this->notifyDispatchingUser($result);
$this->notifyMailChannels($result);
$this->notifyWebhookChannels($result);
}
/**
* Notify Apprise channels.
*/
private function notifyAppriseChannels(Result $result): void
{
// Don't send Apprise notification if dispatched by a user or test is unhealthy.
if (filled($result->dispatched_by) || ! $result->healthy) {
return;
}
//
}
/**
* Notify database channels.
*/
private function notifyDatabaseChannels(Result $result): void
{
// Don't send database notification if dispatched by a user or test is unhealthy.
if (filled($result->dispatched_by) || ! $result->healthy) {
return;
}
// Check if database notifications are enabled.
if (! $this->notificationSettings->database_enabled || ! $this->notificationSettings->database_on_speedtest_run) {
return;
}
foreach (User::all() as $user) {
Notification::make()
->title(__('results.speedtest_completed'))
->actions([
Action::make('view')
->label(__('general.view'))
->url(route('filament.admin.resources.results.index')),
])
->success()
->sendToDatabase($user);
}
}
@@ -27,9 +82,11 @@ class ProcessCompletedSpeedtest
*/
private function notifyDispatchingUser(Result $result): void
{
$user = User::find($result->dispatched_by);
if (empty($result->dispatched_by) || ! $result->healthy) {
return;
}
$user->notify(
$result->dispatchedBy->notify(
Notification::make()
->title(__('results.speedtest_completed'))
->actions([
@@ -41,4 +98,72 @@ class ProcessCompletedSpeedtest
->toDatabase(),
);
}
/**
* Notify mail channels.
*/
private function notifyMailChannels(Result $result): void
{
if (empty($result->dispatched_by) || ! $result->healthy) {
return;
}
if (! $this->notificationSettings->mail_enabled || ! $this->notificationSettings->mail_on_speedtest_run) {
return;
}
if (! count($this->notificationSettings->mail_recipients)) {
Log::warning('Mail recipients not found, check mail notification channel settings.');
return;
}
foreach ($this->notificationSettings->mail_recipients as $recipient) {
Mail::to($recipient)
->send(new CompletedSpeedtestMail($result));
}
}
/**
* Notify webhook channels.
*/
private function notifyWebhookChannels(Result $result): void
{
// Don't send webhook if dispatched by a user or test is unhealthy.
if (filled($result->dispatched_by) || ! $result->healthy) {
return;
}
// Check if webhook notifications are enabled.
if (! $this->notificationSettings->webhook_enabled || ! $this->notificationSettings->webhook_on_speedtest_run) {
return;
}
// Check if webhook urls are configured.
if (! count($this->notificationSettings->webhook_urls)) {
Log::warning('Webhook urls not found, check webhook notification channel settings.');
return;
}
foreach ($this->notificationSettings->webhook_urls as $url) {
WebhookCall::create()
->url($url['url'])
->payload([
'result_id' => $result->id,
'site_name' => config('app.name'),
'server_name' => Arr::get($result->data, 'server.name'),
'server_id' => Arr::get($result->data, 'server.id'),
'isp' => Arr::get($result->data, 'isp'),
'ping' => $result->ping,
'download' => $result->downloadBits,
'upload' => $result->uploadBits,
'packet_loss' => Arr::get($result->data, 'packetLoss'),
'speedtest_url' => Arr::get($result->data, 'result.url'),
'url' => url('/admin/results'),
])
->doNotSign()
->dispatch();
}
}
}
+20 -5
View File
@@ -4,7 +4,6 @@ namespace App\Listeners;
use App\Events\SpeedtestFailed;
use App\Models\Result;
use App\Models\User;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
@@ -17,9 +16,23 @@ class ProcessFailedSpeedtest
{
$result = $event->result;
if ($result->dispatched_by && ! $result->scheduled) {
$this->notifyDispatchingUser($result);
$result->loadMissing(['dispatchedBy']);
// $this->notifyAppriseChannels($result);
$this->notifyDispatchingUser($result);
}
/**
* Notify Apprise channels.
*/
private function notifyAppriseChannels(Result $result): void
{
// Don't send Apprise notification if dispatched by a user or test is unhealthy.
if (filled($result->dispatched_by) || ! $result->healthy) {
return;
}
//
}
/**
@@ -27,9 +40,11 @@ class ProcessFailedSpeedtest
*/
private function notifyDispatchingUser(Result $result): void
{
$user = User::find($result->dispatched_by);
if (empty($result->dispatched_by)) {
return;
}
$user->notify(
$result->dispatchedBy->notify(
Notification::make()
->title(__('results.speedtest_failed'))
->actions([
+169
View File
@@ -0,0 +1,169 @@
<?php
namespace App\Listeners;
use App\Events\SpeedtestBenchmarkFailed;
use App\Mail\UnhealthySpeedtestMail;
use App\Models\Result;
use App\Models\User;
use App\Settings\NotificationSettings;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Spatie\WebhookServer\WebhookCall;
class ProcessUnhealthySpeedtest
{
/**
* Create the event listener.
*/
public function __construct(
public NotificationSettings $notificationSettings,
) {}
/**
* Handle the event.
*/
public function handle(SpeedtestBenchmarkFailed $event): void
{
$result = $event->result;
$result->loadMissing(['dispatchedBy']);
// $this->notifyAppriseChannels($result);
$this->notifyDatabaseChannels($result);
$this->notifyDispatchingUser($result);
$this->notifyMailChannels($result);
$this->notifyWebhookChannels($result);
}
/**
* Notify Apprise channels.
*/
private function notifyAppriseChannels(Result $result): void
{
// Don't send Apprise notification if dispatched by a user.
if (filled($result->dispatched_by)) {
return;
}
//
}
/**
* Notify database channels.
*/
private function notifyDatabaseChannels(Result $result): void
{
// Don't send database notification if dispatched by a user.
if (filled($result->dispatched_by)) {
return;
}
// Check if database notifications are enabled.
if (! $this->notificationSettings->database_enabled || ! $this->notificationSettings->database_on_threshold_failure) {
return;
}
foreach (User::all() as $user) {
Notification::make()
->title(__('results.speedtest_benchmark_failed'))
->actions([
Action::make('view')
->label(__('general.view'))
->url(route('filament.admin.resources.results.index')),
])
->success()
->sendToDatabase($user);
}
}
/**
* Notify the user who dispatched the speedtest.
*/
private function notifyDispatchingUser(Result $result): void
{
if (empty($result->dispatched_by)) {
return;
}
$result->dispatchedBy->notify(
Notification::make()
->title(__('results.speedtest_benchmark_failed'))
->actions([
Action::make('view')
->label(__('general.view'))
->url(route('filament.admin.resources.results.index')),
])
->warning()
->toDatabase(),
);
}
/**
* Notify mail channels.
*/
private function notifyMailChannels(Result $result): void
{
// Don't send webhook if dispatched by a user.
if (filled($result->dispatched_by)) {
return;
}
// Check if mail notifications are enabled.
if (! $this->notificationSettings->mail_enabled || ! $this->notificationSettings->mail_on_threshold_failure) {
return;
}
// Check if mail recipients are configured.
if (! count($this->notificationSettings->mail_recipients)) {
Log::warning('Mail recipients not found, check mail notification channel settings.');
return;
}
foreach ($this->notificationSettings->mail_recipients as $recipient) {
Mail::to($recipient)
->send(new UnhealthySpeedtestMail($result));
}
}
/**
* Notify webhook channels.
*/
private function notifyWebhookChannels(Result $result): void
{
// Don't send webhook if dispatched by a user.
if (filled($result->dispatched_by)) {
return;
}
// Check if webhook notifications are enabled.
if (! $this->notificationSettings->webhook_enabled || ! $this->notificationSettings->webhook_on_threshold_failure) {
return;
}
// Check if webhook urls are configured.
if (! count($this->notificationSettings->webhook_urls)) {
Log::warning('Webhook urls not found, check webhook notification channel settings.');
return;
}
foreach ($this->notificationSettings->webhook_urls as $url) {
WebhookCall::create()
->url($url['url'])
->payload([
'result_id' => $result->id,
'site_name' => config('app.name'),
'isp' => $result->isp,
'benchmarks' => $result->benchmarks,
'speedtest_url' => $result->result_url,
'url' => url('/admin/results'),
])
->doNotSign()
->dispatch();
}
}
}
@@ -1,54 +0,0 @@
<?php
namespace App\Listeners\Webhook;
use App\Events\SpeedtestCompleted;
use App\Settings\NotificationSettings;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Log;
use Spatie\WebhookServer\WebhookCall;
class SendSpeedtestCompletedNotification
{
/**
* Handle the event.
*/
public function handle(SpeedtestCompleted $event): void
{
$notificationSettings = new NotificationSettings;
if (! $notificationSettings->webhook_enabled) {
return;
}
if (! $notificationSettings->webhook_on_speedtest_run) {
return;
}
if (! count($notificationSettings->webhook_urls)) {
Log::warning('Webhook urls not found, check webhook notification channel settings.');
return;
}
foreach ($notificationSettings->webhook_urls as $url) {
WebhookCall::create()
->url($url['url'])
->payload([
'result_id' => $event->result->id,
'site_name' => config('app.name'),
'server_name' => Arr::get($event->result->data, 'server.name'),
'server_id' => Arr::get($event->result->data, 'server.id'),
'isp' => Arr::get($event->result->data, 'isp'),
'ping' => $event->result->ping,
'download' => $event->result->downloadBits,
'upload' => $event->result->uploadBits,
'packet_loss' => Arr::get($event->result->data, 'packetLoss'),
'speedtest_url' => Arr::get($event->result->data, 'result.url'),
'url' => url('/admin/results'),
])
->doNotSign()
->dispatch();
}
}
}
@@ -1,126 +0,0 @@
<?php
namespace App\Listeners\Webhook;
use App\Events\SpeedtestCompleted;
use App\Helpers\Number;
use App\Settings\NotificationSettings;
use App\Settings\ThresholdSettings;
use Illuminate\Support\Facades\Log;
use Spatie\WebhookServer\WebhookCall;
class SendSpeedtestThresholdNotification
{
/**
* Handle the event.
*/
public function handle(SpeedtestCompleted $event): void
{
$notificationSettings = new NotificationSettings;
if (! $notificationSettings->webhook_enabled) {
return;
}
if (! $notificationSettings->webhook_on_threshold_failure) {
return;
}
if (! count($notificationSettings->webhook_urls)) {
Log::warning('Webhook urls not found, check webhook notification channel 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 webhook thresholds not found, won\'t send notification.');
return;
}
foreach ($notificationSettings->webhook_urls as $url) {
WebhookCall::create()
->url($url['url'])
->payload([
'result_id' => $event->result->id,
'site_name' => config('app.name'),
'isp' => $event->result->isp,
'metrics' => $failed,
'speedtest_url' => $event->result->result_url,
'url' => url('/admin/results'),
])
->doNotSign()
->dispatch();
}
}
/**
* Build webhook 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 webhook 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 webhook 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',
];
}
}
@@ -12,7 +12,7 @@ use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Str;
class SpeedtestCompletedMail extends Mailable implements ShouldQueue
class CompletedSpeedtestMail extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
@@ -41,7 +41,7 @@ class SpeedtestCompletedMail extends Mailable implements ShouldQueue
public function content(): Content
{
return new Content(
markdown: 'emails.speedtest-completed',
markdown: 'mail.speedtest.completed',
with: [
'id' => $this->result->id,
'service' => Str::title($this->result->service->getLabel()),
-57
View File
@@ -1,57 +0,0 @@
<?php
namespace App\Mail;
use App\Models\Result;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Str;
class SpeedtestThresholdMail extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
/**
* Create a new message instance.
*
* @return void
*/
public function __construct(
public Result $result,
public array $metrics,
) {}
/**
* Get the message envelope.
*/
public function envelope(): Envelope
{
return new Envelope(
subject: 'Speedtest Threshold Breached - #'.$this->result->id,
);
}
/**
* Get the message content definition.
*/
public function content(): Content
{
return new Content(
markdown: 'emails.speedtest-threshold',
with: [
'id' => $this->result->id,
'service' => Str::title($this->result->service->getLabel()),
'serverName' => $this->result->server_name,
'serverId' => $this->result->server_id,
'isp' => $this->result->isp,
'speedtest_url' => $this->result->result_url,
'url' => url('/admin/results'),
'metrics' => $this->metrics,
],
);
}
}
+2 -2
View File
@@ -9,7 +9,7 @@ use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class Test extends Mailable implements ShouldQueue
class TestMail extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
@@ -29,7 +29,7 @@ class Test extends Mailable implements ShouldQueue
public function content(): Content
{
return new Content(
markdown: 'emails.test',
markdown: 'mail.test',
);
}
}
+83
View File
@@ -0,0 +1,83 @@
<?php
namespace App\Mail;
use App\Helpers\Number;
use App\Models\Result;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class UnhealthySpeedtestMail extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
/**
* Create a new message instance.
*/
public function __construct(
public Result $result,
) {}
/**
* Get the message envelope.
*/
public function envelope(): Envelope
{
return new Envelope(
subject: 'Speedtest Threshold Breached - #'.$this->result->id,
);
}
/**
* Get the message content definition.
*/
public function content(): Content
{
$benchmarks = [];
foreach ($this->result->benchmarks as $metric => $benchmark) {
$benchmarks[] = $this->formatBenchmark($metric, $benchmark);
}
return new Content(
markdown: 'mail.speedtest.unhealthy',
with: [
'id' => $this->result->id,
'service' => str($this->result->service->getLabel())->title(),
'isp' => $this->result->isp,
'url' => url('/admin/results'),
'benchmarks' => $benchmarks,
],
);
}
/**
* Format a benchmark for display in the email.
*/
private function formatBenchmark(string $metric, array $benchmark): array
{
$metricName = str($metric)->title();
$type = str($benchmark['type'])->title();
$thresholdValue = $benchmark['value'].' '.str($benchmark['unit'])->title();
// Get the actual result value
$resultValue = match ($metric) {
'download' => Number::toBitRate($this->result->download_bits, 2),
'upload' => Number::toBitRate($this->result->upload_bits, 2),
'ping' => round(Number::castToType($this->result->ping, 'float'), 2).' ms',
default => 'N/A',
};
return [
'metric' => $metricName,
'type' => $type,
'threshold_value' => $thresholdValue,
'result_value' => $resultValue,
'passed' => $benchmark['passed'],
];
}
}
@@ -32,6 +32,7 @@ class AdminPanelProvider extends PanelProvider
->colors([
'primary' => Color::Amber,
])
->viteTheme('resources/css/filament/admin/theme.css')
->favicon(asset('img/speedtest-tracker-icon.png'))
->sidebarCollapsibleOnDesktop()
->discoverResources(in: app_path('Filament/Resources'), for: 'App\\Filament\\Resources')
@@ -40,6 +41,7 @@ class AdminPanelProvider extends PanelProvider
->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\\Filament\\Widgets')
->widgets([])
->databaseNotifications()
->databaseNotificationsPolling('5s')
->maxContentWidth(config('speedtest.content_width'))
->middleware([
EncryptCookies::class,
+1
View File
@@ -16,6 +16,7 @@
"require": {
"php": "^8.2",
"chrisullyott/php-filesize": "^4.2.1",
"codewithdennis/filament-simple-alert": "^4.0.2",
"dragonmantank/cron-expression": "^3.6.0",
"filament/filament": "4.1.0",
"filament/spatie-laravel-settings-plugin": "^4.1",
Generated
+75 -2
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "39020dcee9d9965e781ef550aca663ac",
"content-hash": "3aff9923fe99afc6088082ec8c3be834",
"packages": [
{
"name": "anourvalar/eloquent-serialize",
@@ -625,6 +625,79 @@
],
"time": "2023-12-20T15:40:13+00:00"
},
{
"name": "codewithdennis/filament-simple-alert",
"version": "v4.0.2",
"source": {
"type": "git",
"url": "https://github.com/CodeWithDennis/filament-simple-alert.git",
"reference": "d30b0cad908f3ade1bed153d486fd564ac312ffd"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/CodeWithDennis/filament-simple-alert/zipball/d30b0cad908f3ade1bed153d486fd564ac312ffd",
"reference": "d30b0cad908f3ade1bed153d486fd564ac312ffd",
"shasum": ""
},
"require": {
"filament/filament": "^4.0",
"php": "^8.1",
"spatie/laravel-package-tools": "^1.15.0"
},
"require-dev": {
"laravel/pint": "^1.16",
"nunomaduro/collision": "^7.9",
"orchestra/testbench": "^8.0",
"pestphp/pest": "^2.1",
"pestphp/pest-plugin-arch": "^2.0",
"pestphp/pest-plugin-laravel": "^2.0"
},
"type": "library",
"extra": {
"laravel": {
"aliases": {
"SimpleAlert": "CodeWithDennis\\SimpleAlert\\Facades\\SimpleAlert"
},
"providers": [
"CodeWithDennis\\SimpleAlert\\SimpleAlertServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"CodeWithDennis\\SimpleAlert\\": "src/",
"CodeWithDennis\\SimpleAlert\\Database\\Factories\\": "database/factories/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "CodeWithDennis",
"role": "Developer"
}
],
"description": "A plugin for adding straightforward alerts to your filament pages",
"homepage": "https://github.com/codewithdennis/filament-simple-alert",
"keywords": [
"CodeWithDennis",
"filament-simple-alert",
"laravel"
],
"support": {
"issues": "https://github.com/codewithdennis/filament-simple-alert/issues",
"source": "https://github.com/codewithdennis/filament-simple-alert"
},
"funding": [
{
"url": "https://github.com/CodeWithDennis",
"type": "github"
}
],
"time": "2025-06-21T18:43:06+00:00"
},
{
"name": "danharrin/date-format-converter",
"version": "v0.3.1",
@@ -13714,5 +13787,5 @@
"platform-overrides": {
"php": "8.4"
},
"plugin-api-version": "2.6.0"
"plugin-api-version": "2.9.0"
}
+2
View File
@@ -61,6 +61,8 @@ return [
'view_on_speedtest_net' => 'View on Speedtest.net',
// Notifications
'speedtest_benchmark_passed' => 'Speedtest benchmark passed',
'speedtest_benchmark_failed' => 'Speedtest benchmark failed',
'speedtest_started' => 'Speedtest started',
'speedtest_completed' => 'Speedtest completed',
'speedtest_failed' => 'Speedtest failed',
-3
View File
@@ -7,14 +7,12 @@ return [
// Database notifications
'database' => 'Database',
'database_description' => 'Notifications sent to this channel will show up under the 🔔 icon in the header.',
'enable_database_notifications' => 'Enable database notifications',
'database_on_speedtest_run' => 'Notify on every speedtest run',
'database_on_threshold_failure' => 'Notify on threshold failures',
'test_database_channel' => 'Test database channel',
// Mail notifications
'mail' => 'Mail',
'enable_mail_notifications' => 'Enable mail notifications',
'recipients' => 'Recipients',
'mail_on_speedtest_run' => 'Notify on every speedtest run',
'mail_on_threshold_failure' => 'Notify on threshold failures',
@@ -23,7 +21,6 @@ return [
// Webhook
'webhook' => 'Webhook',
'webhooks' => 'Webhooks',
'enable_webhook_notifications' => 'Enable webhook notifications',
'webhook_on_speedtest_run' => 'Notify on every speedtest run',
'webhook_on_threshold_failure' => 'Notify on threshold failures',
'test_webhook_channel' => 'Test webhook channel',
+2 -2
View File
@@ -6,10 +6,10 @@
"": {
"devDependencies": {
"@tailwindcss/typography": "^0.5.10",
"@tailwindcss/vite": "^4.1.16",
"@tailwindcss/vite": "^4.1.17",
"autoprefixer": "^10.4.15",
"laravel-vite-plugin": "^1.0.0",
"tailwindcss": "^4.1.16",
"tailwindcss": "^4.1.17",
"vite": "^6.4.1"
}
},
+2 -2
View File
@@ -7,10 +7,10 @@
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.10",
"@tailwindcss/vite": "^4.1.16",
"@tailwindcss/vite": "^4.1.17",
"autoprefixer": "^10.4.15",
"laravel-vite-plugin": "^1.0.0",
"tailwindcss": "^4.1.16",
"tailwindcss": "^4.1.17",
"vite": "^6.4.1"
}
}
@@ -1,3 +1,13 @@
@import '../../../../vendor/filament/filament/resources/css/theme.css';
@source '../../../../app/Filament/**/*';
@source '../../../../resources/views/filament/**/*';
/* Filament Plugins */
@source '../../../../vendor/codewithdennis/filament-simple-alert/resources/**/*.blade.php';
@source inline('animate-{spin,pulse,bounce}');
/* Additional styles */
.fi-topbar #dashboardAction .fi-btn-label,
.fi-topbar #speedtestAction .fi-btn-label {
display: none;
@@ -1,24 +0,0 @@
<x-mail::message>
# Speedtest Threshold Breached - #{{ $id }}
A new speedtest was completed using **{{ $service }}** on **{{ $isp }}** but a threshold was breached.
<x-mail::table>
| **Metric** | **Threshold** | **Value** |
|:-----------|:--------------|----------:|
@foreach ($metrics as $item)
| {{ $item['name'] }} | {{ $item['threshold'] }} | {{ $item['value'] }} |
@endforeach
</x-mail::table>
<x-mail::button :url="$url">
View Results
</x-mail::button>
<x-mail::button :url="$speedtest_url">
View Results on Ookla
</x-mail::button>
Thanks,<br>
{{ config('app.name') }}
</x-mail::message>
@@ -0,0 +1,17 @@
<x-mail::message>
# Speedtest Threshold Breached - #{{ $id }}
A new speedtest was completed using **{{ $service }}** on **{{ $isp }}** but a threshold was breached.
<x-mail::table>
| **Metric** | **Type** | **Threshold Value** | **Result Value** | **Status** |
|:-----------|:---------|:--------------------|:-----------------|:---------:|
@foreach ($benchmarks as $benchmark)
| {{ $benchmark['metric'] }} | {{ $benchmark['type'] }} | {{ $benchmark['threshold_value'] }} | {{ $benchmark['result_value'] }} | {{ $benchmark['passed'] ? '✅' : '❌' }} |
@endforeach
</x-mail::table>
<x-mail::button :url="$url">
{{ __('general.view') }}
</x-mail::button>
</x-mail::message>
+1 -1
View File
@@ -7,7 +7,7 @@ import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
plugins: [
laravel({
input: ['resources/css/app.css'],
input: ['resources/css/app.css', 'resources/css/filament/admin/theme.css'],
refresh: [`resources/views/**/*`],
}),
tailwindcss(),