diff --git a/app/Actions/LatencyTests/RunScheduledLatencyTests.php b/app/Actions/LatencyTests/RunScheduledLatencyTests.php new file mode 100644 index 000000000..55abbb1d9 --- /dev/null +++ b/app/Actions/LatencyTests/RunScheduledLatencyTests.php @@ -0,0 +1,62 @@ +getSettings(); + + // Check if latency tests are enabled + if (! $settings->latency_enabled) { + Log::info('Latency tests are disabled in the settings. Exiting.'); + + return; + } + + // Proceed with scheduling checks if enabled + $cronExpression = new CronExpression($settings->latency_schedule); + $now = now()->timezone(config('app.display_timezone')); + + Log::info('Checking if latency test is due.', [ + 'current_time' => $now, + 'is_due' => $cronExpression->isDue($now), + ]); + + if (! $cronExpression->isDue($now)) { + Log::info('Latency test is not due. Exiting.'); + + return; + } + + $urls = $settings->target_url; + Log::info('Running latency tests for URLs', ['urls' => $urls]); + + foreach ($urls as $urlItem) { + if (is_array($urlItem) && isset($urlItem['url']) && is_string($urlItem['url'])) { + $url = trim($urlItem['url']); + $target_name = $urlItem['target_name'] ?? 'Unnamed'; // Default to 'Unnamed' if no name is provided + if ($url) { + Log::info('Dispatching latency test', ['url' => $url, 'name' => $target_name]); + ExecuteLatencyTest::dispatch($url, $target_name); + } + } else { + Log::warning('Skipping invalid URL entry', ['urlItem' => $urlItem]); + } + } + } + + protected function getSettings(): LatencySettings + { + return app(LatencySettings::class); + } +} diff --git a/app/Enums/ThresholdBreached.php b/app/Enums/ThresholdBreached.php new file mode 100644 index 000000000..b275e67fc --- /dev/null +++ b/app/Enums/ThresholdBreached.php @@ -0,0 +1,31 @@ + 'danger', + self::Passed => 'success', + self::NotChecked => 'warning', + }; + } + + public function getLabel(): ?string + { + return match ($this) { + self::Failed => 'Failed', + self::Passed => 'Passed', + self::NotChecked => 'NotChecked', + }; + } +} diff --git a/app/Filament/Exports/ResultExporter.php b/app/Filament/Exports/ResultExporter.php index 05f7ac2ee..901f0a105 100644 --- a/app/Filament/Exports/ResultExporter.php +++ b/app/Filament/Exports/ResultExporter.php @@ -103,6 +103,11 @@ public static function getColumns(): array }), ExportColumn::make('comments') ->enabledByDefault(false), + ExportColumn::make('threshold_breached') + ->enabledByDefault(false) + ->state(function (Result $record): string { + return $record->threshold_breached; + }), // ExportColumn::make('status'), // TODO: enable status when upgrading to PHP v8.3: https://php.watch/versions/8.3/dynamic-class-const-enum-member-syntax-support ExportColumn::make('scheduled') ->state(function (Result $record): string { diff --git a/app/Filament/Pages/Dashboard.php b/app/Filament/Pages/Dashboard.php index 3737cf9bf..0d9602350 100644 --- a/app/Filament/Pages/Dashboard.php +++ b/app/Filament/Pages/Dashboard.php @@ -3,13 +3,10 @@ namespace App\Filament\Pages; use App\Actions\Speedtests\RunOoklaSpeedtest; -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 App\Filament\Widgets\Latency\DashboardLatencyChartWidget; +use App\Filament\Widgets\Speedtest\DashboardDownloadUploadChartWidget; +use App\Filament\Widgets\Speedtest\StatsOverviewWidget; +use App\Settings\LatencySettings; use Carbon\Carbon; use Cron\CronExpression; use Filament\Actions\Action; @@ -81,14 +78,19 @@ protected function getHeaderActions(): array protected function getHeaderWidgets(): array { - return [ + $widgets = [ StatsOverviewWidget::make(), - RecentDownloadChartWidget::make(), - RecentUploadChartWidget::make(), - RecentPingChartWidget::make(), - RecentJitterChartWidget::make(), - RecentDownloadLatencyChartWidget::make(), - RecentUploadLatencyChartWidget::make(), + DashboardDownloadUploadChartWidget::make(), ]; + + // Get the settings instance + $settings = app(LatencySettings::class); + + // Check if latency tests are enabled + if ($settings->latency_enabled) { + $widgets[] = DashboardLatencyChartWidget::make(); + } + + return $widgets; } } diff --git a/app/Filament/Pages/Latency/Latency.php b/app/Filament/Pages/Latency/Latency.php new file mode 100644 index 000000000..b250da5d4 --- /dev/null +++ b/app/Filament/Pages/Latency/Latency.php @@ -0,0 +1,71 @@ +latency_enabled || blank($settings->latency_schedule)) { + return __('No latency tests scheduled.'); + } + + $cronExpression = new CronExpression($settings->latency_schedule); + + $nextRunDate = Carbon::parse($cronExpression->getNextRunDate()) + ->setTimezone(config('app.display_timezone')) + ->format(config('app.datetime_format')); + + return 'Next latency test at: '.$nextRunDate; + } + + public function getData() + { + // Retrieve distinct target names + $target_names = LatencyResult::distinct()->pluck('target_name'); + + return [ + 'target_names' => $target_names, + 'filters' => $this->getFilters(), + ]; + } + + protected function getFilters(): array + { + return [ + '24h' => 'Last 24h', + 'week' => 'Last week', + 'month' => 'Last month', + ]; + } + + protected function getHeaderWidgets(): array + { + $target_names = $this->getData()['target_names']; + + $widgets = []; + foreach ($target_names as $target_name) { + $widget = RecentLatencyChartWidget::make(['target_name' => $target_name]); // Pass target_name during creation + $widgets[] = $widget; + } + + return $widgets; + } +} diff --git a/app/Filament/Pages/Settings/InfluxDbPage.php b/app/Filament/Pages/Settings/InfluxDbPage.php index 1c0d495e7..8948c0a43 100644 --- a/app/Filament/Pages/Settings/InfluxDbPage.php +++ b/app/Filament/Pages/Settings/InfluxDbPage.php @@ -13,7 +13,7 @@ class InfluxDbPage extends SettingsPage protected static ?string $navigationGroup = 'Settings'; - protected static ?int $navigationSort = 2; + protected static ?int $navigationSort = 5; protected static ?string $title = 'InfluxDB'; diff --git a/app/Filament/Pages/Settings/LatencySettingsPage.php b/app/Filament/Pages/Settings/LatencySettingsPage.php new file mode 100644 index 000000000..828f984a0 --- /dev/null +++ b/app/Filament/Pages/Settings/LatencySettingsPage.php @@ -0,0 +1,123 @@ +user()->is_admin; + } + + public static function shouldRegisterNavigation(): bool + { + return auth()->user()->is_admin; + } + + public function form(Form $form): Form + { + return $form + ->schema([ + Forms\Components\Grid::make([ + 'default' => 1, + 'md' => 3, + ]) + ->schema([ + Forms\Components\Grid::make([ + 'default' => 1, + ]) + ->schema([ + Forms\Components\Section::make('General') + ->schema([ + Forms\Components\Toggle::make('latency_enabled') + ->label('Enable Latency Tests') + ->default(false) + ->reactive(), + Forms\Components\Grid::make([ + 'default' => 2, + ]) + ->hidden(fn (Forms\Get $get) => $get('latency_enabled') !== true) + ->schema([ + Forms\Components\TextInput::make('ping_count') + ->label('Ping Count') + ->helperText('Number of pings to send during the test.') + ->default(10) + ->minValue(1) + ->numeric() + ->required(), + Forms\Components\TextInput::make('latency_schedule') + ->label('Cron Expression') + ->helperText('Specify the cron expression for scheduling tests.') + ->required(), + Forms\Components\Select::make('latency_column_span') + ->label('View') + ->options([ + 'full' => 'List view', + 'half' => 'Grid view', + ]) + ->default('full') + ->required(), + ]), + ]) + ->compact() + ->columns([ + 'default' => 1, + 'md' => 2, + ]), + + Forms\Components\Section::make('Targets') + ->hidden(fn (Forms\Get $get) => $get('latency_enabled') !== true) + ->collapsible() + ->schema([ + Forms\Components\Repeater::make('target_url') + ->label('Targets') + ->schema([ + Forms\Components\TextInput::make('target_name') + ->label('Display Name') + ->placeholder('Enter a display name') + ->maxLength(100) + ->required(), + Forms\Components\TextInput::make('url') + ->label('Target') + ->placeholder('example.com') + ->maxLength(2000) + ->required(), + ]) + ->columns([ + 'default' => 1, + 'md' => 2, + ]), + ]), + ]) + ->columnSpan([ + 'md' => 2, + ]), + + Forms\Components\Section::make() + ->schema([ + Forms\Components\View::make('filament.forms.latency-helptext'), + ]) + ->columnSpan([ + 'md' => 1, + ]), + ]), + ]); + } +} diff --git a/app/Filament/Pages/Settings/NotificationPage.php b/app/Filament/Pages/Settings/NotificationPage.php index bd7df5902..d7c34f92d 100755 --- a/app/Filament/Pages/Settings/NotificationPage.php +++ b/app/Filament/Pages/Settings/NotificationPage.php @@ -24,7 +24,7 @@ class NotificationPage extends SettingsPage protected static ?string $navigationGroup = 'Settings'; - protected static ?int $navigationSort = 3; + protected static ?int $navigationSort = 2; protected static ?string $title = 'Notifications'; diff --git a/app/Filament/Pages/Settings/ThresholdsPage.php b/app/Filament/Pages/Settings/ThresholdsPage.php index 7f60ba5f5..2ceb39f36 100644 --- a/app/Filament/Pages/Settings/ThresholdsPage.php +++ b/app/Filament/Pages/Settings/ThresholdsPage.php @@ -13,7 +13,7 @@ class ThresholdsPage extends SettingsPage protected static ?string $navigationGroup = 'Settings'; - protected static ?int $navigationSort = 4; + protected static ?int $navigationSort = 3; protected static ?string $title = 'Thresholds'; diff --git a/app/Filament/Pages/Speedtest/InsightsCharts.php b/app/Filament/Pages/Speedtest/InsightsCharts.php new file mode 100644 index 000000000..4c7588fed --- /dev/null +++ b/app/Filament/Pages/Speedtest/InsightsCharts.php @@ -0,0 +1,32 @@ +badge() ->toggleable() ->sortable(), + Tables\Columns\TextColumn::make('threshold_breached') + ->label('Threshold') + ->badge() + ->color(fn (string $state): string => ThresholdBreached::from($state)->getColor()) + ->toggleable() + ->toggledHiddenByDefault() + ->sortable(), Tables\Columns\IconColumn::make('scheduled') ->boolean() ->toggleable() @@ -359,6 +373,10 @@ public static function table(Table $table): Table Tables\Filters\SelectFilter::make('status') ->multiple() ->options(ResultStatus::class), + Tables\Filters\SelectFilter::make('threshold_breached') + ->label('Threshold Status') + ->multiple() + ->options(ThresholdBreached::class), ]) ->actions([ Tables\Actions\ActionGroup::make([ diff --git a/app/Filament/Resources/UserResource.php b/app/Filament/Resources/UserResource.php index 973f86239..e81966120 100644 --- a/app/Filament/Resources/UserResource.php +++ b/app/Filament/Resources/UserResource.php @@ -23,6 +23,12 @@ class UserResource extends Resource protected static ?string $navigationIcon = 'heroicon-o-users'; + protected static ?string $navigationGroup = 'Settings'; + + protected static ?string $navigationLabel = 'Users'; + + protected static ?int $navigationSort = 4; + public static function form(Form $form): Form { return $form diff --git a/app/Filament/Widgets/Latency/DashboardLatencyChartWidget.php b/app/Filament/Widgets/Latency/DashboardLatencyChartWidget.php new file mode 100644 index 000000000..77219f4ea --- /dev/null +++ b/app/Filament/Widgets/Latency/DashboardLatencyChartWidget.php @@ -0,0 +1,126 @@ + 'Last 24h', + 'week' => 'Last week', + 'month' => 'Last month', + ]; + } + + protected function getData(): array + { + // Query the latency results based on the selected filter + $results = LatencyResult::query() + ->selectRaw('AVG(avg_latency) as avg_latency, AVG(packet_loss) as packet_loss, created_at') + ->when($this->filter == '24h', function ($query) { + $query->where('created_at', '>=', now()->subDay()); + }) + ->when($this->filter == 'week', function ($query) { + $query->where('created_at', '>=', now()->subWeek()); + }) + ->when($this->filter == 'month', function ($query) { + $query->where('created_at', '>=', now()->subMonth()); + }) + ->groupBy('created_at') + ->orderBy('created_at') + ->get(); + + $dataPointsCount = $results->count(); + + return [ + 'datasets' => [ + [ + 'label' => 'Average Latency (ms)', + 'data' => $results->map(fn ($item) => $item->avg_latency ?? 0)->toArray(), + 'borderColor' => 'rgb(51, 181, 229)', + 'backgroundColor' => 'rgba(51, 181, 229, 0.1)', + 'pointBackgroundColor' => 'rgb(51, 181, 229)', + 'pointRadius' => $dataPointsCount <= 5 ? 3 : 0, + 'fill' => true, + 'tension' => 0.4, + ], + [ + 'label' => 'Packet Loss (%)', + 'data' => $results->map(fn ($item) => $item->packet_loss ?? 0)->toArray(), + 'borderColor' => 'rgb(255, 87, 51)', + 'backgroundColor' => 'rgba(255, 87, 51, 0.1)', + 'pointBackgroundColor' => 'rgb(255, 87, 51)', + 'pointRadius' => $dataPointsCount <= 5 ? 3 : 0, + 'fill' => true, + 'yAxisID' => 'right-y-axis', + 'tension' => 0.4, + ], + ], + 'labels' => $results->map(fn ($item) => $item->created_at->timezone(config('app.display_timezone'))->format(config('app.chart_datetime_format')))->toArray(), + ]; + } + + protected function getOptions(): array + { + return [ + 'plugins' => [ + 'legend' => [ + 'display' => true, + ], + ], + 'scales' => [ + 'y' => [ + 'type' => 'linear', + 'position' => 'left', + 'beginAtZero' => false, + 'title' => [ + 'display' => true, + 'text' => 'Latency (ms)', + ], + 'grid' => [ + 'display' => true, + 'drawBorder' => false, + ], + ], + 'right-y-axis' => [ + 'type' => 'linear', + 'position' => 'right', + 'beginAtZero' => true, + 'title' => [ + 'display' => true, + 'text' => 'Packet Loss (%)', + ], + 'grid' => [ + 'display' => false, + 'drawBorder' => false, + ], + ], + ], + ]; + } + + protected function getType(): string + { + return 'line'; + } + + public function getHeading(): ?string + { + return 'Overall Latency & Packet Loss'; // A generic heading + } +} diff --git a/app/Filament/Widgets/Latency/RecentLatencyChartWidget.php b/app/Filament/Widgets/Latency/RecentLatencyChartWidget.php new file mode 100644 index 000000000..812684e66 --- /dev/null +++ b/app/Filament/Widgets/Latency/RecentLatencyChartWidget.php @@ -0,0 +1,137 @@ +columnSpan = app(LatencySettings::class)->latency_column_span; // Set columnSpan from settings + } + + protected function getPollingInterval(): ?string + { + return config('speedtest.dashboard_polling'); + } + + protected function getFilters(): ?array + { + return [ + '24h' => 'Last 24h', + 'week' => 'Last week', + 'month' => 'Last month', + ]; + } + + protected function getData(): array + { + if (! $this->target_name) { + return []; + } + + $results = LatencyResult::query() + ->select(['id', 'avg_latency', 'packet_loss', 'created_at']) + ->where('target_name', $this->target_name) + ->when($this->filter == '24h', function ($query) { + $query->where('created_at', '>=', now()->subDay()); + }) + ->when($this->filter == 'week', function ($query) { + $query->where('created_at', '>=', now()->subWeek()); + }) + ->when($this->filter == 'month', function ($query) { + $query->where('created_at', '>=', now()->subMonth()); + }) + ->orderBy('created_at') + ->get(); + + $dataPointsCount = $results->count(); + + return [ + 'datasets' => [ + [ + 'label' => 'Average (ms)', + 'data' => $results->map(fn ($item) => $item->avg_latency ?? 0)->toArray(), + 'borderColor' => 'rgb(51, 181, 229)', + 'backgroundColor' => 'rgba(51, 181, 229, 0.1)', + 'pointBackgroundColor' => 'rgb(51, 181, 229)', + 'pointRadius' => $dataPointsCount <= 5 ? 3 : 0, + 'fill' => true, + 'tension' => 0.4, + ], + [ + 'label' => 'Packet Loss (%)', + 'data' => $results->map(fn ($item) => $item->packet_loss ?? 0)->toArray(), + 'borderColor' => 'rgb(255, 87, 51)', + 'backgroundColor' => 'rgba(255, 87, 51, 0.1)', + 'pointBackgroundColor' => 'rgb(255, 87, 51)', + 'pointRadius' => $dataPointsCount <= 5 ? 3 : 0, + 'fill' => true, + 'yAxisID' => 'right-y-axis', + 'tension' => 0.4, + ], + ], + 'labels' => $results->map(fn ($item) => $item->created_at->timezone(config('app.display_timezone'))->format(config('app.chart_datetime_format')))->toArray(), + ]; + } + + protected function getOptions(): array + { + return [ + 'plugins' => [ + 'legend' => [ + 'display' => true, + ], + ], + 'scales' => [ + 'y' => [ + 'type' => 'linear', + 'position' => 'left', + 'beginAtZero' => false, + 'title' => [ + 'display' => true, + 'text' => 'Average (ms)', + ], + 'grid' => [ + 'display' => true, + 'drawBorder' => false, + ], + ], + 'right-y-axis' => [ + 'type' => 'linear', + 'position' => 'right', + 'beginAtZero' => true, + 'title' => [ + 'display' => true, + 'text' => 'Packet Loss (%)', + ], + 'grid' => [ + 'display' => false, + 'drawBorder' => false, + ], + ], + ], + ]; + } + + protected function getType(): string + { + return 'line'; + } + + public function getHeading(): ?string + { + return $this->target_name ?: 'Unknown'; // Return the target_name directly + } +} diff --git a/app/Filament/Widgets/Speedtest/DashboardDownloadUploadChartWidget.php b/app/Filament/Widgets/Speedtest/DashboardDownloadUploadChartWidget.php new file mode 100644 index 000000000..9d16f89bb --- /dev/null +++ b/app/Filament/Widgets/Speedtest/DashboardDownloadUploadChartWidget.php @@ -0,0 +1,102 @@ + 'Last 24h', + 'week' => 'Last week', + 'month' => 'Last month', + ]; + } + + protected function getData(): array + { + $results = Result::query() + ->select(['id', 'download', 'upload', 'created_at']) + ->where('status', '=', ResultStatus::Completed) + ->when($this->filter == '24h', function ($query) { + $query->where('created_at', '>=', now()->subDay()); + }) + ->when($this->filter == 'week', function ($query) { + $query->where('created_at', '>=', now()->subWeek()); + }) + ->when($this->filter == 'month', function ($query) { + $query->where('created_at', '>=', now()->subMonth()); + }) + ->orderBy('created_at') + ->get(); + + $dataPointsCount = $results->count(); + + return [ + 'datasets' => [ + [ + 'label' => 'Download', + 'data' => $results->map(fn ($item) => ! blank($item->download) ? Number::bitsToMagnitude(bits: $item->download_bits, precision: 2, magnitude: 'mbit') : 0), + 'borderColor' => 'rgba(14, 165, 233)', + 'backgroundColor' => 'rgba(14, 165, 233, 0.1)', + 'pointBackgroundColor' => 'rgba(14, 165, 233)', + 'pointRadius' => $dataPointsCount <= 5 ? 3 : 0, + 'fill' => true, + 'cubicInterpolationMode' => 'monotone', + 'tension' => 0.4, + ], + [ + 'label' => 'Upload', + 'data' => $results->map(fn ($item) => ! blank($item->upload) ? Number::bitsToMagnitude(bits: $item->upload_bits, precision: 2, magnitude: 'mbit') : 0), + 'borderColor' => 'rgba(139, 92, 246)', + 'backgroundColor' => 'rgba(139, 92, 246, 0.1)', + 'pointBackgroundColor' => 'rgba(139, 92, 246)', + 'pointRadius' => $dataPointsCount <= 5 ? 3 : 0, + 'fill' => true, + 'cubicInterpolationMode' => 'monotone', + 'tension' => 0.4, + ], + ], + 'labels' => $results->map(fn ($item) => $item->created_at->timezone(config('app.display_timezone'))->format(config('app.chart_datetime_format'))), + ]; + } + + protected function getOptions(): array + { + return [ + 'plugins' => [ + 'legend' => [ + 'display' => true, + ], + ], + 'scales' => [ + 'y' => [ + 'beginAtZero' => true, + ], + ], + ]; + } + + protected function getType(): string + { + return 'line'; + } +} diff --git a/app/Filament/Widgets/Speedtest/Insights/AverageDownloadUploadChartWidget.php b/app/Filament/Widgets/Speedtest/Insights/AverageDownloadUploadChartWidget.php new file mode 100644 index 000000000..1afd4c690 --- /dev/null +++ b/app/Filament/Widgets/Speedtest/Insights/AverageDownloadUploadChartWidget.php @@ -0,0 +1,101 @@ +select(['download', 'upload', 'created_at']) + ->where('status', '=', ResultStatus::Completed->value) // Filter by completed status + ->orderBy('created_at') + ->get(); + + // Group results by month and calculate average download and upload + $monthlyData = $results->groupBy(function ($item) { + return $item->created_at->format('Y-m'); // Group by month (YYYY-MM) + })->map(function ($items) { + // Calculate the average download and upload speed for each month + $averageDownload = $items->avg('download'); + $averageUpload = $items->avg('upload'); + + return [ + 'download' => Number::bitsToMagnitude(bits: $averageDownload * 8, precision: 2, magnitude: 'mbit'), // Adjust if needed + 'upload' => Number::bitsToMagnitude(bits: $averageUpload * 8, precision: 2, magnitude: 'mbit'), // Adjust if needed + ]; + }); + + // Convert month-year format to month names + $labels = $monthlyData->keys()->map(function ($monthYear) { + return \Carbon\Carbon::createFromFormat('Y-m', $monthYear)->format('F Y'); // Convert YYYY-MM to "Month Year" + })->toArray(); + + $downloadData = $monthlyData->pluck('download')->toArray(); // Average download speeds + $uploadData = $monthlyData->pluck('upload')->toArray(); // Average upload speeds + + return [ + 'datasets' => [ + [ + 'label' => 'Average Download (Mbps)', + 'data' => $downloadData, + 'borderColor' => 'rgba(14, 165, 233)', + 'backgroundColor' => 'rgba(14, 165, 233, 0.1)', + 'pointBackgroundColor' => 'rgba(14, 165, 233)', + 'fill' => true, + 'cubicInterpolationMode' => 'monotone', + 'tension' => 0.4, + ], + [ + 'label' => 'Average Upload (Mbps)', + 'data' => $uploadData, + 'borderColor' => 'rgba(139, 92, 246)', + 'backgroundColor' => 'rgba(139, 92, 246, 0.1)', + 'pointBackgroundColor' => 'rgba(139, 92, 246)', + 'fill' => true, + 'cubicInterpolationMode' => 'monotone', + 'tension' => 0.4, + ], + ], + 'labels' => $labels, + ]; + } + + protected function getOptions(): array + { + return [ + 'plugins' => [ + 'legend' => [ + 'display' => true, + ], + 'tooltip' => [ + 'enabled' => true, // Enable tooltips + ], + ], + ]; + } + + protected function getType(): string + { + return 'line'; + } +} diff --git a/app/Filament/Widgets/Speedtest/Insights/ResultStatusWidget.php b/app/Filament/Widgets/Speedtest/Insights/ResultStatusWidget.php new file mode 100644 index 000000000..7567ca896 --- /dev/null +++ b/app/Filament/Widgets/Speedtest/Insights/ResultStatusWidget.php @@ -0,0 +1,97 @@ + 'Last 24h', + 'week' => 'Last week', + 'month' => 'Last month', + ]; + } + + protected function getData(): array + { + // Aggregate the count of each status within the selected time frame + $results = Result::query() + ->select(['status', \DB::raw('COUNT(*) as count')]) + ->when($this->filter == '24h', function ($query) { + $query->where('created_at', '>=', now()->subDay()); + }) + ->when($this->filter == 'week', function ($query) { + $query->where('created_at', '>=', now()->subWeek()); + }) + ->when($this->filter == 'month', function ($query) { + $query->where('created_at', '>=', now()->subMonth()); + }) + ->groupBy('status') + ->get(); + + // Define colors for each status using string keys + $statusColors = [ + ResultStatus::Completed->value => '#4caf50', // Green for completed + ResultStatus::Failed->value => '#f44336', // Red for failed + ResultStatus::Started->value => '#ff9800', // Amber for started + ]; + + // Prepare data for the pie chart + $labels = $results->map(fn ($item) => $item->status->value)->toArray(); // Ensure status is a string + $data = $results->map(fn ($item) => $item->count)->toArray(); + $colors = $results->map(fn ($item) => $statusColors[$item->status->value] ?? '#000000')->toArray(); // Default color if status not found + + return [ + 'datasets' => [ + [ + 'data' => $data, + 'backgroundColor' => $colors, // Set colors for each status + ], + ], + 'labels' => $labels, + ]; + } + + protected function getOptions(): array + { + return [ + 'plugins' => [ + 'legend' => [ + 'display' => true, // Show the legend + 'position' => 'bottom', // Position of the legend + 'labels' => [ + 'font' => [ + 'size' => 14, // Font size of legend labels + ], + ], + ], + 'tooltip' => [ + 'enabled' => true, // Show tooltips + ], + ], + ]; + } + + protected function getType(): string + { + return 'pie'; + } +} diff --git a/app/Filament/Widgets/Speedtest/Insights/ResultThresholdWidget.php b/app/Filament/Widgets/Speedtest/Insights/ResultThresholdWidget.php new file mode 100644 index 000000000..9caceb605 --- /dev/null +++ b/app/Filament/Widgets/Speedtest/Insights/ResultThresholdWidget.php @@ -0,0 +1,103 @@ + 'Last 24h', + 'week' => 'Last week', + 'month' => 'Last month', + ]; + } + + protected function getData(): array + { + // Aggregate the count of each threshold status within the selected time frame + $results = Result::query() + ->select(['threshold_breached', \DB::raw('COUNT(*) as count')]) + ->when($this->filter == '24h', function ($query) { + $query->where('created_at', '>=', now()->subDay()); + }) + ->when($this->filter == 'week', function ($query) { + $query->where('created_at', '>=', now()->subWeek()); + }) + ->when($this->filter == 'month', function ($query) { + $query->where('created_at', '>=', now()->subMonth()); + }) + // Apply threshold status if it is set + ->when($this->thresholdStatus, function ($query) { + $query->where('threshold_breached', $this->thresholdStatus); + }) + ->groupBy('threshold_breached') + ->get(); + + // Define colors for each threshold status + $statusColors = [ + 'NotChecked' => '#ff9800', // Amber for not checked + 'Passed' => '#4caf50', // Green for pass + 'Failed' => '#f44336', // Red for failed + ]; + + // Prepare data for the pie chart + $labels = $results->map(fn ($item) => $item->threshold_breached)->toArray(); + $data = $results->map(fn ($item) => $item->count)->toArray(); + $colors = $results->map(fn ($item) => $statusColors[$item->threshold_breached] ?? '#000000')->toArray(); // Default color if status not found + + return [ + 'datasets' => [ + [ + 'data' => $data, + 'backgroundColor' => $colors, // Set colors for each threshold status + ], + ], + 'labels' => $labels, + ]; + } + + protected function getOptions(): array + { + return [ + 'plugins' => [ + 'legend' => [ + 'display' => true, // Show the legend + 'position' => 'bottom', // Position of the legend + 'labels' => [ + 'font' => [ + 'size' => 14, // Font size of legend labels + ], + ], + ], + 'tooltip' => [ + 'enabled' => true, // Show tooltips + ], + ], + ]; + } + + protected function getType(): string + { + return 'pie'; + } +} diff --git a/app/Filament/Widgets/RecentDownloadChartWidget.php b/app/Filament/Widgets/Speedtest/RecentDownloadChartWidget.php similarity index 98% rename from app/Filament/Widgets/RecentDownloadChartWidget.php rename to app/Filament/Widgets/Speedtest/RecentDownloadChartWidget.php index 7aca1d1df..6b51615e7 100644 --- a/app/Filament/Widgets/RecentDownloadChartWidget.php +++ b/app/Filament/Widgets/Speedtest/RecentDownloadChartWidget.php @@ -1,6 +1,6 @@ target_url = $target_url; + $this->target_name = $target_name; + $this->pingCount = $settings->ping_count; + } + + public function handle() + { + Log::info("Starting ping test for URL: {$this->target_url}"); + + try { + $command = sprintf( + 'ping -c %d %s', + $this->pingCount, + escapeshellarg($this->target_url) + ); + + $output = shell_exec($command); + + if ($output === null) { + Log::error("Failed to execute ping command for URL: {$this->target_url}"); + + return; + } + + $latencies = $this->parseLatencies($output); + $packetLoss = $this->parsePacketLoss($output); + + LatencyResult::create([ + 'target_url' => $this->target_url, + 'target_name' => $this->target_name, + 'min_latency' => $latencies['min'] ?? null, + 'avg_latency' => $latencies['avg'] ?? null, + 'max_latency' => $latencies['max'] ?? null, + 'packet_loss' => $packetLoss, + 'ping_count' => $this->pingCount, + ]); + + } catch (\Exception $e) { + Log::error("Error executing latency test for URL: {$this->target_url}. Error: {$e->getMessage()}"); + } + } + + protected function parseLatencies($output) + { + $latencies = []; + if (preg_match_all('/time=(\d+\.?\d*) ms/', $output, $matches)) { + $latencies = array_map('floatval', $matches[1]); + } + + $min = $max = $avg = null; + + if (count($latencies) > 0) { + $min = min($latencies); + $max = max($latencies); + $avg = array_sum($latencies) / count($latencies); + } + + return [ + 'min' => $min, + 'avg' => $avg, + 'max' => $max, + ]; + } + + protected function parsePacketLoss($output) + { + if (preg_match('/(\d+)% packet loss/', $output, $matches)) { + return $matches[1]; + } + + return null; + } +} diff --git a/app/Jobs/Speedtests/ExecuteOoklaSpeedtest.php b/app/Jobs/Speedtests/ExecuteOoklaSpeedtest.php index 3e3d591f9..d8b6edcd3 100644 --- a/app/Jobs/Speedtests/ExecuteOoklaSpeedtest.php +++ b/app/Jobs/Speedtests/ExecuteOoklaSpeedtest.php @@ -84,6 +84,9 @@ public function handle(): void 'status' => ResultStatus::Completed, ]); + // Ensure thresholds are checked and updated + $this->result->checkAndUpdateThresholds(); + SpeedtestCompleted::dispatch($this->result); } diff --git a/app/Models/LatencyResult.php b/app/Models/LatencyResult.php new file mode 100644 index 000000000..35ba85607 --- /dev/null +++ b/app/Models/LatencyResult.php @@ -0,0 +1,18 @@ + Arr::get($this->data, 'upload.latency.iqm'), ); } + + public function checkAndUpdateThresholds(): void + { + $thresholds = app(ThresholdSettings::class); + + // Determine if thresholds are enabled + $thresholdsEnabled = $thresholds->absolute_enabled; + + // Convert bits to Mbits if needed + $downloadInMbits = ! is_null($this->download) ? Number::bitsToMagnitude($this->download_bits, 2, 'mbit') : null; + $uploadInMbits = ! is_null($this->upload) ? Number::bitsToMagnitude($this->upload_bits, 2, 'mbit') : null; + + // Determine if thresholds are breached or NotChecked + $downloadBreached = $thresholdsEnabled && $downloadInMbits !== null && $downloadInMbits < $thresholds->absolute_download; + $uploadBreached = $thresholdsEnabled && $uploadInMbits !== null && $uploadInMbits < $thresholds->absolute_upload; + $pingBreached = $thresholdsEnabled && $this->ping !== null && $this->ping > $thresholds->absolute_ping; + + // Update only the threshold_breached field + $this->update([ + 'threshold_breached' => $thresholdsEnabled + ? ($downloadBreached || $uploadBreached || $pingBreached ? 'Failed' : 'Passed') + : 'NotChecked', + ]); + } } diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index 6b8647b97..876b3adf6 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -74,7 +74,15 @@ public function panel(Panel $panel): Panel ]) ->navigationGroups([ NavigationGroup::make() - ->label('Settings'), + ->label('Speedtest') + ->collapsible(false), + NavigationGroup::make() + ->label('Latency') + ->collapsible(false), + NavigationGroup::make() + ->label('Settings') + ->collapsible(true) + ->collapsed(true), NavigationGroup::make() ->label('Links') ->collapsible(false), diff --git a/app/Settings/LatencySettings.php b/app/Settings/LatencySettings.php new file mode 100644 index 000000000..21594e5e3 --- /dev/null +++ b/app/Settings/LatencySettings.php @@ -0,0 +1,26 @@ + [ - + \App\Settings\LatencySettings::class, // Register the settings class here as a string ], /* @@ -16,17 +15,14 @@ 'setting_class_path' => app_path('Settings'), /* - * In these directories settings migrations will be stored and ran when migrating. A settings - * migration created via the make:settings-migration command will be stored in the first path or - * a custom defined path when running the command. + * In these directories, settings migrations will be stored and run when migrating. */ 'migrations_paths' => [ database_path('settings'), ], /* - * When no repository was set for a settings class the following repository - * will be used for loading and saving settings. + * When no repository is set for a settings class, the following repository will be used. */ 'default_repository' => 'database', @@ -48,9 +44,7 @@ ], /* - * The contents of settings classes can be cached through your application, - * settings will be stored within a provided Laravel store and can have an - * additional prefix. + * Settings caching configuration. */ 'cache' => [ 'enabled' => env('SETTINGS_CACHE_ENABLED', false), @@ -60,27 +54,22 @@ ], /* - * These global casts will be automatically used whenever a property within - * your settings class isn't a default PHP type. + * Global casts for non-PHP type properties in settings classes. */ 'global_casts' => [ DateTimeInterface::class => Spatie\LaravelSettings\SettingsCasts\DateTimeInterfaceCast::class, DateTimeZone::class => Spatie\LaravelSettings\SettingsCasts\DateTimeZoneCast::class, - // Spatie\DataTransferObject\DataTransferObject::class => Spatie\LaravelSettings\SettingsCasts\DtoCast::class, - // Spatie\LaravelData\Data::class => Spatie\LaravelSettings\SettingsCasts\DataCast::class, ], /* - * The package will look for settings in these paths and automatically - * register them. + * Paths for automatically discovering settings classes. */ 'auto_discover_settings' => [ app_path('Settings'), ], /* - * Automatically discovered settings classes can be cached, so they don't - * need to be searched each time the application boots up. + * Path to cache discovered settings classes. */ 'discovered_settings_cache_path' => base_path('bootstrap/cache'), ]; diff --git a/database/migrations/2024_08_19_115130_add_threshold_breached_to_results_table.php b/database/migrations/2024_08_19_115130_add_threshold_breached_to_results_table.php new file mode 100644 index 000000000..b714ac418 --- /dev/null +++ b/database/migrations/2024_08_19_115130_add_threshold_breached_to_results_table.php @@ -0,0 +1,27 @@ +string('threshold_breached')->default('NotChecked'); + }); + } + + public function down(): void + { + Schema::table('results', function (Blueprint $table) { + if (Schema::hasColumn('results', 'threshold_breached')) { + $table->dropColumn('threshold_breached'); + } + }); + } +}; diff --git a/database/migrations/2024_08_22_074443_create_latency_results_table.php b/database/migrations/2024_08_22_074443_create_latency_results_table.php new file mode 100644 index 000000000..06b86d1e0 --- /dev/null +++ b/database/migrations/2024_08_22_074443_create_latency_results_table.php @@ -0,0 +1,28 @@ +id(); + $table->string('target_url'); + $table->string('target_name')->nullable(); // Add 'name' column + $table->decimal('min_latency', 8, 2)->nullable(); + $table->decimal('avg_latency', 8, 2)->nullable(); + $table->decimal('max_latency', 8, 2)->nullable(); + $table->integer('packet_loss')->nullable(); + $table->integer('ping_count'); + $table->timestamps(); + }); + } + + public function down() + { + Schema::dropIfExists('latency_results'); + } +} diff --git a/database/settings/2024_08_29_225049_add_latency_settings.php b/database/settings/2024_08_29_225049_add_latency_settings.php new file mode 100644 index 000000000..e297df89c --- /dev/null +++ b/database/settings/2024_08_29_225049_add_latency_settings.php @@ -0,0 +1,16 @@ +migrator->add('latency.ping_count', 10); // Default ping count + $this->migrator->add('latency.target_url', []); // Default empty array for ping URLs + $this->migrator->add('latency.latency_schedule', ''); // Default cron expression + $this->migrator->add('latency.latency_enabled', false); // Default state for the enable/disable toggle + $this->migrator->add('latency.latency_column_span', 'full'); // Add column_span with default value + + } +} diff --git a/resources/views/filament/forms/latency-helptext.blade.php b/resources/views/filament/forms/latency-helptext.blade.php new file mode 100644 index 000000000..592b9d39d --- /dev/null +++ b/resources/views/filament/forms/latency-helptext.blade.php @@ -0,0 +1,7 @@ +
+

+ 💡 Did you know that the graph is generated based on the display name? + You can update the URL without creating a new graph, but changing the name will generate a new one. + Ensure that the schedule is different from the Speedtest schedule to avoid any potential impact on the results. +

+
diff --git a/resources/views/filament/pages/latency-results-page.blade.php b/resources/views/filament/pages/latency-results-page.blade.php new file mode 100644 index 000000000..f351a1c97 --- /dev/null +++ b/resources/views/filament/pages/latency-results-page.blade.php @@ -0,0 +1,3 @@ + + {{-- Silence is golden --}} + diff --git a/resources/views/filament/pages/speedtest-dashboard.blade.php b/resources/views/filament/pages/speedtest-dashboard.blade.php new file mode 100644 index 000000000..f351a1c97 --- /dev/null +++ b/resources/views/filament/pages/speedtest-dashboard.blade.php @@ -0,0 +1,3 @@ + + {{-- Silence is golden --}} + diff --git a/resources/views/filament/pages/speedtest-insights.blade.php b/resources/views/filament/pages/speedtest-insights.blade.php new file mode 100644 index 000000000..f351a1c97 --- /dev/null +++ b/resources/views/filament/pages/speedtest-insights.blade.php @@ -0,0 +1,3 @@ + + {{-- Silence is golden --}} + diff --git a/routes/console.php b/routes/console.php index 23a9c4081..cc8b97456 100644 --- a/routes/console.php +++ b/routes/console.php @@ -1,5 +1,6 @@ RunScheduledSpeedtests::run()) ->everyMinute() ->when(! blank(config('speedtest.schedule'))); + +/** + * Action to run scheduled latency tests. + */ +Schedule::call(fn () => RunScheduledLatencyTests::run()) + ->everyMinute(); diff --git a/storage/app/.gitignore b/storage/app/.gitignore old mode 100644 new mode 100755 diff --git a/storage/app/public/.gitignore b/storage/app/public/.gitignore old mode 100644 new mode 100755 diff --git a/storage/framework/.gitignore b/storage/framework/.gitignore old mode 100644 new mode 100755 diff --git a/storage/framework/cache/.gitignore b/storage/framework/cache/.gitignore old mode 100644 new mode 100755 diff --git a/storage/framework/cache/data/.gitignore b/storage/framework/cache/data/.gitignore old mode 100644 new mode 100755 diff --git a/storage/framework/sessions/.gitignore b/storage/framework/sessions/.gitignore old mode 100644 new mode 100755 diff --git a/storage/framework/testing/.gitignore b/storage/framework/testing/.gitignore old mode 100644 new mode 100755 diff --git a/storage/framework/views/.gitignore b/storage/framework/views/.gitignore old mode 100644 new mode 100755 diff --git a/storage/logs/.gitignore b/storage/logs/.gitignore old mode 100644 new mode 100755