From 68389cec3911ebfea583700b39610e9a13ed9d8f Mon Sep 17 00:00:00 2001
From: Alex Justesen <1144087+alexjustesen@users.noreply.github.com>
Date: Tue, 25 Nov 2025 08:54:43 -0600
Subject: [PATCH 01/13] refactored database and webhook notifications
---
.../SendSpeedtestCompletedNotification.php | 34 -----
.../SendSpeedtestThresholdNotification.php | 101 -------------
app/Listeners/ProcessCompletedSpeedtest.php | 109 +++++++++++++-
app/Listeners/ProcessFailedSpeedtest.php | 13 +-
.../ProcessSpeedtestBenchmarkFailed.php | 142 ++++++++++++++++++
.../SendSpeedtestCompletedNotification.php | 54 -------
.../SendSpeedtestThresholdNotification.php | 126 ----------------
lang/en/results.php | 2 +
8 files changed, 256 insertions(+), 325 deletions(-)
delete mode 100644 app/Listeners/Database/SendSpeedtestCompletedNotification.php
delete mode 100644 app/Listeners/Database/SendSpeedtestThresholdNotification.php
create mode 100644 app/Listeners/ProcessSpeedtestBenchmarkFailed.php
delete mode 100644 app/Listeners/Webhook/SendSpeedtestCompletedNotification.php
delete mode 100644 app/Listeners/Webhook/SendSpeedtestThresholdNotification.php
diff --git a/app/Listeners/Database/SendSpeedtestCompletedNotification.php b/app/Listeners/Database/SendSpeedtestCompletedNotification.php
deleted file mode 100644
index acaaf9d42..000000000
--- a/app/Listeners/Database/SendSpeedtestCompletedNotification.php
+++ /dev/null
@@ -1,34 +0,0 @@
-database_enabled) {
- return;
- }
-
- if (! $notificationSettings->database_on_speedtest_run) {
- return;
- }
-
- foreach (User::all() as $user) {
- Notification::make()
- ->title(__('results.speedtest_completed'))
- ->success()
- ->sendToDatabase($user);
- }
- }
-}
diff --git a/app/Listeners/Database/SendSpeedtestThresholdNotification.php b/app/Listeners/Database/SendSpeedtestThresholdNotification.php
deleted file mode 100644
index a998faff5..000000000
--- a/app/Listeners/Database/SendSpeedtestThresholdNotification.php
+++ /dev/null
@@ -1,101 +0,0 @@
-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);
- }
- }
-}
diff --git a/app/Listeners/ProcessCompletedSpeedtest.php b/app/Listeners/ProcessCompletedSpeedtest.php
index 01bf317e6..209f8f7c6 100644
--- a/app/Listeners/ProcessCompletedSpeedtest.php
+++ b/app/Listeners/ProcessCompletedSpeedtest.php
@@ -5,11 +5,19 @@
use App\Events\SpeedtestCompleted;
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 Spatie\WebhookServer\WebhookCall;
class ProcessCompletedSpeedtest
{
+ public function __construct(
+ public NotificationSettings $notificationSettings,
+ ) {}
+
/**
* Handle the event.
*/
@@ -17,8 +25,48 @@ public function handle(SpeedtestCompleted $event): void
{
$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
+ {
+ //
+ }
+
+ /**
+ * 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 +75,11 @@ public function handle(SpeedtestCompleted $event): void
*/
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 +91,55 @@ private function notifyDispatchingUser(Result $result): void
->toDatabase(),
);
}
+
+ /**
+ * Notify mail channels.
+ */
+ private function notifyMailChannels(Result $result): void
+ {
+ //
+ }
+
+ /**
+ * 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();
+ }
+ }
}
diff --git a/app/Listeners/ProcessFailedSpeedtest.php b/app/Listeners/ProcessFailedSpeedtest.php
index d11e5e7a0..3363a3a33 100644
--- a/app/Listeners/ProcessFailedSpeedtest.php
+++ b/app/Listeners/ProcessFailedSpeedtest.php
@@ -4,7 +4,6 @@
use App\Events\SpeedtestFailed;
use App\Models\Result;
-use App\Models\User;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
@@ -17,9 +16,9 @@ public function handle(SpeedtestFailed $event): void
{
$result = $event->result;
- if ($result->dispatched_by && ! $result->scheduled) {
- $this->notifyDispatchingUser($result);
- }
+ $result->loadMissing(['dispatchedBy']);
+
+ $this->notifyDispatchingUser($result);
}
/**
@@ -27,9 +26,11 @@ public function handle(SpeedtestFailed $event): void
*/
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([
diff --git a/app/Listeners/ProcessSpeedtestBenchmarkFailed.php b/app/Listeners/ProcessSpeedtestBenchmarkFailed.php
new file mode 100644
index 000000000..6e9353a6c
--- /dev/null
+++ b/app/Listeners/ProcessSpeedtestBenchmarkFailed.php
@@ -0,0 +1,142 @@
+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
+ {
+ //
+ }
+
+ /**
+ * 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
+ {
+ //
+ }
+
+ /**
+ * 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();
+ }
+ }
+}
diff --git a/app/Listeners/Webhook/SendSpeedtestCompletedNotification.php b/app/Listeners/Webhook/SendSpeedtestCompletedNotification.php
deleted file mode 100644
index bee0668d6..000000000
--- a/app/Listeners/Webhook/SendSpeedtestCompletedNotification.php
+++ /dev/null
@@ -1,54 +0,0 @@
-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();
- }
- }
-}
diff --git a/app/Listeners/Webhook/SendSpeedtestThresholdNotification.php b/app/Listeners/Webhook/SendSpeedtestThresholdNotification.php
deleted file mode 100644
index bb64866b4..000000000
--- a/app/Listeners/Webhook/SendSpeedtestThresholdNotification.php
+++ /dev/null
@@ -1,126 +0,0 @@
-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',
- ];
- }
-}
diff --git a/lang/en/results.php b/lang/en/results.php
index f59df96d8..6d8a2016d 100644
--- a/lang/en/results.php
+++ b/lang/en/results.php
@@ -61,6 +61,8 @@
'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',
From 604e381787733cf369b4e6bab609313cacf040f9 Mon Sep 17 00:00:00 2001
From: Alex Justesen <1144087+alexjustesen@users.noreply.github.com>
Date: Tue, 25 Nov 2025 09:08:02 -0600
Subject: [PATCH 02/13] added Apprise notification placeholders
---
app/Listeners/ProcessCompletedSpeedtest.php | 5 +++++
app/Listeners/ProcessFailedSpeedtest.php | 14 ++++++++++++++
app/Listeners/ProcessSpeedtestBenchmarkFailed.php | 5 +++++
3 files changed, 24 insertions(+)
diff --git a/app/Listeners/ProcessCompletedSpeedtest.php b/app/Listeners/ProcessCompletedSpeedtest.php
index 209f8f7c6..48b6772cc 100644
--- a/app/Listeners/ProcessCompletedSpeedtest.php
+++ b/app/Listeners/ProcessCompletedSpeedtest.php
@@ -39,6 +39,11 @@ public function handle(SpeedtestCompleted $event): void
*/
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;
+ }
+
//
}
diff --git a/app/Listeners/ProcessFailedSpeedtest.php b/app/Listeners/ProcessFailedSpeedtest.php
index 3363a3a33..8ba154387 100644
--- a/app/Listeners/ProcessFailedSpeedtest.php
+++ b/app/Listeners/ProcessFailedSpeedtest.php
@@ -18,9 +18,23 @@ public function handle(SpeedtestFailed $event): void
$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;
+ }
+
+ //
+ }
+
/**
* Notify the user who dispatched the speedtest.
*/
diff --git a/app/Listeners/ProcessSpeedtestBenchmarkFailed.php b/app/Listeners/ProcessSpeedtestBenchmarkFailed.php
index 6e9353a6c..8f3a1076b 100644
--- a/app/Listeners/ProcessSpeedtestBenchmarkFailed.php
+++ b/app/Listeners/ProcessSpeedtestBenchmarkFailed.php
@@ -41,6 +41,11 @@ public function handle(SpeedtestBenchmarkFailed $event): void
*/
private function notifyAppriseChannels(Result $result): void
{
+ // Don't send Apprise notification if dispatched by a user.
+ if (filled($result->dispatched_by)) {
+ return;
+ }
+
//
}
From 7a19d357d20ad799ce3059087c8409a534f89ee9 Mon Sep 17 00:00:00 2001
From: Alex Justesen <1144087+alexjustesen@users.noreply.github.com>
Date: Tue, 25 Nov 2025 11:08:43 -0600
Subject: [PATCH 03/13] refactored mail notification channel
---
.../SendSpeedtestCompletedNotification.php | 39 ------
.../SendSpeedtestThresholdNotification.php | 117 ------------------
app/Listeners/ProcessCompletedSpeedtest.php | 23 +++-
...iled.php => ProcessUnhealthySpeedtest.php} | 28 ++++-
app/Mail/SpeedtestThresholdMail.php | 57 ---------
app/Mail/UnhealthySpeedtestMail.php | 83 +++++++++++++
.../emails/speedtest-threshold.blade.php | 24 ----
.../views/mail/speedtest/unhealthy.blade.php | 20 +++
8 files changed, 149 insertions(+), 242 deletions(-)
delete mode 100644 app/Listeners/Mail/SendSpeedtestCompletedNotification.php
delete mode 100644 app/Listeners/Mail/SendSpeedtestThresholdNotification.php
rename app/Listeners/{ProcessSpeedtestBenchmarkFailed.php => ProcessUnhealthySpeedtest.php} (81%)
delete mode 100644 app/Mail/SpeedtestThresholdMail.php
create mode 100644 app/Mail/UnhealthySpeedtestMail.php
delete mode 100644 resources/views/emails/speedtest-threshold.blade.php
create mode 100644 resources/views/mail/speedtest/unhealthy.blade.php
diff --git a/app/Listeners/Mail/SendSpeedtestCompletedNotification.php b/app/Listeners/Mail/SendSpeedtestCompletedNotification.php
deleted file mode 100644
index 2e731cd99..000000000
--- a/app/Listeners/Mail/SendSpeedtestCompletedNotification.php
+++ /dev/null
@@ -1,39 +0,0 @@
-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));
- }
- }
-}
diff --git a/app/Listeners/Mail/SendSpeedtestThresholdNotification.php b/app/Listeners/Mail/SendSpeedtestThresholdNotification.php
deleted file mode 100644
index 774851df5..000000000
--- a/app/Listeners/Mail/SendSpeedtestThresholdNotification.php
+++ /dev/null
@@ -1,117 +0,0 @@
-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',
- ];
- }
-}
diff --git a/app/Listeners/ProcessCompletedSpeedtest.php b/app/Listeners/ProcessCompletedSpeedtest.php
index 48b6772cc..2e7474a1b 100644
--- a/app/Listeners/ProcessCompletedSpeedtest.php
+++ b/app/Listeners/ProcessCompletedSpeedtest.php
@@ -3,6 +3,7 @@
namespace App\Listeners;
use App\Events\SpeedtestCompleted;
+use App\Mail\SpeedtestCompletedMail;
use App\Models\Result;
use App\Models\User;
use App\Settings\NotificationSettings;
@@ -10,6 +11,7 @@
use Filament\Notifications\Notification;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Facades\Mail;
use Spatie\WebhookServer\WebhookCall;
class ProcessCompletedSpeedtest
@@ -30,7 +32,7 @@ public function handle(SpeedtestCompleted $event): void
// $this->notifyAppriseChannels($result);
$this->notifyDatabaseChannels($result);
$this->notifyDispatchingUser($result);
- // $this->notifyMailChannels($result);
+ $this->notifyMailChannels($result);
$this->notifyWebhookChannels($result);
}
@@ -102,7 +104,24 @@ private function notifyDispatchingUser(Result $result): void
*/
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 SpeedtestCompletedMail($result));
+ }
}
/**
diff --git a/app/Listeners/ProcessSpeedtestBenchmarkFailed.php b/app/Listeners/ProcessUnhealthySpeedtest.php
similarity index 81%
rename from app/Listeners/ProcessSpeedtestBenchmarkFailed.php
rename to app/Listeners/ProcessUnhealthySpeedtest.php
index 8f3a1076b..5a6d0e057 100644
--- a/app/Listeners/ProcessSpeedtestBenchmarkFailed.php
+++ b/app/Listeners/ProcessUnhealthySpeedtest.php
@@ -3,15 +3,17 @@
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 ProcessSpeedtestBenchmarkFailed
+class ProcessUnhealthySpeedtest
{
/**
* Create the event listener.
@@ -32,7 +34,7 @@ public function handle(SpeedtestBenchmarkFailed $event): void
// $this->notifyAppriseChannels($result);
$this->notifyDatabaseChannels($result);
$this->notifyDispatchingUser($result);
- // $this->notifyMailChannels($result);
+ $this->notifyMailChannels($result);
$this->notifyWebhookChannels($result);
}
@@ -104,7 +106,27 @@ private function notifyDispatchingUser(Result $result): void
*/
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));
+ }
}
/**
diff --git a/app/Mail/SpeedtestThresholdMail.php b/app/Mail/SpeedtestThresholdMail.php
deleted file mode 100644
index 94ad14af9..000000000
--- a/app/Mail/SpeedtestThresholdMail.php
+++ /dev/null
@@ -1,57 +0,0 @@
-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,
- ],
- );
- }
-}
diff --git a/app/Mail/UnhealthySpeedtestMail.php b/app/Mail/UnhealthySpeedtestMail.php
new file mode 100644
index 000000000..29bb302d4
--- /dev/null
+++ b/app/Mail/UnhealthySpeedtestMail.php
@@ -0,0 +1,83 @@
+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'],
+ ];
+ }
+}
diff --git a/resources/views/emails/speedtest-threshold.blade.php b/resources/views/emails/speedtest-threshold.blade.php
deleted file mode 100644
index 55bdaff31..000000000
--- a/resources/views/emails/speedtest-threshold.blade.php
+++ /dev/null
@@ -1,24 +0,0 @@
-
-# Speedtest Threshold Breached - #{{ $id }}
-
-A new speedtest was completed using **{{ $service }}** on **{{ $isp }}** but a threshold was breached.
-
-
-| **Metric** | **Threshold** | **Value** |
-|:-----------|:--------------|----------:|
-@foreach ($metrics as $item)
-| {{ $item['name'] }} | {{ $item['threshold'] }} | {{ $item['value'] }} |
-@endforeach
-
-
-
-View Results
-
-
-
-View Results on Ookla
-
-
-Thanks,
-{{ config('app.name') }}
-
diff --git a/resources/views/mail/speedtest/unhealthy.blade.php b/resources/views/mail/speedtest/unhealthy.blade.php
new file mode 100644
index 000000000..faa9c836e
--- /dev/null
+++ b/resources/views/mail/speedtest/unhealthy.blade.php
@@ -0,0 +1,20 @@
+
+# Speedtest Threshold Breached - #{{ $id }}
+
+A new speedtest was completed using **{{ $service }}** on **{{ $isp }}** but a threshold was breached.
+
+
+| **Metric** | **Type** | **Threshold Value** | **Result Value** | **Status** |
+|:-----------|:---------|:--------------------|:-----------------|:---------:|
+@foreach ($benchmarks as $benchmark)
+| {{ $benchmark['metric'] }} | {{ $benchmark['type'] }} | {{ $benchmark['threshold_value'] }} | {{ $benchmark['result_value'] }} | {{ $benchmark['passed'] ? '✅' : '❌' }} |
+@endforeach
+
+
+
+{{ __('general.view') }}
+
+
+Thanks,
+{{ config('app.name') }}
+
From 354af072647a87aeba8bac67019a53a3a7c44dad Mon Sep 17 00:00:00 2001
From: Alex Justesen <1144087+alexjustesen@users.noreply.github.com>
Date: Tue, 25 Nov 2025 11:14:58 -0600
Subject: [PATCH 04/13] remove footer from unhealthy speedtest notification
email
---
resources/views/mail/speedtest/unhealthy.blade.php | 3 ---
1 file changed, 3 deletions(-)
diff --git a/resources/views/mail/speedtest/unhealthy.blade.php b/resources/views/mail/speedtest/unhealthy.blade.php
index faa9c836e..f5934fd1e 100644
--- a/resources/views/mail/speedtest/unhealthy.blade.php
+++ b/resources/views/mail/speedtest/unhealthy.blade.php
@@ -14,7 +14,4 @@
{{ __('general.view') }}
-
-Thanks,
-{{ config('app.name') }}
From 5c8e25777b4bdcf37c51a7c8ae25f5eb7e86a41a Mon Sep 17 00:00:00 2001
From: Alex Justesen <1144087+alexjustesen@users.noreply.github.com>
Date: Tue, 25 Nov 2025 11:58:28 -0600
Subject: [PATCH 05/13] add database notifications polling interval to admin
panel
---
app/Providers/Filament/AdminPanelProvider.php | 1 +
1 file changed, 1 insertion(+)
diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php
index aa8e024ff..c1fe7d2d1 100644
--- a/app/Providers/Filament/AdminPanelProvider.php
+++ b/app/Providers/Filament/AdminPanelProvider.php
@@ -40,6 +40,7 @@ public function panel(Panel $panel): Panel
->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\\Filament\\Widgets')
->widgets([])
->databaseNotifications()
+ ->databaseNotificationsPolling('5s')
->maxContentWidth(config('speedtest.content_width'))
->middleware([
EncryptCookies::class,
From 0997f95bb37ed4521a9306a4c44e3c843c202345 Mon Sep 17 00:00:00 2001
From: Alex Justesen <1144087+alexjustesen@users.noreply.github.com>
Date: Tue, 25 Nov 2025 12:46:22 -0600
Subject: [PATCH 06/13] add filament-simple-alert package to composer
dependencies
---
composer.json | 1 +
composer.lock | 77 +++++++++++++++++++++++++++++++++++++++++++++++++--
2 files changed, 76 insertions(+), 2 deletions(-)
diff --git a/composer.json b/composer.json
index 2b38b1ec7..fef7f5773 100644
--- a/composer.json
+++ b/composer.json
@@ -16,6 +16,7 @@
"require": {
"php": "^8.2",
"chrisullyott/php-filesize": "^4.2.1",
+ "codewithdennis/filament-simple-alert": "4.x",
"dragonmantank/cron-expression": "^3.6.0",
"filament/filament": "4.1.0",
"filament/spatie-laravel-settings-plugin": "^4.1",
diff --git a/composer.lock b/composer.lock
index b5d427f75..d473d8628 100644
--- a/composer.lock
+++ b/composer.lock
@@ -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": "ccf5016c746153e60d1ad5e2996a4bea",
"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"
}
From 3da5bc5af8f399ead29f7e65612ebfd9619e7bac Mon Sep 17 00:00:00 2001
From: Alex Justesen <1144087+alexjustesen@users.noreply.github.com>
Date: Tue, 25 Nov 2025 12:46:32 -0600
Subject: [PATCH 07/13] bump tailwindcss and @tailwindcss/vite to version
4.1.17
---
package-lock.json | 4 ++--
package.json | 4 ++--
2 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/package-lock.json b/package-lock.json
index eb0c8bb10..e4153991b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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"
}
},
diff --git a/package.json b/package.json
index cd7b12885..87e7212c3 100644
--- a/package.json
+++ b/package.json
@@ -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"
}
}
From 451f7440e5b77840cd36d9d2d1845b882c12a467 Mon Sep 17 00:00:00 2001
From: Alex Justesen <1144087+alexjustesen@users.noreply.github.com>
Date: Tue, 25 Nov 2025 12:46:59 -0600
Subject: [PATCH 08/13] bundle admin panel theme
---
app/Providers/Filament/AdminPanelProvider.php | 1 +
resources/css/{panel.css => filament/admin/theme.css} | 10 ++++++++++
vite.config.js | 2 +-
3 files changed, 12 insertions(+), 1 deletion(-)
rename resources/css/{panel.css => filament/admin/theme.css} (50%)
diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php
index c1fe7d2d1..f72019481 100644
--- a/app/Providers/Filament/AdminPanelProvider.php
+++ b/app/Providers/Filament/AdminPanelProvider.php
@@ -32,6 +32,7 @@ public function panel(Panel $panel): Panel
->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')
diff --git a/resources/css/panel.css b/resources/css/filament/admin/theme.css
similarity index 50%
rename from resources/css/panel.css
rename to resources/css/filament/admin/theme.css
index 4503ad7e8..b39705336 100644
--- a/resources/css/panel.css
+++ b/resources/css/filament/admin/theme.css
@@ -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;
diff --git a/vite.config.js b/vite.config.js
index fd6e9f1b6..d4b0e527b 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -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(),
From 42119f59cdf29885b2e8a40d77a3ae90ba421254 Mon Sep 17 00:00:00 2001
From: Alex Justesen <1144087+alexjustesen@users.noreply.github.com>
Date: Tue, 25 Nov 2025 12:49:14 -0600
Subject: [PATCH 09/13] moved core channels to tabs for easier navigation
---
app/Filament/Pages/Settings/Notification.php | 318 ++++++++++---------
lang/en/settings/notifications.php | 3 -
2 files changed, 170 insertions(+), 151 deletions(-)
diff --git a/app/Filament/Pages/Settings/Notification.php b/app/Filament/Pages/Settings/Notification.php
index 66bb8575c..1e87891f1 100755
--- a/app/Filament/Pages/Settings/Notification.php
+++ b/app/Filament/Pages/Settings/Notification.php
@@ -13,7 +13,9 @@
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\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,128 +65,145 @@ public function form(Schema $schema): Schema
{
return $schema
->components([
- Grid::make([
- 'default' => 1,
- 'md' => 3,
- ])
- ->columnSpan('full')
+ Tabs::make()
->schema([
- Grid::make([
- 'default' => 1,
- ])
+ Tab::make(__('settings/notifications.database'))
+ ->icon(Heroicon::OutlinedCircleStack)
->schema([
- Section::make(__('settings/notifications.database'))
- ->description(__('settings/notifications.database_description'))
+ Toggle::make('database_enabled')
+ ->label(__('general.enable'))
+ ->live(),
+
+ Grid::make([
+ 'default' => 1,
+ ])
+ ->hidden(fn (Get $get) => $get('database_enabled') !== true)
->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)
+ Fieldset::make(__('settings.triggers'))
+ ->columns(1)
->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())),
- ]),
+ 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')),
]),
- ])
- ->compact()
- ->columnSpan('full'),
- Section::make(__('settings/notifications.mail'))
+ 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([
- Toggle::make('mail_enabled')
- ->label(__('settings/notifications.enable_mail_notifications'))
- ->reactive()
- ->columnSpanFull(),
- Grid::make([
- 'default' => 1,
- ])
- ->hidden(fn (Get $get) => $get('mail_enabled') !== true)
+ Fieldset::make(__('settings.triggers'))
+ ->columns(1)
->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'))),
- ]),
+ 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')),
]),
- ])
- ->compact()
- ->columnSpan('full'),
- Section::make(__('settings/notifications.webhook'))
+ 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([
- Toggle::make('webhook_enabled')
- ->label(__('settings/notifications.enable_webhook_notifications'))
- ->reactive()
- ->columnSpanFull(),
- Grid::make([
- 'default' => 1,
- ])
- ->hidden(fn (Get $get) => $get('webhook_enabled') !== true)
+ Fieldset::make(__('settings.triggers'))
+ ->columns(1)
->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'))),
- ]),
+ 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')),
]),
- ])
- ->compact()
- ->columnSpan('full'),
+ 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,
+ ])
+ ->columnSpan('full')
+ ->schema([
+ Grid::make([
+ 'default' => 1,
+ ])
+ ->schema([
Section::make('Pushover')
->description('⚠️ Pushover is deprecated and will be removed in a future release.')
->schema([
@@ -192,46 +214,46 @@ public function form(Schema $schema): Schema
Grid::make([
'default' => 1,
])
- ->hidden(fn (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(),
+ ->hidden(fn (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')
+ ->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 (Get $get) => SendPushoverTestNotification::run(
+ webhooks: $get('pushover_webhooks')
+ ))
+ ->hidden(fn (Get $get) => ! count($get('pushover_webhooks'))),
]),
- Repeater::make('pushover_webhooks')
- ->label('Pushover Webhooks')
- ->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 (Get $get) => SendPushoverTestNotification::run(
- webhooks: $get('pushover_webhooks')
- ))
- ->hidden(fn (Get $get) => ! count($get('pushover_webhooks'))),
- ]),
]),
])
->compact()
diff --git a/lang/en/settings/notifications.php b/lang/en/settings/notifications.php
index 77f779529..6dcf09a88 100644
--- a/lang/en/settings/notifications.php
+++ b/lang/en/settings/notifications.php
@@ -7,14 +7,12 @@
// 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 @@
// 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',
From 85f126f229d10685a4256fbdf62bd8bbda5d2abd Mon Sep 17 00:00:00 2001
From: Alex Justesen <1144087+alexjustesen@users.noreply.github.com>
Date: Tue, 25 Nov 2025 12:49:58 -0600
Subject: [PATCH 10/13] lint fixes
---
app/Filament/Pages/Settings/Notification.php | 78 ++++++++++----------
app/Mail/UnhealthySpeedtestMail.php | 2 +-
2 files changed, 40 insertions(+), 40 deletions(-)
diff --git a/app/Filament/Pages/Settings/Notification.php b/app/Filament/Pages/Settings/Notification.php
index 1e87891f1..3fd0d82c5 100755
--- a/app/Filament/Pages/Settings/Notification.php
+++ b/app/Filament/Pages/Settings/Notification.php
@@ -214,46 +214,46 @@ public function form(Schema $schema): Schema
Grid::make([
'default' => 1,
])
- ->hidden(fn (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')
- ->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 (Get $get) => SendPushoverTestNotification::run(
- webhooks: $get('pushover_webhooks')
- ))
- ->hidden(fn (Get $get) => ! count($get('pushover_webhooks'))),
+ ->hidden(fn (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')
+ ->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 (Get $get) => SendPushoverTestNotification::run(
+ webhooks: $get('pushover_webhooks')
+ ))
+ ->hidden(fn (Get $get) => ! count($get('pushover_webhooks'))),
+ ]),
]),
])
->compact()
diff --git a/app/Mail/UnhealthySpeedtestMail.php b/app/Mail/UnhealthySpeedtestMail.php
index 29bb302d4..e22f4ec28 100644
--- a/app/Mail/UnhealthySpeedtestMail.php
+++ b/app/Mail/UnhealthySpeedtestMail.php
@@ -62,7 +62,7 @@ private function formatBenchmark(string $metric, array $benchmark): array
{
$metricName = str($metric)->title();
$type = str($benchmark['type'])->title();
- $thresholdValue = $benchmark['value']. ' ' . str($benchmark['unit'])->title();
+ $thresholdValue = $benchmark['value'].' '.str($benchmark['unit'])->title();
// Get the actual result value
$resultValue = match ($metric) {
From d1c2bac6f96258747fa6f68cb95af5d932849c35 Mon Sep 17 00:00:00 2001
From: Alex Justesen <1144087+alexjustesen@users.noreply.github.com>
Date: Tue, 25 Nov 2025 14:16:20 -0600
Subject: [PATCH 11/13] refactor test mail
---
app/Actions/Notifications/SendMailTestNotification.php | 2 +-
app/Mail/{Test.php => TestMail.php} | 4 ++--
resources/views/{emails => mail}/test.blade.php | 0
3 files changed, 3 insertions(+), 3 deletions(-)
rename app/Mail/{Test.php => TestMail.php} (87%)
rename resources/views/{emails => mail}/test.blade.php (100%)
diff --git a/app/Actions/Notifications/SendMailTestNotification.php b/app/Actions/Notifications/SendMailTestNotification.php
index 6bcad272d..f8220e20f 100644
--- a/app/Actions/Notifications/SendMailTestNotification.php
+++ b/app/Actions/Notifications/SendMailTestNotification.php
@@ -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;
diff --git a/app/Mail/Test.php b/app/Mail/TestMail.php
similarity index 87%
rename from app/Mail/Test.php
rename to app/Mail/TestMail.php
index 5fe6fd88b..c6e43a269 100644
--- a/app/Mail/Test.php
+++ b/app/Mail/TestMail.php
@@ -9,7 +9,7 @@
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 @@ public function envelope(): Envelope
public function content(): Content
{
return new Content(
- markdown: 'emails.test',
+ markdown: 'mail.test',
);
}
}
diff --git a/resources/views/emails/test.blade.php b/resources/views/mail/test.blade.php
similarity index 100%
rename from resources/views/emails/test.blade.php
rename to resources/views/mail/test.blade.php
From 6e8a88f251b544d88ea118485c675b36bde838a2 Mon Sep 17 00:00:00 2001
From: Alex Justesen <1144087+alexjustesen@users.noreply.github.com>
Date: Tue, 25 Nov 2025 14:22:30 -0600
Subject: [PATCH 12/13] refactor: rename mail class and update email template
for completed speedtests
---
app/Listeners/ProcessCompletedSpeedtest.php | 4 ++--
...{SpeedtestCompletedMail.php => CompletedSpeedtestMail.php} | 4 ++--
.../speedtest/completed.blade.php} | 0
3 files changed, 4 insertions(+), 4 deletions(-)
rename app/Mail/{SpeedtestCompletedMail.php => CompletedSpeedtestMail.php} (93%)
rename resources/views/{emails/speedtest-completed.blade.php => mail/speedtest/completed.blade.php} (100%)
diff --git a/app/Listeners/ProcessCompletedSpeedtest.php b/app/Listeners/ProcessCompletedSpeedtest.php
index 2e7474a1b..94c2caa89 100644
--- a/app/Listeners/ProcessCompletedSpeedtest.php
+++ b/app/Listeners/ProcessCompletedSpeedtest.php
@@ -3,7 +3,7 @@
namespace App\Listeners;
use App\Events\SpeedtestCompleted;
-use App\Mail\SpeedtestCompletedMail;
+use App\Mail\CompletedSpeedtestMail;
use App\Models\Result;
use App\Models\User;
use App\Settings\NotificationSettings;
@@ -120,7 +120,7 @@ private function notifyMailChannels(Result $result): void
foreach ($this->notificationSettings->mail_recipients as $recipient) {
Mail::to($recipient)
- ->send(new SpeedtestCompletedMail($result));
+ ->send(new CompletedSpeedtestMail($result));
}
}
diff --git a/app/Mail/SpeedtestCompletedMail.php b/app/Mail/CompletedSpeedtestMail.php
similarity index 93%
rename from app/Mail/SpeedtestCompletedMail.php
rename to app/Mail/CompletedSpeedtestMail.php
index 6f7295771..109d95360 100644
--- a/app/Mail/SpeedtestCompletedMail.php
+++ b/app/Mail/CompletedSpeedtestMail.php
@@ -12,7 +12,7 @@
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 @@ public function envelope(): Envelope
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()),
diff --git a/resources/views/emails/speedtest-completed.blade.php b/resources/views/mail/speedtest/completed.blade.php
similarity index 100%
rename from resources/views/emails/speedtest-completed.blade.php
rename to resources/views/mail/speedtest/completed.blade.php
From e4f5437baeba5940a085fcd755024a362d398b35 Mon Sep 17 00:00:00 2001
From: Alex Justesen <1144087+alexjustesen@users.noreply.github.com>
Date: Tue, 25 Nov 2025 14:31:39 -0600
Subject: [PATCH 13/13] bump codewithdennis/filament-simple-alert
---
composer.json | 2 +-
composer.lock | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/composer.json b/composer.json
index fef7f5773..5652a0c4d 100644
--- a/composer.json
+++ b/composer.json
@@ -16,7 +16,7 @@
"require": {
"php": "^8.2",
"chrisullyott/php-filesize": "^4.2.1",
- "codewithdennis/filament-simple-alert": "4.x",
+ "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",
diff --git a/composer.lock b/composer.lock
index d473d8628..58af2d1e9 100644
--- a/composer.lock
+++ b/composer.lock
@@ -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": "ccf5016c746153e60d1ad5e2996a4bea",
+ "content-hash": "3aff9923fe99afc6088082ec8c3be834",
"packages": [
{
"name": "anourvalar/eloquent-serialize",