diff --git a/app/Actions/Notifications/SendAppriseTestNotification.php b/app/Actions/Notifications/SendAppriseTestNotification.php new file mode 100644 index 000000000..062202b06 --- /dev/null +++ b/app/Actions/Notifications/SendAppriseTestNotification.php @@ -0,0 +1,45 @@ +title('You need to add Apprise channel URLs!') + ->warning() + ->send(); + + return; + } + + foreach ($channel_urls as $row) { + $channelUrl = $row['channel_url'] ?? null; + if (! $channelUrl) { + Notification::make() + ->title('Skipping missing channel URL!') + ->warning() + ->send(); + + continue; + } + + FacadesNotification::route('apprise_urls', $channelUrl) + ->notify(new TestNotification); + } + + Notification::make() + ->title('Test Apprise notification sent.') + ->success() + ->send(); + } +} diff --git a/app/Filament/Pages/Dashboard.php b/app/Filament/Pages/Dashboard.php index 6db680a00..bc6d7591d 100644 --- a/app/Filament/Pages/Dashboard.php +++ b/app/Filament/Pages/Dashboard.php @@ -2,20 +2,11 @@ namespace App\Filament\Pages; -use App\Filament\Widgets\RecentDownloadChartWidget; -use App\Filament\Widgets\RecentDownloadLatencyChartWidget; -use App\Filament\Widgets\RecentJitterChartWidget; -use App\Filament\Widgets\RecentPingChartWidget; -use App\Filament\Widgets\RecentUploadChartWidget; -use App\Filament\Widgets\RecentUploadLatencyChartWidget; -use App\Filament\Widgets\StatsOverviewWidget; -use Carbon\Carbon; -use Cron\CronExpression; use Filament\Pages\Dashboard as BasePage; class Dashboard extends BasePage { - protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-chart-bar'; + protected static string|\BackedEnum|null $navigationIcon = 'tabler-layout-dashboard'; protected string $view = 'filament.pages.dashboard'; @@ -28,32 +19,4 @@ public static function getNavigationLabel(): string { return __('dashboard.title'); } - - public function getSubheading(): ?string - { - $schedule = config('speedtest.schedule'); - - if (blank($schedule) || $schedule === false) { - return __('dashboard.no_speedtests_scheduled'); - } - - $cronExpression = new CronExpression($schedule); - - $nextRunDate = Carbon::parse($cronExpression->getNextRunDate(timeZone: config('app.display_timezone')))->format(config('app.datetime_format')); - - return __('dashboard.next_speedtest_at').': '.$nextRunDate; - } - - protected function getHeaderWidgets(): array - { - return [ - StatsOverviewWidget::make(), - RecentDownloadChartWidget::make(), - RecentUploadChartWidget::make(), - RecentPingChartWidget::make(), - RecentJitterChartWidget::make(), - RecentDownloadLatencyChartWidget::make(), - RecentUploadLatencyChartWidget::make(), - ]; - } } diff --git a/app/Filament/Pages/Settings/DataIntegration.php b/app/Filament/Pages/Settings/DataIntegration.php index d61f41df8..e680627dd 100644 --- a/app/Filament/Pages/Settings/DataIntegration.php +++ b/app/Filament/Pages/Settings/DataIntegration.php @@ -23,7 +23,7 @@ class DataIntegration extends SettingsPage { - protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-circle-stack'; + protected static string|\BackedEnum|null $navigationIcon = 'tabler-database'; protected static string|\UnitEnum|null $navigationGroup = 'Settings'; diff --git a/app/Filament/Pages/Settings/Notification.php b/app/Filament/Pages/Settings/Notification.php index 915225265..9a5decb00 100755 --- a/app/Filament/Pages/Settings/Notification.php +++ b/app/Filament/Pages/Settings/Notification.php @@ -2,6 +2,7 @@ namespace App\Filament\Pages\Settings; +use App\Actions\Notifications\SendAppriseTestNotification; use App\Actions\Notifications\SendDatabaseTestNotification; use App\Actions\Notifications\SendDiscordTestNotification; use App\Actions\Notifications\SendGotifyTestNotification; @@ -12,6 +13,7 @@ use App\Actions\Notifications\SendSlackTestNotification; use App\Actions\Notifications\SendTelegramTestNotification; use App\Actions\Notifications\SendWebhookTestNotification; +use App\Rules\AppriseScheme; use App\Settings\NotificationSettings; use CodeWithDennis\SimpleAlert\Components\SimpleAlert; use Filament\Actions\Action; @@ -33,7 +35,7 @@ class Notification extends SettingsPage { - protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-bell-alert'; + protected static string|\BackedEnum|null $navigationIcon = 'tabler-bell-ringing'; protected static string|\UnitEnum|null $navigationGroup = 'Settings'; @@ -199,6 +201,80 @@ public function form(Schema $schema): Schema // ... ]), + Tab::make(__('settings/notifications.apprise')) + ->icon(Heroicon::CloudArrowUp) + ->schema([ + SimpleAlert::make('wehbook_info') + ->title(__('general.documentation')) + ->description(__('settings/notifications.apprise_hint_description')) + ->border() + ->info() + ->actions([ + Action::make('webhook_docs') + ->label(__('general.view_documentation')) + ->icon('heroicon-m-arrow-long-right') + ->color('info') + ->link() + ->url('https://docs.speedtest-tracker.dev/settings/notifications/apprise') + ->openUrlInNewTab(), + ]) + ->columnSpanFull(), + + Toggle::make('apprise_enabled') + ->label(__('settings/notifications.enable_apprise_notifications')) + ->reactive() + ->columnSpanFull(), + Grid::make([ + 'default' => 1, + ]) + ->hidden(fn (Get $get) => $get('apprise_enabled') !== true) + ->schema([ + Fieldset::make(__('settings/notifications.apprise_server')) + ->schema([ + TextInput::make('apprise_server_url') + ->label(__('settings/notifications.apprise_server_url')) + ->placeholder('http://localhost:8000') + ->maxLength(2000) + ->required() + ->url() + ->columnSpanFull(), + Checkbox::make('apprise_verify_ssl') + ->label(__('settings/notifications.apprise_verify_ssl')) + ->default(true) + ->columnSpanFull(), + ]), + Fieldset::make(__('settings.triggers')) + ->schema([ + Checkbox::make('apprise_on_speedtest_run') + ->label(__('settings/notifications.notify_on_every_speedtest_run')) + ->columnSpanFull(), + Checkbox::make('apprise_on_threshold_failure') + ->label(__('settings/notifications.notify_on_threshold_failures')) + ->columnSpanFull(), + ]), + Repeater::make('apprise_channel_urls') + ->label(__('settings/notifications.apprise_channels')) + ->schema([ + TextInput::make('channel_url') + ->label(__('settings/notifications.apprise_channel_url')) + ->placeholder('discord://WebhookID/WebhookToken') + ->helperText(__('settings/notifications.apprise_channel_url_helper')) + ->maxLength(2000) + ->distinct() + ->required() + ->rule(new AppriseScheme), + ]) + ->columnSpanFull(), + Actions::make([ + Action::make('test apprise') + ->label(__('settings/notifications.test_apprise_channel')) + ->action(fn (Get $get) => SendAppriseTestNotification::run( + channel_urls: $get('apprise_channel_urls'), + )) + ->hidden(fn (Get $get) => ! count($get('apprise_channel_urls'))), + ]), + ]), + ]), ]) ->columnSpanFull(), diff --git a/app/Filament/Pages/Settings/Thresholds.php b/app/Filament/Pages/Settings/Thresholds.php index 6c8adb4ab..1953ff52a 100644 --- a/app/Filament/Pages/Settings/Thresholds.php +++ b/app/Filament/Pages/Settings/Thresholds.php @@ -15,7 +15,7 @@ class Thresholds extends SettingsPage { - protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-exclamation-triangle'; + protected static string|\BackedEnum|null $navigationIcon = 'tabler-alert-triangle'; protected static string|\UnitEnum|null $navigationGroup = 'Settings'; diff --git a/app/Filament/Resources/Results/ResultResource.php b/app/Filament/Resources/Results/ResultResource.php index 6efd4a9f4..5ff0893d7 100644 --- a/app/Filament/Resources/Results/ResultResource.php +++ b/app/Filament/Resources/Results/ResultResource.php @@ -14,7 +14,7 @@ class ResultResource extends Resource { protected static ?string $model = Result::class; - protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-table-cells'; + protected static string|\BackedEnum|null $navigationIcon = 'tabler-table'; public static function getNavigationLabel(): string { diff --git a/app/Filament/Resources/Users/UserResource.php b/app/Filament/Resources/Users/UserResource.php index 914172ab8..c1b2d958d 100644 --- a/app/Filament/Resources/Users/UserResource.php +++ b/app/Filament/Resources/Users/UserResource.php @@ -14,9 +14,7 @@ class UserResource extends Resource { protected static ?string $model = User::class; - protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-users'; - - protected static string|\UnitEnum|null $navigationGroup = 'Settings'; + protected static string|\BackedEnum|null $navigationIcon = 'tabler-users'; protected static ?int $navigationSort = 4; diff --git a/app/Filament/Widgets/StatsOverviewWidget.php b/app/Filament/Widgets/StatsOverviewWidget.php deleted file mode 100644 index 8178c10e3..000000000 --- a/app/Filament/Widgets/StatsOverviewWidget.php +++ /dev/null @@ -1,76 +0,0 @@ -result = Result::query() - ->select(['id', 'ping', 'download', 'upload', 'status', 'created_at']) - ->where('status', '=', ResultStatus::Completed) - ->latest() - ->first(); - - if (blank($this->result)) { - return [ - Stat::make(__('dashboard.latest_download'), '-') - ->icon('heroicon-o-arrow-down-tray'), - Stat::make(__('dashboard.latest_upload'), '-') - ->icon('heroicon-o-arrow-up-tray'), - Stat::make(__('dashboard.latest_ping'), '-') - ->icon('heroicon-o-clock'), - ]; - } - - $previous = Result::query() - ->select(['id', 'ping', 'download', 'upload', 'status', 'created_at']) - ->where('id', '<', $this->result->id) - ->where('status', '=', ResultStatus::Completed) - ->latest() - ->first(); - - if (! $previous) { - return [ - Stat::make(__('dashboard.latest_download'), fn (): string => ! blank($this->result) ? Number::toBitRate(bits: $this->result->download_bits, precision: 2) : 'n/a') - ->icon('heroicon-o-arrow-down-tray'), - Stat::make(__('dashboard.latest_upload'), fn (): string => ! blank($this->result) ? Number::toBitRate(bits: $this->result->upload_bits, precision: 2) : 'n/a') - ->icon('heroicon-o-arrow-up-tray'), - Stat::make(__('dashboard.latest_ping'), fn (): string => ! blank($this->result) ? number_format($this->result->ping, 2).' ms' : 'n/a') - ->icon('heroicon-o-clock'), - ]; - } - - $downloadChange = percentChange($this->result->download, $previous->download, 2); - $uploadChange = percentChange($this->result->upload, $previous->upload, 2); - $pingChange = percentChange($this->result->ping, $previous->ping, 2); - - return [ - Stat::make(__('dashboard.latest_download'), fn (): string => ! blank($this->result) ? Number::toBitRate(bits: $this->result->download_bits, precision: 2) : 'n/a') - ->icon('heroicon-o-arrow-down-tray') - ->description($downloadChange > 0 ? $downloadChange.'% '.__('general.faster') : abs($downloadChange).'% '.__('general.slower')) - ->descriptionIcon($downloadChange > 0 ? 'heroicon-m-arrow-trending-up' : 'heroicon-m-arrow-trending-down') - ->color($downloadChange > 0 ? 'success' : 'danger'), - Stat::make(__('dashboard.latest_upload'), fn (): string => ! blank($this->result) ? Number::toBitRate(bits: $this->result->upload_bits, precision: 2) : 'n/a') - ->icon('heroicon-o-arrow-up-tray') - ->description($uploadChange > 0 ? $uploadChange.'% '.__('general.faster') : abs($uploadChange).'% '.__('general.slower')) - ->descriptionIcon($uploadChange > 0 ? 'heroicon-m-arrow-trending-up' : 'heroicon-m-arrow-trending-down') - ->color($uploadChange > 0 ? 'success' : 'danger'), - Stat::make(__('dashboard.latest_ping'), fn (): string => ! blank($this->result) ? number_format($this->result->ping, 2).' ms' : 'n/a') - ->icon('heroicon-o-clock') - ->description($pingChange > 0 ? $pingChange.'% '.__('general.slower') : abs($pingChange).'% '.__('general.faster')) - ->descriptionIcon($pingChange > 0 ? 'heroicon-m-arrow-trending-up' : 'heroicon-m-arrow-trending-down') - ->color($pingChange > 0 ? 'danger' : 'success'), - ]; - } -} diff --git a/app/Http/Controllers/HomeController.php b/app/Http/Controllers/HomeController.php index 0b6d0e906..543c692e4 100644 --- a/app/Http/Controllers/HomeController.php +++ b/app/Http/Controllers/HomeController.php @@ -2,8 +2,6 @@ namespace App\Http\Controllers; -use App\Enums\ResultStatus; -use App\Models\Result; use Illuminate\Http\Request; class HomeController extends Controller @@ -13,14 +11,6 @@ class HomeController extends Controller */ public function __invoke(Request $request) { - $latestResult = Result::query() - ->select(['id', 'ping', 'download', 'upload', 'status', 'created_at']) - ->where('status', '=', ResultStatus::Completed) - ->latest() - ->first(); - - return view('dashboard', [ - 'latestResult' => $latestResult, - ]); + return view('dashboard'); } } diff --git a/app/Listeners/ProcessCompletedSpeedtest.php b/app/Listeners/ProcessCompletedSpeedtest.php index 228151750..66b927f1a 100644 --- a/app/Listeners/ProcessCompletedSpeedtest.php +++ b/app/Listeners/ProcessCompletedSpeedtest.php @@ -3,15 +3,19 @@ namespace App\Listeners; use App\Events\SpeedtestCompleted; +use App\Helpers\Number; use App\Mail\CompletedSpeedtestMail; use App\Models\Result; use App\Models\User; +use App\Notifications\Apprise\SpeedtestNotification; use App\Settings\NotificationSettings; use Filament\Actions\Action; -use Filament\Notifications\Notification; +use Filament\Notifications\Notification as FilamentNotification; use Illuminate\Support\Arr; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Mail; +use Illuminate\Support\Facades\Notification; +use Illuminate\Support\Str; use Spatie\WebhookServer\WebhookCall; class ProcessCompletedSpeedtest @@ -29,7 +33,7 @@ public function handle(SpeedtestCompleted $event): void $result->loadMissing(['dispatchedBy']); - // $this->notifyAppriseChannels($result); + $this->notifyAppriseChannels($result); $this->notifyDatabaseChannels($result); $this->notifyDispatchingUser($result); $this->notifyMailChannels($result); @@ -42,11 +46,50 @@ 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) { + if (filled($result->dispatched_by) || $result->healthy === false) { return; } - // + // Check if Apprise notifications are enabled. + if (! $this->notificationSettings->apprise_enabled || ! $this->notificationSettings->apprise_on_speedtest_run) { + return; + } + + if (! count($this->notificationSettings->apprise_channel_urls)) { + Log::warning('Apprise channel URLs not found, check Apprise notification channel settings.'); + + return; + } + + // Build the speedtest data + $body = view('apprise.speedtest-completed', [ + 'id' => $result->id, + 'service' => Str::title($result->service->getLabel()), + 'serverName' => $result->server_name, + 'serverId' => $result->server_id, + 'isp' => $result->isp, + 'ping' => round($result->ping).' ms', + 'download' => Number::toBitRate(bits: $result->download_bits, precision: 2), + 'upload' => Number::toBitRate(bits: $result->upload_bits, precision: 2), + 'packetLoss' => $result->packet_loss, + 'speedtest_url' => $result->result_url, + 'url' => url('/admin/results'), + ])->render(); + + $title = 'Speedtest Completed – #'.$result->id; + + // Send notification to each configured channel URL + foreach ($this->notificationSettings->apprise_channel_urls as $row) { + $channelUrl = $row['channel_url'] ?? null; + if (! $channelUrl) { + Log::warning('Skipping entry with missing channel_url.'); + + continue; + } + + Notification::route('apprise_urls', $channelUrl) + ->notify(new SpeedtestNotification($title, $body, 'info')); + } } /** @@ -65,7 +108,7 @@ private function notifyDatabaseChannels(Result $result): void } foreach (User::all() as $user) { - Notification::make() + FilamentNotification::make() ->title(__('results.speedtest_completed')) ->actions([ Action::make('view') @@ -87,7 +130,7 @@ private function notifyDispatchingUser(Result $result): void } $result->dispatchedBy->notify( - Notification::make() + FilamentNotification::make() ->title(__('results.speedtest_completed')) ->actions([ Action::make('view') diff --git a/app/Listeners/ProcessUnhealthySpeedtest.php b/app/Listeners/ProcessUnhealthySpeedtest.php index 5a6d0e057..68b9b6a9b 100644 --- a/app/Listeners/ProcessUnhealthySpeedtest.php +++ b/app/Listeners/ProcessUnhealthySpeedtest.php @@ -3,14 +3,18 @@ namespace App\Listeners; use App\Events\SpeedtestBenchmarkFailed; +use App\Helpers\Number; use App\Mail\UnhealthySpeedtestMail; use App\Models\Result; use App\Models\User; +use App\Notifications\Apprise\SpeedtestNotification; use App\Settings\NotificationSettings; use Filament\Actions\Action; -use Filament\Notifications\Notification; +use Filament\Notifications\Notification as FilamentNotification; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Mail; +use Illuminate\Support\Facades\Notification; +use Illuminate\Support\Str; use Spatie\WebhookServer\WebhookCall; class ProcessUnhealthySpeedtest @@ -31,7 +35,7 @@ public function handle(SpeedtestBenchmarkFailed $event): void $result->loadMissing(['dispatchedBy']); - // $this->notifyAppriseChannels($result); + $this->notifyAppriseChannels($result); $this->notifyDatabaseChannels($result); $this->notifyDispatchingUser($result); $this->notifyMailChannels($result); @@ -48,7 +52,79 @@ private function notifyAppriseChannels(Result $result): void return; } - // + if (! $this->notificationSettings->apprise_enabled || ! $this->notificationSettings->apprise_on_threshold_failure) { + return; + } + + if (! count($this->notificationSettings->apprise_channel_urls)) { + Log::warning('Apprise channel URLs not found, check Apprise notification channel settings.'); + + return; + } + + if (empty($result->benchmarks)) { + Log::warning('Benchmark data not found, won\'t send Apprise notification.'); + + return; + } + + // Build metrics array from failed benchmarks + $failed = []; + + foreach ($result->benchmarks as $metric => $benchmark) { + if ($benchmark['passed'] === false) { + $failed[] = [ + 'name' => ucfirst($metric), + 'threshold' => $benchmark['value'].' '.$benchmark['unit'], + 'value' => $this->formatMetricValue($metric, $result), + ]; + } + } + + if (! count($failed)) { + Log::warning('No failed thresholds found in benchmarks, won\'t send Apprise notification.'); + + return; + } + + $body = view('apprise.speedtest-threshold', [ + 'id' => $result->id, + 'service' => Str::title($result->service->getLabel()), + 'serverName' => $result->server_name, + 'serverId' => $result->server_id, + 'isp' => $result->isp, + 'metrics' => $failed, + 'speedtest_url' => $result->result_url, + 'url' => url('/admin/results'), + ])->render(); + + $title = 'Speedtest Threshold Breach – #'.$result->id; + + // Send notification to each configured channel URL + foreach ($this->notificationSettings->apprise_channel_urls as $row) { + $channelUrl = $row['channel_url'] ?? null; + if (! $channelUrl) { + Log::warning('Skipping entry with missing channel_url.'); + + continue; + } + + Notification::route('apprise_urls', $channelUrl) + ->notify(new SpeedtestNotification($title, $body, 'warning')); + } + } + + /** + * Format metric value for display in notification. + */ + private function formatMetricValue(string $metric, Result $result): string + { + return match ($metric) { + 'download' => Number::toBitRate(bits: $result->download_bits, precision: 2), + 'upload' => Number::toBitRate(bits: $result->upload_bits, precision: 2), + 'ping' => round($result->ping, 2).' ms', + default => '', + }; } /** @@ -67,7 +143,7 @@ private function notifyDatabaseChannels(Result $result): void } foreach (User::all() as $user) { - Notification::make() + FilamentNotification::make() ->title(__('results.speedtest_benchmark_failed')) ->actions([ Action::make('view') @@ -89,7 +165,7 @@ private function notifyDispatchingUser(Result $result): void } $result->dispatchedBy->notify( - Notification::make() + FilamentNotification::make() ->title(__('results.speedtest_benchmark_failed')) ->actions([ Action::make('view') @@ -106,7 +182,7 @@ private function notifyDispatchingUser(Result $result): void */ private function notifyMailChannels(Result $result): void { - // Don't send webhook if dispatched by a user. + // Don't send mail if dispatched by a user. if (filled($result->dispatched_by)) { return; } diff --git a/app/Livewire/LatestResultStats.php b/app/Livewire/LatestResultStats.php new file mode 100644 index 000000000..64217b8ab --- /dev/null +++ b/app/Livewire/LatestResultStats.php @@ -0,0 +1,24 @@ +latest() + ->first(); + } + + public function render() + { + return view('livewire.latest-result-stats'); + } +} diff --git a/app/Livewire/PlatformStats.php b/app/Livewire/PlatformStats.php new file mode 100644 index 000000000..17668eabf --- /dev/null +++ b/app/Livewire/PlatformStats.php @@ -0,0 +1,45 @@ +getNextRunDate(timeZone: config('app.display_timezone'))); + } + + return null; + } + + #[Computed] + public function platformStats(): array + { + $totalResults = Result::count(); + $completedResults = Result::where('status', ResultStatus::Completed)->count(); + $failedResults = Result::where('status', ResultStatus::Failed)->count(); + + return [ + 'total' => Number::format($totalResults), + 'completed' => Number::format($completedResults), + 'failed' => Number::format($failedResults), + ]; + } + + public function render() + { + return view('livewire.platform-stats'); + } +} diff --git a/app/Livewire/Topbar/RunSpeedtestAction.php b/app/Livewire/Topbar/Actions.php similarity index 82% rename from app/Livewire/Topbar/RunSpeedtestAction.php rename to app/Livewire/Topbar/Actions.php index ad8c05271..95a8abcce 100644 --- a/app/Livewire/Topbar/RunSpeedtestAction.php +++ b/app/Livewire/Topbar/Actions.php @@ -13,21 +13,21 @@ use Filament\Forms\Contracts\HasForms; use Filament\Notifications\Notification; use Filament\Support\Enums\IconPosition; +use Filament\Support\Enums\Size; use Illuminate\Support\Facades\Auth; use Livewire\Component; -class RunSpeedtestAction extends Component implements HasActions, HasForms +class Actions extends Component implements HasActions, HasForms { use InteractsWithActions, InteractsWithForms; public function dashboardAction(): Action { - return Action::make('home') - ->label(__('results.public_dashboard')) - ->icon('heroicon-o-chart-bar') - ->iconPosition(IconPosition::Before) + return Action::make('metrics') + ->iconButton() + ->icon('tabler-chart-histogram') ->color('gray') - ->url(shouldOpenInNewTab: true, url: route('home')) + ->url(url: route('home')) ->extraAttributes([ 'id' => 'dashboardAction', ]); @@ -61,13 +61,14 @@ public function speedtestAction(): Action ->success() ->send(); }) - ->modalHeading(__('results.run_speedtest')) + ->modalHeading(__('results.speedtest')) ->modalWidth('lg') ->modalSubmitActionLabel(__('results.start')) ->button() + ->size(Size::Medium) ->color('primary') ->label(__('results.speedtest')) - ->icon('heroicon-o-rocket-launch') + ->icon('tabler-rocket') ->iconPosition(IconPosition::Before) ->hidden(! Auth::check() && Auth::user()->is_admin) ->extraAttributes([ @@ -77,6 +78,6 @@ public function speedtestAction(): Action public function render() { - return view('livewire.topbar.run-speedtest-action'); + return view('livewire.topbar.actions'); } } diff --git a/app/Notifications/Apprise/AppriseMessage.php b/app/Notifications/Apprise/AppriseMessage.php new file mode 100644 index 000000000..a510ded7b --- /dev/null +++ b/app/Notifications/Apprise/AppriseMessage.php @@ -0,0 +1,66 @@ +urls = $urls; + + return $this; + } + + public function title(string $title): self + { + $this->title = $title; + + return $this; + } + + public function body(string $body): self + { + $this->body = $body; + + return $this; + } + + public function type(string $type): self + { + $this->type = $type; + + return $this; + } + + public function format(string $format): self + { + $this->format = $format; + + return $this; + } + + public function tag(string $tag): self + { + $this->tag = $tag; + + return $this; + } +} diff --git a/app/Notifications/Apprise/SpeedtestNotification.php b/app/Notifications/Apprise/SpeedtestNotification.php new file mode 100644 index 000000000..3c2ffb3cd --- /dev/null +++ b/app/Notifications/Apprise/SpeedtestNotification.php @@ -0,0 +1,40 @@ + + */ + public function via(object $notifiable): array + { + return ['apprise']; + } + + /** + * Get the Apprise message representation of the notification. + */ + public function toApprise(object $notifiable): AppriseMessage + { + return AppriseMessage::create() + ->urls($notifiable->routes['apprise_urls']) + ->title($this->title) + ->body($this->body) + ->type($this->type); + } +} diff --git a/app/Notifications/Apprise/TestNotification.php b/app/Notifications/Apprise/TestNotification.php new file mode 100644 index 000000000..f07810fcc --- /dev/null +++ b/app/Notifications/Apprise/TestNotification.php @@ -0,0 +1,34 @@ + + */ + public function via(object $notifiable): array + { + return ['apprise']; + } + + /** + * Get the Apprise message representation of the notification. + */ + public function toApprise(object $notifiable): AppriseMessage + { + return AppriseMessage::create() + ->urls($notifiable->routes['apprise_urls']) + ->title('Test Notification') + ->body('👋 Testing the Apprise notification channel.') + ->type('info'); + } +} diff --git a/app/Notifications/AppriseChannel.php b/app/Notifications/AppriseChannel.php index af5ac3683..3cd2592a1 100644 --- a/app/Notifications/AppriseChannel.php +++ b/app/Notifications/AppriseChannel.php @@ -2,6 +2,7 @@ namespace App\Notifications; +use App\Settings\NotificationSettings; use Illuminate\Notifications\Notification; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; @@ -20,34 +21,54 @@ public function send(object $notifiable, Notification $notification): void return; } - $appriseUrl = config('services.apprise.url'); + $settings = app(NotificationSettings::class); + $appriseUrl = rtrim($settings->apprise_server_url ?? '', '/'); + + if (empty($appriseUrl)) { + Log::warning('Apprise notification skipped: No Server URL configured'); + + return; + } try { - $response = Http::timeout(5) + $request = Http::timeout(5) ->withHeaders([ 'Content-Type' => 'application/json', - ]) - // ->when(true, function ($http) { - // $http->withoutVerifying(); - // }) - ->post("{$appriseUrl}/notify", [ - 'urls' => $message->urls, - 'title' => $message->title, - 'body' => $message->body, - 'type' => $message->type ?? 'info', - 'format' => $message->format ?? 'text', - 'tag' => $message->tag ?? null, ]); + // If SSL verification is disabled in settings, skip it + if (! $settings->apprise_verify_ssl) { + $request = $request->withoutVerifying(); + } + + $response = $request->post("{$appriseUrl}/notify", [ + 'urls' => $message->urls, + 'title' => $message->title, + 'body' => $message->body, + 'type' => $message->type ?? 'info', + 'format' => $message->format ?? 'text', + 'tag' => $message->tag ?? null, + ]); + if ($response->failed()) { Log::error('Apprise notification failed', [ + 'channel' => $message->urls, + 'instance' => $appriseUrl, 'status' => $response->status(), 'body' => $response->body(), ]); + } else { + Log::info('Apprise notification sent', [ + 'channel' => $message->urls, + 'instance' => $appriseUrl, + ]); } - } catch (\Exception $e) { + } catch (\Throwable $e) { Log::error('Apprise notification exception', [ + 'channel' => $message->urls ?? 'unknown', + 'instance' => $appriseUrl, 'message' => $e->getMessage(), + 'exception' => get_class($e), ]); } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index e4ea6209f..ba434c79e 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -4,11 +4,14 @@ use App\Enums\UserRole; use App\Models\User; +use App\Notifications\AppriseChannel; use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Foundation\Console\AboutCommand; use Illuminate\Http\Request; +use Illuminate\Notifications\ChannelManager; use Illuminate\Support\Facades\Blade; use Illuminate\Support\Facades\Gate; +use Illuminate\Support\Facades\Notification; use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\URL; use Illuminate\Support\ServiceProvider; @@ -44,12 +47,25 @@ public function boot(): void $this->defineGates(); $this->forceHttps(); $this->setApiRateLimit(); + $this->registerNotificationChannels(); AboutCommand::add('Speedtest Tracker', fn () => [ 'Version' => config('speedtest.build_version'), ]); } + /** + * Register custom notification channels. + */ + protected function registerNotificationChannels(): void + { + Notification::resolved(function (ChannelManager $service) { + $service->extend('apprise', function ($app) { + return new AppriseChannel; + }); + }); + } + /** * Define custom if statements, these were added to make the blade templates more readable. * diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index f72019481..3a6ba6f40 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -2,12 +2,10 @@ namespace App\Providers\Filament; -use App\Services\GitHub\Repository; use Filament\Http\Middleware\Authenticate; use Filament\Http\Middleware\DisableBladeIconComponents; use Filament\Http\Middleware\DispatchServingFilamentEvent; use Filament\Navigation\NavigationGroup; -use Filament\Navigation\NavigationItem; use Filament\Panel; use Filament\PanelProvider; use Filament\Support\Colors\Color; @@ -59,25 +57,8 @@ public function panel(Panel $panel): Panel ]) ->navigationGroups([ NavigationGroup::make() - ->label(__('general.settings')), - NavigationGroup::make() - ->label(__('general.links')) + ->label(__('general.settings')) ->collapsible(false), - ]) - ->navigationItems([ - NavigationItem::make(__('general.documentation')) - ->url('https://docs.speedtest-tracker.dev/', shouldOpenInNewTab: true) - ->icon('heroicon-o-book-open') - ->group(__('general.links')), - NavigationItem::make(__('general.donate')) - ->url('https://github.com/sponsors/alexjustesen', shouldOpenInNewTab: true) - ->icon('heroicon-o-banknotes') - ->group(__('general.links')), - NavigationItem::make(config('speedtest.build_version')) - ->url('https://github.com/alexjustesen/speedtest-tracker', shouldOpenInNewTab: true) - ->icon('tabler-brand-github') - ->badge(fn (): string => Repository::updateAvailable() ? __('general.update_available') : __('general.up_to_date')) - ->group(__('general.links')), ]); } } diff --git a/app/Providers/FilamentServiceProvider.php b/app/Providers/FilamentServiceProvider.php index 1f6b2bd44..99e95cd2e 100644 --- a/app/Providers/FilamentServiceProvider.php +++ b/app/Providers/FilamentServiceProvider.php @@ -24,7 +24,7 @@ public function boot(): void { FilamentView::registerRenderHook( PanelsRenderHook::GLOBAL_SEARCH_BEFORE, - fn (): string => Blade::render("@livewire('topbar.run-speedtest-action')"), + fn (): string => Blade::render("@livewire('topbar.actions')"), ); } } diff --git a/app/Rules/AppriseScheme.php b/app/Rules/AppriseScheme.php new file mode 100644 index 000000000..03a50059e --- /dev/null +++ b/app/Rules/AppriseScheme.php @@ -0,0 +1,22 @@ + [ - 'url' => env('APPRISE_URL', 'http://apprise:8000'), - ], - 'telegram-bot-api' => [ 'token' => env('TELEGRAM_BOT_TOKEN'), ], diff --git a/config/speedtest.php b/config/speedtest.php index c9657334b..8fd6c0f81 100644 --- a/config/speedtest.php +++ b/config/speedtest.php @@ -8,7 +8,7 @@ */ 'build_date' => Carbon::parse('2025-12-05'), - 'build_version' => 'v1.11.2', + 'build_version' => 'v1.12.0', 'content_width' => env('CONTENT_WIDTH', '7xl'), diff --git a/database/settings/2024_12_31_164343_create_apprise_notification.php b/database/settings/2024_12_31_164343_create_apprise_notification.php new file mode 100644 index 000000000..1be9ac906 --- /dev/null +++ b/database/settings/2024_12_31_164343_create_apprise_notification.php @@ -0,0 +1,16 @@ +migrator->add('notification.apprise_enabled', false); + $this->migrator->add('notification.apprise_server_url', null); + $this->migrator->add('notification.apprise_on_speedtest_run', false); + $this->migrator->add('notification.apprise_on_threshold_failure', false); + $this->migrator->add('notification.apprise_verify_ssl', true); + $this->migrator->add('notification.apprise_channel_urls', null); + } +}; diff --git a/lang/en/general.php b/lang/en/general.php index dbc2d675f..33c212e21 100644 --- a/lang/en/general.php +++ b/lang/en/general.php @@ -1,6 +1,11 @@ 'Current version', + 'latest_version' => 'Latest version', + 'github' => 'GitHub', + 'repository' => 'Repository', + // Common actions 'save' => 'Save', 'cancel' => 'Cancel', @@ -32,6 +37,8 @@ 'created_at' => 'Created at', 'updated_at' => 'Updated at', 'url' => 'URL', + 'stats' => 'Stats', + 'statistics' => 'Statistics', // Navigation 'dashboard' => 'Dashboard', @@ -42,6 +49,7 @@ 'view_documentation' => 'View documentation', 'links' => 'Links', 'donate' => 'Donate', + 'donations' => 'Donations', // Roles 'admin' => 'Admin', @@ -54,12 +62,15 @@ 'last_month' => 'Last month', // Metrics + 'metrics' => 'Metrics', 'average' => 'Average', 'high' => 'High', 'low' => 'Low', 'faster' => 'faster', 'slower' => 'slower', 'healthy' => 'Healthy', + 'not_measured' => 'Not measured', + 'unhealthy' => 'Unhealthy', // Units 'ms' => 'ms', diff --git a/lang/en/results.php b/lang/en/results.php index 6d8a2016d..65f81386f 100644 --- a/lang/en/results.php +++ b/lang/en/results.php @@ -72,7 +72,6 @@ // Run Speedtest Action 'speedtest' => 'Speedtest', - 'public_dashboard' => 'Public Dashboard', 'select_server' => 'Select Server', 'select_server_helper' => 'Leave empty to run the speedtest without specifying a server. Blocked servers will be skipped.', 'manual_servers' => 'Manual servers', diff --git a/lang/en/settings/notifications.php b/lang/en/settings/notifications.php index ddc1baae8..203590b23 100644 --- a/lang/en/settings/notifications.php +++ b/lang/en/settings/notifications.php @@ -14,6 +14,19 @@ 'recipients' => 'Recipients', 'test_mail_channel' => 'Test mail channel', + // Apprise notifications + 'apprise' => 'Apprise', + 'enable_apprise_notifications' => 'Enable Apprise notifications', + 'apprise_server' => 'Apprise Server', + 'apprise_server_url' => 'Apprise Server URL', + 'apprise_verify_ssl' => 'Verify SSL', + 'apprise_channels' => 'Apprise Channels', + 'apprise_channel_url' => 'Channel URL', + 'apprise_hint_description' => 'For more information on setting up Apprise, view the documentation.', + 'apprise_channel_url_helper' => 'Provide the service endpoint URL for notifications.', + 'test_apprise_channel' => 'Test Apprise', + 'apprise_channel_url_validation_error' => 'The Apprise channel URL must not start with "http" or "https". Please provide a valid Apprise URL scheme.', + // Webhook 'webhook' => 'Webhook', 'webhooks' => 'Webhooks', diff --git a/resources/css/app.css b/resources/css/app.css index 54ea6064a..532b718c1 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -1,4 +1,5 @@ @import 'tailwindcss'; +@import './custom.css'; /* Safelist max-width utilities to always generate them */ @source inline("max-w-{xs,sm,md,lg,xl,2xl,3xl,4xl,5xl,6xl,7xl,full,min,max,fit,prose,screen-sm,screen-md,screen-lg,screen-xl,screen-2xl}"); diff --git a/resources/css/custom.css b/resources/css/custom.css new file mode 100644 index 000000000..465ee0cf8 --- /dev/null +++ b/resources/css/custom.css @@ -0,0 +1,11 @@ +.dashboard-page .fi-section-header { + padding-bottom: 0px; +} + +.dashboard-page .fi-section-header .fi-section-header-heading { + @apply font-medium text-zinc-600 dark:text-zinc-400; +} + +.dashboard-page .fi-section-content-ctn { + border-top: none; +} diff --git a/resources/css/filament/admin/theme.css b/resources/css/filament/admin/theme.css index b39705336..6d386b0e9 100644 --- a/resources/css/filament/admin/theme.css +++ b/resources/css/filament/admin/theme.css @@ -1,4 +1,6 @@ +@import 'tailwindcss'; @import '../../../../vendor/filament/filament/resources/css/theme.css'; +@import '../../custom.css'; @source '../../../../app/Filament/**/*'; @source '../../../../resources/views/filament/**/*'; @@ -6,6 +8,7 @@ /* Filament Plugins */ @source '../../../../vendor/codewithdennis/filament-simple-alert/resources/**/*.blade.php'; @source inline('animate-{spin,pulse,bounce}'); +@source inline('{bg,text,border,ring}-{amber,zinc}-{50,100,200,300,400,500,600,700,800,900,950}'); /* Additional styles */ .fi-topbar #dashboardAction .fi-btn-label, diff --git a/resources/views/apprise/speedtest-completed.blade.php b/resources/views/apprise/speedtest-completed.blade.php new file mode 100644 index 000000000..6363ee642 --- /dev/null +++ b/resources/views/apprise/speedtest-completed.blade.php @@ -0,0 +1,11 @@ +A new speedtest on {{ config('app.name') }} was completed using {{ $service }}. + +Server name: {{ $serverName }} +Server ID: {{ $serverId }} +ISP: {{ $isp }} +Ping: {{ $ping }} +Download: {{ $download }} +Upload: {{ $upload }} +Packet Loss: {{ $packetLoss }} % +Ookla Speedtest: {{ $speedtest_url }} +URL: {{ $url }} diff --git a/resources/views/apprise/speedtest-threshold.blade.php b/resources/views/apprise/speedtest-threshold.blade.php new file mode 100644 index 000000000..6d0bb4926 --- /dev/null +++ b/resources/views/apprise/speedtest-threshold.blade.php @@ -0,0 +1,7 @@ +A new speedtest on **{{ config('app.name') }}** was completed using **{{ $service }}** on **{{ $isp }}** but a threshold was breached. + +@foreach ($metrics as $item) +- **{{ $item['name'] }}** {{ $item['threshold'] }}: {{ $item['value'] }} +@endforeach +- **Ookla Speedtest:** {{ $speedtest_url }} +- **URL:** {{ $url }} diff --git a/resources/views/dashboard.blade.php b/resources/views/dashboard.blade.php index 8a03ce90c..b3d3762b7 100644 --- a/resources/views/dashboard.blade.php +++ b/resources/views/dashboard.blade.php @@ -1,39 +1,26 @@ -
-
- @livewire(\App\Filament\Widgets\StatsOverviewWidget::class) -
+
+ + + - @isset($latestResult) -
- Latest result: -
- @endisset +
+

+ + Metrics +

-
@livewire(\App\Filament\Widgets\RecentDownloadChartWidget::class) -
-
@livewire(\App\Filament\Widgets\RecentUploadChartWidget::class) -
-
@livewire(\App\Filament\Widgets\RecentPingChartWidget::class) -
-
@livewire(\App\Filament\Widgets\RecentJitterChartWidget::class) -
-
@livewire(\App\Filament\Widgets\RecentDownloadLatencyChartWidget::class) -
-
@livewire(\App\Filament\Widgets\RecentUploadLatencyChartWidget::class)
-
- diff --git a/resources/views/filament/pages/dashboard.blade.php b/resources/views/filament/pages/dashboard.blade.php index f351a1c97..42eb6bbf5 100644 --- a/resources/views/filament/pages/dashboard.blade.php +++ b/resources/views/filament/pages/dashboard.blade.php @@ -1,3 +1,97 @@ - - {{-- Silence is golden --}} + +
+ + + + +
+ + + {{ __('general.documentation') }} + + +
+

Need help getting started or configuring your speedtests?

+
+ +
+ + {{ __('general.view_documentation') }} + +
+
+ + + + {{ __('general.donations') }} + + +
+

Support the development and maintenance of Speedtest Tracker by making a donation.

+
+ +
+ + {{ __('general.donate') }} + +
+
+ + + + {{ __('general.speedtest_tracker') }} + + + @if (\App\Services\GitHub\Repository::updateAvailable()) + + + {{ __('general.update_available') }} + + + @endif + +
    +
  • +

    {{ __('general.current_version') }}

    +

    {{ config('speedtest.build_version') }}

    +
  • + +
  • +

    {{ __('general.latest_version') }}

    +

    {{ \App\Services\GitHub\Repository::getLatestVersion() }}

    +
  • +
+ +
+ + {{ __('general.github') }} {{ str(__('general.repository'))->lower() }} + +
+
+
+
diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index 80ba6986d..cc94b33d3 100644 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -40,7 +40,57 @@

{{ $title ?? 'Page Title' }} - {{ config('app.name') }}

-
+
+
+ + + + + +
+ + @filled($this->latestResult) +
+
+

+ + Latest result +

+ + + {{ __('general.view') }} + +
+ + + + Benchmark status + + +
+ @if($this->latestResult->healthy === true) +
+
+
+ + {{ __('general.healthy') }} + @elseif($this->latestResult->healthy === false) +
+
+
+ + {{ __('general.unhealthy') }} + @else +
+
+
+ + {{ __('general.not_measured') }} + @endif +
+
+ + + + {{ __('general.download') }} + + + @php + $downloadBenchmark = Arr::get($this->latestResult->benchmarks, 'download'); + $downloadBenchmarkPassed = Arr::get($downloadBenchmark, 'passed', false); + @endphp + + @filled($downloadBenchmark) + + $downloadBenchmarkPassed, + 'text-amber-500 dark:text-amber-400' => ! $downloadBenchmarkPassed, + ]) title="Benchmark {{ $downloadBenchmarkPassed ? 'passed' : 'failed' }}"> + @if (! $downloadBenchmarkPassed) + + @endif + {{ Arr::get($downloadBenchmark, 'value').' '.str(Arr::get($downloadBenchmark, 'unit'))->title() }} + + + @endfilled + +

+ @php + $download = \App\Helpers\Bitrate::formatBits(\App\Helpers\Bitrate::bytesToBits($this->latestResult?->download)); + + $download = explode(' ', $download); + @endphp + + {{ $download[0] }} + {{ $download[1].'ps' }} +

+
+ + + + {{ __('general.upload') }} + + + @php + $uploadBenchmark = Arr::get($this->latestResult->benchmarks, 'upload'); + $uploadBenchmarkPassed = Arr::get($uploadBenchmark, 'passed', false); + @endphp + + @filled($uploadBenchmark) + + $uploadBenchmarkPassed, + 'text-amber-500 dark:text-amber-400' => ! $uploadBenchmarkPassed, + ]) title="Benchmark {{ $uploadBenchmarkPassed ? 'passed' : 'failed' }}"> + @if (! $uploadBenchmarkPassed) + + @endif + {{ Arr::get($uploadBenchmark, 'value').' '.str(Arr::get($uploadBenchmark, 'unit'))->title() }} + + + @endfilled + +

+ @php + $upload = \App\Helpers\Bitrate::formatBits(\App\Helpers\Bitrate::bytesToBits($this->latestResult?->upload)); + + $upload = explode(' ', $upload); + @endphp + + {{ $upload[0] }} + {{ $upload[1].'ps' }} +

+
+ + + + {{ __('general.ping') }} + + + @php + $pingBenchmark = Arr::get($this->latestResult->benchmarks, 'ping'); + $pingBenchmarkPassed = Arr::get($pingBenchmark, 'passed', false); + @endphp + + @filled($pingBenchmark) + + $pingBenchmarkPassed, + 'text-amber-500 dark:text-amber-400' => ! $pingBenchmarkPassed, + ]) title="Benchmark {{ $pingBenchmarkPassed ? 'passed' : 'failed' }}"> + @if (! $pingBenchmarkPassed) + + @endif + {{ Arr::get($pingBenchmark, 'value').' '.str(Arr::get($pingBenchmark, 'unit')) }} + + + @endfilled + +

+ {{ $this->latestResult?->ping }} + ms +

+
+
+ @endfilled +
\ No newline at end of file diff --git a/resources/views/livewire/platform-stats.blade.php b/resources/views/livewire/platform-stats.blade.php new file mode 100644 index 000000000..c9ddfa9c9 --- /dev/null +++ b/resources/views/livewire/platform-stats.blade.php @@ -0,0 +1,68 @@ +
+
+

+ + {{ __('general.statistics') }} +

+ + {{-- +
+

Quota Usage

+ Edit +
+ +
+
+ Bandwidth + 450MB of 1 GB +
+ +
+
+
+
+
--}} + + @filled($this->nextSpeedtest) + + + Next Speedtest in + + +

{{ $this->nextSpeedtest->diffForHumans() }}

+
+ @else + + + Next Speedtest in + + +

No scheduled speedtests

+
+ @endfilled + + + + Total tests + + +

{{ $this->platformStats['total'] }}

+
+ + + + Total successful tests + + +

{{ $this->platformStats['completed'] }}

+
+ + + + Total failed tests + + +

{{ $this->platformStats['failed'] }}

+
+
+
diff --git a/resources/views/livewire/topbar/actions.blade.php b/resources/views/livewire/topbar/actions.blade.php new file mode 100644 index 000000000..b27fb1767 --- /dev/null +++ b/resources/views/livewire/topbar/actions.blade.php @@ -0,0 +1,10 @@ +
+
+ {{ $this->speedtestAction }} + + {{ $this->dashboardAction }} + +
+ + +
diff --git a/resources/views/livewire/topbar/run-speedtest-action.blade.php b/resources/views/livewire/topbar/run-speedtest-action.blade.php deleted file mode 100644 index 3c88447e0..000000000 --- a/resources/views/livewire/topbar/run-speedtest-action.blade.php +++ /dev/null @@ -1,9 +0,0 @@ -
-
- {{ $this->dashboard }} - - {{ $this->speedtestAction }} -
- - -