diff --git a/app/Actions/Notifications/Average/CheckAndSendPeriodicAverageNotifications.php b/app/Actions/Notifications/Average/CheckAndSendPeriodicAverageNotifications.php new file mode 100644 index 000000000..2d62dd305 --- /dev/null +++ b/app/Actions/Notifications/Average/CheckAndSendPeriodicAverageNotifications.php @@ -0,0 +1,33 @@ + $settings->mail_enabled && $settings->mail_daily_average_enabled, + ReportPeriod::Weekly => $settings->mail_enabled && $settings->mail_weekly_average_enabled, + ReportPeriod::Monthly => $settings->mail_enabled && $settings->mail_monthly_average_enabled, + }; + + $appriseEnabled = match ($period) { + ReportPeriod::Daily => $settings->apprise_enabled && $settings->apprise_daily_average_enabled, + ReportPeriod::Weekly => $settings->apprise_enabled && $settings->apprise_weekly_average_enabled, + ReportPeriod::Monthly => $settings->apprise_enabled && $settings->apprise_monthly_average_enabled, + }; + + if (! $mailEnabled && ! $appriseEnabled) { + return; + } + + SendPeriodicAverageReportJob::dispatch($period, $mailEnabled, $appriseEnabled); + } +} diff --git a/app/Enums/ReportPeriod.php b/app/Enums/ReportPeriod.php new file mode 100644 index 000000000..9b66c0dcd --- /dev/null +++ b/app/Enums/ReportPeriod.php @@ -0,0 +1,48 @@ + now()->subDay()->startOfDay(), + self::Weekly => now()->subWeek()->startOfWeek(), + self::Monthly => now()->subMonth()->startOfMonth(), + }; + } + + public function getEndDate(): Carbon + { + return match ($this) { + self::Daily => now()->subDay()->endOfDay(), + self::Weekly => now()->subWeek()->endOfWeek(), + self::Monthly => now()->subMonth()->endOfMonth(), + }; + } + + public function getLabel(): string + { + return match ($this) { + self::Daily => now()->subDay()->format('F j, Y'), + self::Weekly => $this->getStartDate()->format('M j').' - '.$this->getEndDate()->format('M j, Y'), + self::Monthly => $this->getStartDate()->format('F Y'), + }; + } + + public function getName(): string + { + return match ($this) { + self::Daily => 'Daily', + self::Weekly => 'Weekly', + self::Monthly => 'Monthly', + }; + } +} diff --git a/app/Filament/Pages/Settings/Notification.php b/app/Filament/Pages/Settings/Notification.php index c01ee4e1f..965bf7f43 100755 --- a/app/Filament/Pages/Settings/Notification.php +++ b/app/Filament/Pages/Settings/Notification.php @@ -111,12 +111,13 @@ public function form(Schema $schema): Schema ->live(), Grid::make([ - 'default' => 1, + 'default' => 2, ]) ->hidden(fn (Get $get) => $get('mail_enabled') !== true) ->schema([ Fieldset::make(__('settings.triggers')) ->columns(1) + ->columnSpan(1) ->schema([ Checkbox::make('mail_on_speedtest_run') ->label(__('settings/notifications.notify_on_every_speedtest_run')) @@ -126,6 +127,23 @@ public function form(Schema $schema): Schema ->helpertext(__('settings/notifications.notify_on_threshold_failures_helper')), ]), + Fieldset::make(__('settings/notifications.periodic_reports')) + ->columns(1) + ->columnSpan(1) + ->schema([ + Checkbox::make('mail_daily_average_enabled') + ->label(__('settings/notifications.daily_average_report')) + ->helperText(__('settings/notifications.daily_average_report_helper')), + + Checkbox::make('mail_weekly_average_enabled') + ->label(__('settings/notifications.weekly_average_report')) + ->helperText(__('settings/notifications.weekly_average_report_helper')), + + Checkbox::make('mail_monthly_average_enabled') + ->label(__('settings/notifications.monthly_average_report')) + ->helperText(__('settings/notifications.monthly_average_report_helper')), + ]), + Repeater::make('mail_recipients') ->label(__('settings/notifications.recipients')) ->schema([ @@ -133,14 +151,14 @@ public function form(Schema $schema): Schema ->placeholder('your@email.com') ->email() ->required(), - ]), + ])->columnSpan('full'), 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'))), - ]), + ])->columnSpan('full'), ]), // ... @@ -193,14 +211,14 @@ public function form(Schema $schema): Schema ->maxLength(2000) ->required() ->url(), - ]), + ])->columnSpan('full'), 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'))), - ]), + ])->columnSpan('full'), ]), // ... @@ -248,18 +266,39 @@ public function form(Schema $schema): Schema ->label(__('settings/notifications.apprise_verify_ssl')) ->default(true) ->columnSpanFull(), - ]), - Fieldset::make(__('settings.triggers')) + ]) + ->columnSpanFull(), + Grid::make([ + 'default' => 2, + ]) ->schema([ - Checkbox::make('apprise_on_speedtest_run') - ->label(__('settings/notifications.notify_on_every_speedtest_run')) - ->helpertext(__('settings/notifications.notify_on_every_speedtest_run_helper')) - ->columnSpanFull(), - Checkbox::make('apprise_on_threshold_failure') - ->label(__('settings/notifications.notify_on_threshold_failures')) - ->helpertext(__('settings/notifications.notify_on_threshold_failures_helper')) - ->columnSpanFull(), - ]), + Fieldset::make(__('settings.triggers')) + ->columns(1) + ->columnSpan(1) + ->schema([ + Checkbox::make('apprise_on_speedtest_run') + ->label(__('settings/notifications.notify_on_every_speedtest_run')) + ->helpertext(__('settings/notifications.notify_on_every_speedtest_run_helper')), + Checkbox::make('apprise_on_threshold_failure') + ->label(__('settings/notifications.notify_on_threshold_failures')) + ->helpertext(__('settings/notifications.notify_on_threshold_failures_helper')), + ]), + Fieldset::make(__('settings/notifications.periodic_reports')) + ->columns(1) + ->columnSpan(1) + ->schema([ + Checkbox::make('apprise_daily_average_enabled') + ->label(__('settings/notifications.daily_average_report')) + ->helperText(__('settings/notifications.daily_average_report_helper')), + Checkbox::make('apprise_weekly_average_enabled') + ->label(__('settings/notifications.weekly_average_report')) + ->helperText(__('settings/notifications.weekly_average_report_helper')), + Checkbox::make('apprise_monthly_average_enabled') + ->label(__('settings/notifications.monthly_average_report')) + ->helperText(__('settings/notifications.monthly_average_report_helper')), + ]), + ]) + ->columnSpanFull(), Repeater::make('apprise_channel_urls') ->label(__('settings/notifications.apprise_channels')) ->helperText(__('settings/notifications.apprise_save_to_test')) diff --git a/app/Jobs/Notifications/SendPeriodicAverageReportJob.php b/app/Jobs/Notifications/SendPeriodicAverageReportJob.php new file mode 100644 index 000000000..d509b101a --- /dev/null +++ b/app/Jobs/Notifications/SendPeriodicAverageReportJob.php @@ -0,0 +1,52 @@ +period->getStartDate(); + $end = $this->period->getEndDate(); + + $results = $reportService->getResults($start, $end); + + if ($results->isEmpty()) { + return; + } + + $stats = $reportService->calculateStats($results); + + $periodName = $this->period->getName(); + $periodLabel = $this->period->getLabel(); + + if ($this->sendMail) { + $notificationService->sendMail($settings, $stats, $periodName, $periodLabel); + } + + if ($this->sendApprise) { + $notificationService->sendApprise($settings, $stats, $periodName, $periodLabel); + } + } +} diff --git a/app/Mail/PeriodicAverageMail.php b/app/Mail/PeriodicAverageMail.php new file mode 100644 index 000000000..ad1d4d12b --- /dev/null +++ b/app/Mail/PeriodicAverageMail.php @@ -0,0 +1,44 @@ +period} Speedtest Average Report - {$this->periodLabel}", + ); + } + + /** + * Get the message content definition. + */ + public function content(): Content + { + return new Content( + markdown: 'mail.speedtest.periodic-average', + ); + } +} diff --git a/app/Notifications/Apprise/PeriodicAverageNotification.php b/app/Notifications/Apprise/PeriodicAverageNotification.php new file mode 100644 index 000000000..f9b0e039c --- /dev/null +++ b/app/Notifications/Apprise/PeriodicAverageNotification.php @@ -0,0 +1,46 @@ + + */ + public function via(object $notifiable): array + { + return ['apprise']; + } + + /** + * Get the Apprise message representation of the notification. + */ + public function toApprise(object $notifiable): AppriseMessage + { + $body = view('apprise.periodic-average', [ + 'stats' => $this->stats, + 'period' => $this->period, + 'periodLabel' => $this->periodLabel, + ])->render(); + + return AppriseMessage::create() + ->urls($notifiable->routes['apprise_urls']) + ->title($this->period.' Speedtest Average Report') + ->body($body) + ->type('info'); + } +} diff --git a/app/Services/PeriodicNotificationService.php b/app/Services/PeriodicNotificationService.php new file mode 100644 index 000000000..4e96ded56 --- /dev/null +++ b/app/Services/PeriodicNotificationService.php @@ -0,0 +1,49 @@ +mail_recipients)) { + return; + } + + foreach ($settings->mail_recipients as $recipient) { + Mail::to($recipient)->queue( + new PeriodicAverageMail($stats, $period, $periodLabel) + ); + } + } + + public function sendApprise( + NotificationSettings $settings, + array $stats, + string $period, + string $periodLabel + ): void { + if (empty($settings->apprise_channel_urls)) { + return; + } + + $urls = collect($settings->apprise_channel_urls)->pluck('channel_url')->toArray(); + + Notification::route('apprise_urls', $urls) + ->notify(new PeriodicAverageNotification( + $stats, + $period, + $periodLabel + )); + } +} diff --git a/app/Services/PeriodicReportService.php b/app/Services/PeriodicReportService.php new file mode 100644 index 000000000..d9d17c24c --- /dev/null +++ b/app/Services/PeriodicReportService.php @@ -0,0 +1,53 @@ +whereBetween('created_at', [$start, $end]) + ->get(); + } + + public function calculateStats(Collection $results): array + { + $completedResults = $results->where('status', ResultStatus::Completed); + $failedResults = $results->where('status', ResultStatus::Failed); + $healthyResults = $results->where('healthy', '===', true); + $unhealthyResults = $results->where('healthy', '===', false); + + $hasCompletedResults = $completedResults->isNotEmpty(); + + // Calculate packet loss stats + $packetLossValues = $completedResults->pluck('packet_loss')->filter(fn ($value) => is_numeric($value)); + $hasPacketLoss = $packetLossValues->isNotEmpty(); + + return [ + 'download_avg' => Number::toBitRate(bits: $results->avg('download') * 8, precision: 2), + 'upload_avg' => Number::toBitRate(bits: $results->avg('upload') * 8, precision: 2), + 'ping_avg' => round($results->avg('ping'), 2).' ms', + 'download_max' => $hasCompletedResults ? Number::toBitRate(bits: $completedResults->max('download') * 8, precision: 2) : 'N/A', + 'download_min' => $hasCompletedResults ? Number::toBitRate(bits: $completedResults->min('download') * 8, precision: 2) : 'N/A', + 'upload_max' => $hasCompletedResults ? Number::toBitRate(bits: $completedResults->max('upload') * 8, precision: 2) : 'N/A', + 'upload_min' => $hasCompletedResults ? Number::toBitRate(bits: $completedResults->min('upload') * 8, precision: 2) : 'N/A', + 'ping_max' => $hasCompletedResults ? round($completedResults->max('ping'), 2).' ms' : 'N/A', + 'ping_min' => $hasCompletedResults ? round($completedResults->min('ping'), 2).' ms' : 'N/A', + 'packet_loss_avg' => $hasPacketLoss ? round($packetLossValues->avg(), 2).'%' : 'N/A', + 'packet_loss_max' => $hasPacketLoss ? round($packetLossValues->max(), 2).'%' : 'N/A', + 'packet_loss_min' => $hasPacketLoss ? round($packetLossValues->min(), 2).'%' : 'N/A', + 'total_tests' => $results->count(), + 'successful_tests' => $completedResults->count(), + 'failed_tests' => $failedResults->count(), + 'healthy_tests' => $healthyResults->count(), + 'unhealthy_tests' => $unhealthyResults->count(), + ]; + } +} diff --git a/app/Settings/NotificationSettings.php b/app/Settings/NotificationSettings.php index 0f332c68b..827967ddd 100644 --- a/app/Settings/NotificationSettings.php +++ b/app/Settings/NotificationSettings.php @@ -98,6 +98,18 @@ class NotificationSettings extends Settings public ?array $apprise_channel_urls; + public bool $mail_daily_average_enabled; + + public bool $mail_weekly_average_enabled; + + public bool $mail_monthly_average_enabled; + + public bool $apprise_daily_average_enabled; + + public bool $apprise_weekly_average_enabled; + + public bool $apprise_monthly_average_enabled; + public static function group(): string { return 'notification'; diff --git a/database/settings/2025_12_01_175300_add_periodic_report_settings_to_notification_settings.php b/database/settings/2025_12_01_175300_add_periodic_report_settings_to_notification_settings.php new file mode 100644 index 000000000..2185d183f --- /dev/null +++ b/database/settings/2025_12_01_175300_add_periodic_report_settings_to_notification_settings.php @@ -0,0 +1,16 @@ +migrator->add('notification.mail_daily_average_enabled', false); + $this->migrator->add('notification.mail_weekly_average_enabled', false); + $this->migrator->add('notification.mail_monthly_average_enabled', false); + $this->migrator->add('notification.apprise_daily_average_enabled', false); + $this->migrator->add('notification.apprise_weekly_average_enabled', false); + $this->migrator->add('notification.apprise_monthly_average_enabled', false); + } +}; diff --git a/lang/en/settings/notifications.php b/lang/en/settings/notifications.php index 8c3145532..4996915dd 100644 --- a/lang/en/settings/notifications.php +++ b/lang/en/settings/notifications.php @@ -62,4 +62,13 @@ // Helper text 'threshold_helper_text' => 'Threshold notifications will be sent to the /fail route in the URL.', + + // Periodic reports + 'periodic_reports' => 'Periodic Reports', + 'daily_average_report' => 'Daily Average Report', + 'daily_average_report_helper' => 'Sends daily average statistics every day at midnight', + 'weekly_average_report' => 'Weekly Average Report', + 'weekly_average_report_helper' => 'Sends weekly average statistics every Monday at midnight', + 'monthly_average_report' => 'Monthly Average Report', + 'monthly_average_report_helper' => 'Sends monthly average statistics on the 1st of each month at midnight', ]; diff --git a/resources/views/apprise/periodic-average.blade.php b/resources/views/apprise/periodic-average.blade.php new file mode 100644 index 000000000..75259ccf1 --- /dev/null +++ b/resources/views/apprise/periodic-average.blade.php @@ -0,0 +1,15 @@ +{{ $period }} Report - {{ $periodLabel }} + +Performance Statistics +━━━━━━━━━━━━━━━━━━━━ +Download: {{ $stats['download_avg'] }} (Best: {{ $stats['download_max'] }}, Worst: {{ $stats['download_min'] }}) +Upload: {{ $stats['upload_avg'] }} (Best: {{ $stats['upload_max'] }}, Worst: {{ $stats['upload_min'] }}) +Ping: {{ $stats['ping_avg'] }} (Best: {{ $stats['ping_min'] }}, Worst: {{ $stats['ping_max'] }}) + +Summary Statistics +━━━━━━━━━━━━━━━━━━━━ +Total Tests: {{ $stats['total_tests'] }} +Successful: {{ $stats['successful_tests'] }} +Failed: {{ $stats['failed_tests'] }} +Healthy: {{ $stats['healthy_tests'] }} +Unhealthy: {{ $stats['unhealthy_tests'] }} diff --git a/resources/views/mail/speedtest/periodic-average.blade.php b/resources/views/mail/speedtest/periodic-average.blade.php new file mode 100644 index 000000000..6197e33af --- /dev/null +++ b/resources/views/mail/speedtest/periodic-average.blade.php @@ -0,0 +1,41 @@ + +# {{ $period }} Speedtest Report + +**Period**: {{ $periodLabel }} + +--- + +## Performance Statistics + + +| **Metric** | **Average** | **Best** | **Worst** | +|:-------------|------------:|------------:|-----------:| +| Download | {{ $stats['download_avg'] }} | {{ $stats['download_max'] }} | {{ $stats['download_min'] }} | +| Upload | {{ $stats['upload_avg'] }} | {{ $stats['upload_max'] }} | {{ $stats['upload_min'] }} | +| Ping | {{ $stats['ping_avg'] }} | {{ $stats['ping_min'] }} | {{ $stats['ping_max'] }} | +| Packet Loss | {{ $stats['packet_loss_avg'] }} | {{ $stats['packet_loss_min'] }} | {{ $stats['packet_loss_max'] }} | + + +--- + +## Summary Statistics + + +| **Metric** | **Value** | +|:-------------------|---------------------------:| +| Total Tests | {{ $stats['total_tests'] }} | +| Successful Tests | {{ $stats['successful_tests'] }} | +| Failed Tests | {{ $stats['failed_tests'] }} | +| Healthy Tests | {{ $stats['healthy_tests'] }} | +| Unhealthy Tests | {{ $stats['unhealthy_tests'] }} | + + +--- + + +View All Results + + +Thanks,
+{{ config('app.name') }} +
diff --git a/routes/console.php b/routes/console.php index e4a670f0b..1b5fabb33 100644 --- a/routes/console.php +++ b/routes/console.php @@ -1,6 +1,8 @@ group(function () { Schedule::call(fn () => CheckForScheduledSpeedtests::run()); }); + +/** + * Send daily average report at 6 AM. + */ +Schedule::dailyAt('00:05') + ->group(function () { + Schedule::call(fn () => CheckAndSendPeriodicAverageNotifications::run(ReportPeriod::Daily)); + }); + +/** + * Send weekly average report every Monday at 6 AM. + */ +Schedule::weeklyOn(1, '00:05') + ->group(function () { + Schedule::call(fn () => CheckAndSendPeriodicAverageNotifications::run(ReportPeriod::Weekly)); + }); + +/** + * Send monthly average report on the 1st of each month at 6 AM. + */ +Schedule::monthlyOn(1, '00:05') + ->group(function () { + Schedule::call(fn () => CheckAndSendPeriodicAverageNotifications::run(ReportPeriod::Monthly)); + });