diff --git a/app/Filament/Resources/PingResults/PingResultResource.php b/app/Filament/Resources/PingResults/PingResultResource.php index d7bd70c93..123cccd4a 100755 --- a/app/Filament/Resources/PingResults/PingResultResource.php +++ b/app/Filament/Resources/PingResults/PingResultResource.php @@ -16,6 +16,8 @@ class PingResultResource extends Resource protected static string|\BackedEnum|null $navigationIcon = 'tabler-chart-line'; + protected static string|\UnitEnum|null $navigationGroup = 'Monitor de Ping'; + public static function getNavigationLabel(): string { return __('ping.ping_results'); diff --git a/app/Filament/Resources/PingResults/Tables/PingResultTable.php b/app/Filament/Resources/PingResults/Tables/PingResultTable.php index 92d7b476a..ac0fb6ffb 100755 --- a/app/Filament/Resources/PingResults/Tables/PingResultTable.php +++ b/app/Filament/Resources/PingResults/Tables/PingResultTable.php @@ -85,6 +85,9 @@ public static function table(Table $table): Table }), ]) ->defaultSort('created_at', 'desc') + ->persistFilters() + ->persistSort() + ->persistColumnVisibility() ->poll('60s'); } } diff --git a/app/Filament/Resources/PingTargets/PingTargetResource.php b/app/Filament/Resources/PingTargets/PingTargetResource.php index 72a953d0e..c4c1c4cce 100755 --- a/app/Filament/Resources/PingTargets/PingTargetResource.php +++ b/app/Filament/Resources/PingTargets/PingTargetResource.php @@ -18,6 +18,8 @@ class PingTargetResource extends Resource protected static string|\BackedEnum|null $navigationIcon = 'tabler-broadcast'; + protected static string|\UnitEnum|null $navigationGroup = 'Monitor de Ping'; + public static function getNavigationLabel(): string { return __('ping.ping_targets'); @@ -51,4 +53,9 @@ public static function getPages(): array 'edit' => EditPingTarget::route('/{record}/edit'), ]; } + + public static function getGloballySearchableAttributes(): array + { + return ['name', 'host']; + } } diff --git a/app/Filament/Resources/PingTargets/Schemas/PingTargetForm.php b/app/Filament/Resources/PingTargets/Schemas/PingTargetForm.php index a417a791a..1403c5b16 100755 --- a/app/Filament/Resources/PingTargets/Schemas/PingTargetForm.php +++ b/app/Filament/Resources/PingTargets/Schemas/PingTargetForm.php @@ -27,7 +27,11 @@ public static function schema(): array ->label(__('ping.host')) ->placeholder(__('ping.host_placeholder')) ->required() - ->maxLength(255), + ->maxLength(255) + ->rules(['regex:/^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}|(\d{1,3}\.){3}\d{1,3}$/i']) + ->validationMessages([ + 'regex' => 'The host must be a valid domain or IP address.', + ]), Select::make('interval_seconds') ->label(__('ping.interval_seconds')) diff --git a/app/Filament/Resources/PingTargets/Tables/PingTargetTable.php b/app/Filament/Resources/PingTargets/Tables/PingTargetTable.php index b1737913b..72392a0ff 100755 --- a/app/Filament/Resources/PingTargets/Tables/PingTargetTable.php +++ b/app/Filament/Resources/PingTargets/Tables/PingTargetTable.php @@ -71,6 +71,9 @@ public static function table(Table $table): Table DeleteAction::make(), ]), ]) - ->defaultSort('id', 'desc'); + ->defaultSort('id', 'desc') + ->persistFilters() + ->persistSort() + ->persistColumnVisibility(); } } diff --git a/app/Filament/Resources/Results/ResultResource.php b/app/Filament/Resources/Results/ResultResource.php index 5ff0893d7..66a726655 100755 --- a/app/Filament/Resources/Results/ResultResource.php +++ b/app/Filament/Resources/Results/ResultResource.php @@ -16,6 +16,8 @@ class ResultResource extends Resource protected static string|\BackedEnum|null $navigationIcon = 'tabler-table'; + protected static \UnitEnum|string|null $navigationGroup = null; + public static function getNavigationLabel(): string { return __('results.title'); @@ -47,4 +49,9 @@ public static function getPages(): array 'index' => ListResults::route('/'), ]; } + + public static function getGloballySearchableAttributes(): array + { + return ['id', 'data->interface->externalIp', 'data->server->name', 'data->server->id']; + } } diff --git a/app/Filament/Resources/Results/Schemas/ResultForm.php b/app/Filament/Resources/Results/Schemas/ResultForm.php index 3e2e5b852..af18668eb 100755 --- a/app/Filament/Resources/Results/Schemas/ResultForm.php +++ b/app/Filament/Resources/Results/Schemas/ResultForm.php @@ -6,9 +6,9 @@ use App\Models\Result; use Carbon\Carbon; use Filament\Forms\Components\Checkbox; +use Filament\Forms\Components\Placeholder; use Filament\Forms\Components\Textarea; use Filament\Forms\Components\TextInput; -use Filament\Infolists\Components\TextEntry; use Filament\Schemas\Components\Grid; use Filament\Schemas\Components\Section; use Illuminate\Support\HtmlString; @@ -100,27 +100,27 @@ public static function schema(): array // Right column: Server & Metadata Section::make(__('results.server_&_metadata'))->schema([ - TextEntry::make('service') + Placeholder::make('service') ->label(__('results.service')) - ->state(fn (Result $result): string => $result->service->getLabel()), - TextEntry::make('server_name') + ->content(fn (Result $result): string => $result->service->getLabel()), + Placeholder::make('server_name') ->label(__('results.server_name')) - ->state(fn (Result $result): ?string => $result->server_name), - TextEntry::make('server_id') + ->content(fn (Result $result): ?string => $result->server_name), + Placeholder::make('server_id') ->label(__('results.server_id')) - ->state(fn (Result $result): ?string => $result->server_id), - TextEntry::make('isp') + ->content(fn (Result $result): ?string => $result->server_id), + Placeholder::make('isp') ->label(__('results.isp')) - ->state(fn (Result $result): ?string => $result->isp), - TextEntry::make('server_location') + ->content(fn (Result $result): ?string => $result->isp), + Placeholder::make('server_location') ->label(__('results.server_location')) - ->state(fn (Result $result): ?string => $result->server_location), - TextEntry::make('server_host') + ->content(fn (Result $result): ?string => $result->server_location), + Placeholder::make('server_host') ->label(__('results.server_host')) - ->state(fn (Result $result): ?string => $result->server_host), - TextEntry::make('comment') + ->content(fn (Result $result): ?string => $result->server_host), + Placeholder::make('comment') ->label(__('general.comment')) - ->state(fn (Result $result): ?string => $result->comments), + ->content(fn (Result $result): ?string => $result->comments), Checkbox::make('scheduled') ->label(__('results.scheduled')), Checkbox::make('healthy') diff --git a/app/Filament/Resources/Results/Tables/ResultTable.php b/app/Filament/Resources/Results/Tables/ResultTable.php index 6808e9f7d..815599b59 100755 --- a/app/Filament/Resources/Results/Tables/ResultTable.php +++ b/app/Filament/Resources/Results/Tables/ResultTable.php @@ -272,6 +272,9 @@ public static function table(Table $table): Table ->fileName(fn (): string => 'results-'.now()->timestamp), ]) ->defaultSort('id', 'desc') + ->persistFilters() + ->persistSort() + ->persistColumnVisibility() ->paginationPageOptions([10, 25, 50]) ->poll('60s'); } diff --git a/app/Filament/Resources/Users/UserResource.php b/app/Filament/Resources/Users/UserResource.php index c1b2d958d..f5dc1c0de 100755 --- a/app/Filament/Resources/Users/UserResource.php +++ b/app/Filament/Resources/Users/UserResource.php @@ -18,6 +18,8 @@ class UserResource extends Resource protected static ?int $navigationSort = 4; + protected static \UnitEnum|string|null $navigationGroup = null; + public static function getLabel(): ?string { return __('general.user'); @@ -51,4 +53,9 @@ public static function getPages(): array 'index' => ListUsers::route('/'), ]; } + + public static function getGloballySearchableAttributes(): array + { + return ['name', 'email']; + } } diff --git a/app/Jobs/RunPingTargetJob.php b/app/Jobs/RunPingTargetJob.php index a187f3cd2..76d801095 100755 --- a/app/Jobs/RunPingTargetJob.php +++ b/app/Jobs/RunPingTargetJob.php @@ -5,8 +5,11 @@ use App\Actions\PingHostname; use App\Models\PingResult; use App\Models\PingTarget; +use App\Models\User; +use App\Notifications\PingTargetOfflineNotification; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Queue\Queueable; +use Illuminate\Support\Facades\Notification; class RunPingTargetJob implements ShouldQueue { @@ -24,15 +27,21 @@ public function __construct( */ public function handle(PingHostname $pingHostname): void { + $lastResult = $this->pingTarget->pingResults()->latest('created_at')->first(); + $result = $pingHostname->run($this->pingTarget->host, $this->pingTarget->packet_count ?? 1); - if ($result === null) { + if ($result === null || ! $result->isSuccess()) { $this->pingTarget->pingResults()->create([ 'latency' => null, - 'packet_loss' => null, + 'packet_loss' => $result ? (float) $result->packetLossPercentage() : null, 'is_reachable' => false, ]); + if ($lastResult === null || $lastResult->is_reachable) { + $this->notifyAdmins(); + } + return; } @@ -41,9 +50,16 @@ public function handle(PingHostname $pingHostname): void $isReachable = $result->isSuccess(); $this->pingTarget->pingResults()->create([ - 'latency' => $isReachable ? round($latency, 3) : null, + 'latency' => round($latency, 3), 'packet_loss' => (float) $packetLoss, 'is_reachable' => $isReachable, ]); } + + protected function notifyAdmins(): void + { + $admins = User::where('role', \App\Enums\UserRole::Admin)->get(); + + Notification::send($admins, new PingTargetOfflineNotification($this->pingTarget)); + } } diff --git a/app/Notifications/PingTargetOfflineNotification.php b/app/Notifications/PingTargetOfflineNotification.php new file mode 100644 index 000000000..aaa491757 --- /dev/null +++ b/app/Notifications/PingTargetOfflineNotification.php @@ -0,0 +1,82 @@ + + */ + public function via(object $notifiable): array + { + $channels = []; + + if (config('services.telegram-bot-api.token')) { + $channels[] = 'telegram'; + } + + if (config('mail.from.address')) { + $channels[] = 'mail'; + } + + $channels[] = 'database'; + + return $channels; + } + + /** + * Get the mail representation of the notification. + */ + public function toMail(object $notifiable): MailMessage + { + return (new MailMessage) + ->error() + ->subject('Ping Target Offline: '.$this->pingTarget->name) + ->line('The ping target "'.$this->pingTarget->name.'" ('.$this->pingTarget->host.') is unreachable.') + ->action('View Results', url('/admin/ping-results')) + ->line('Thank you for using Speedtest Tracker!'); + } + + /** + * Get the Telegram message representation of the notification. + */ + public function toTelegram($notifiable): TelegramMessage + { + return TelegramMessage::create() + ->to($notifiable->routes['telegram_chat_id'] ?? null) + ->content(sprintf('⚠️ *Ping Target Offline* %sThe host "%s" (%s) is unreachable.', PHP_EOL, $this->pingTarget->name, $this->pingTarget->host)); + } + + /** + * Get the array representation of the notification. + * + * @return array + */ + public function toArray(object $notifiable): array + { + return [ + 'ping_target_id' => $this->pingTarget->id, + 'name' => $this->pingTarget->name, + 'host' => $this->pingTarget->host, + 'message' => 'Ping target is unreachable.', + ]; + } +} diff --git a/database/migrations/2026_02_27_160520_add_indexes_to_results_table.php b/database/migrations/2026_02_27_160520_add_indexes_to_results_table.php new file mode 100644 index 000000000..1854c090a --- /dev/null +++ b/database/migrations/2026_02_27_160520_add_indexes_to_results_table.php @@ -0,0 +1,30 @@ +index('service'); + $table->index('status'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('results', function (Blueprint $table) { + $table->dropIndex(['service']); + $table->dropIndex(['status']); + }); + } +}; diff --git a/package-lock.json b/package-lock.json index 8a6a64dd1..aa4663b44 100755 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "html", + "name": "app", "lockfileVersion": 3, "requires": true, "packages": {