From 53bf22ce97afbfe7d088d595053f99ead4d8c281 Mon Sep 17 00:00:00 2001 From: Rizal Jamhari Date: Fri, 26 Dec 2025 14:06:59 +0800 Subject: [PATCH 1/2] feature: add support for fast.com and cloudflare speedtest --- app/Actions/CheckForScheduledSpeedtests.php | 6 +- app/Actions/Ookla/RunSpeedtest.php | 3 + app/Actions/PingHostname.php | 3 +- app/Actions/Speedtest/RunSpeedtest.php | 116 ++++++++++++++++ app/Enums/ResultService.php | 4 + app/Filament/Pages/Settings/General.php | 124 ++++++++++++++++++ .../Resources/Results/ResultResource.php | 1 + .../Resources/Results/Schemas/ResultForm.php | 7 +- .../Widgets/Concerns/HasChartFilters.php | 26 +++- .../Widgets/RecentDownloadChartWidget.php | 96 ++++++++++---- .../RecentDownloadLatencyChartWidget.php | 16 ++- .../Widgets/RecentJitterChartWidget.php | 110 ++++++++++------ .../Widgets/RecentPingChartWidget.php | 76 +++++++---- .../Widgets/RecentUploadChartWidget.php | 86 ++++++++---- .../RecentUploadLatencyChartWidget.php | 16 ++- .../Api/V1/SpeedtestController.php | 4 +- app/Jobs/Speedtest/RunSpeedtestJob.php | 60 +++++++++ app/Livewire/Topbar/Actions.php | 2 +- app/Models/Result.php | 2 +- .../Speedtest/Drivers/CloudflareDriver.php | 62 +++++++++ app/Services/Speedtest/Drivers/FastDriver.php | 81 ++++++++++++ .../Speedtest/Drivers/OoklaDriver.php | 63 +++++++++ app/Services/Speedtest/SpeedtestDriver.php | 27 ++++ app/Settings/GeneralSettings.php | 38 ++++++ compose.yaml | 2 + ...5_12_26_000000_create_general_settings.php | 15 +++ ...2_26_000001_add_extra_general_settings.php | 16 +++ ..._000002_add_cloudflare_enabled_setting.php | 11 ++ docker/8.4/Dockerfile | 18 ++- package-lock.json | 2 +- routes/console.php | 2 +- tests/Feature/MultiServiceSpeedtestTest.php | 85 ++++++++++++ 32 files changed, 1031 insertions(+), 149 deletions(-) create mode 100644 app/Actions/Speedtest/RunSpeedtest.php create mode 100644 app/Filament/Pages/Settings/General.php create mode 100644 app/Jobs/Speedtest/RunSpeedtestJob.php create mode 100644 app/Services/Speedtest/Drivers/CloudflareDriver.php create mode 100644 app/Services/Speedtest/Drivers/FastDriver.php create mode 100644 app/Services/Speedtest/Drivers/OoklaDriver.php create mode 100644 app/Services/Speedtest/SpeedtestDriver.php create mode 100644 app/Settings/GeneralSettings.php create mode 100644 database/settings/2025_12_26_000000_create_general_settings.php create mode 100644 database/settings/2025_12_26_000001_add_extra_general_settings.php create mode 100644 database/settings/2025_12_26_000002_add_cloudflare_enabled_setting.php create mode 100644 tests/Feature/MultiServiceSpeedtestTest.php diff --git a/app/Actions/CheckForScheduledSpeedtests.php b/app/Actions/CheckForScheduledSpeedtests.php index 47098bf34..fa3119ee4 100644 --- a/app/Actions/CheckForScheduledSpeedtests.php +++ b/app/Actions/CheckForScheduledSpeedtests.php @@ -2,7 +2,6 @@ namespace App\Actions; -use App\Actions\Ookla\RunSpeedtest; use Cron\CronExpression; use Lorisleiva\Actions\Concerns\AsAction; @@ -12,13 +11,14 @@ class CheckForScheduledSpeedtests public function handle(): void { - $schedule = config('speedtest.schedule'); + $settings = app(\App\Settings\GeneralSettings::class); + $schedule = $settings->speedtest_schedule; if (blank($schedule) || $schedule === false) { return; } - RunSpeedtest::runIf( + \App\Actions\Speedtest\RunSpeedtest::runIf( $this->isSpeedtestDue(schedule: $schedule), scheduled: true, ); diff --git a/app/Actions/Ookla/RunSpeedtest.php b/app/Actions/Ookla/RunSpeedtest.php index c3814310e..40d7456ef 100644 --- a/app/Actions/Ookla/RunSpeedtest.php +++ b/app/Actions/Ookla/RunSpeedtest.php @@ -19,6 +19,9 @@ use Lorisleiva\Actions\Concerns\AsAction; use Throwable; +/** + * @deprecated Use App\Actions\Speedtest\RunSpeedtest instead. + */ class RunSpeedtest { use AsAction; diff --git a/app/Actions/PingHostname.php b/app/Actions/PingHostname.php index 5bbf15ddf..f0aacc739 100644 --- a/app/Actions/PingHostname.php +++ b/app/Actions/PingHostname.php @@ -13,7 +13,8 @@ class PingHostname public function handle(?string $hostname = null, int $count = 1): PingResult { - $hostname = $hostname ?? config('speedtest.preflight.internet_check_hostname'); + $settings = app(\App\Settings\GeneralSettings::class); + $hostname = $hostname ?? $settings->speedtest_base_url ?? config('speedtest.preflight.internet_check_hostname'); // Remove protocol if present $hostname = preg_replace('#^https?://#', '', $hostname); diff --git a/app/Actions/Speedtest/RunSpeedtest.php b/app/Actions/Speedtest/RunSpeedtest.php new file mode 100644 index 000000000..cd3a5abf0 --- /dev/null +++ b/app/Actions/Speedtest/RunSpeedtest.php @@ -0,0 +1,116 @@ +ookla_enabled) { + $services[] = ResultService::Ookla; + } + + if ($settings->fast_enabled) { + $services[] = ResultService::Fast; + } + + if ($settings->cloudflare_enabled) { + $services[] = ResultService::Cloudflare; + } + + if (empty($services)) { + Log::warning('No speedtest services enabled.'); + return []; + } + + $servicePayloads = []; + + foreach ($services as $service) { + $result = Result::create([ + 'service' => $service, + 'status' => ResultStatus::Waiting, + 'scheduled' => $scheduled, + 'dispatched_by' => $dispatchedBy, + 'data->server->id' => ($service === ResultService::Ookla) ? ($serverId ?? $settings->speedtest_server) : null, + ]); + + $results[] = $result; + + SpeedtestWaiting::dispatch($result); + + $jobs = [ + new StartSpeedtestJob($result), + new CheckForInternetConnectionJob($result), + new SkipSpeedtestJob($result), + ]; + + if ($service === ResultService::Ookla) { + // Ookla specific jobs + $jobs[] = new SelectSpeedtestServerJob($result); + } + + // Generic execution job + $jobs[] = new RunSpeedtestJob($result); + + // Generic/Ookla completion jobs (Reusing existing ones for now) + $jobs[] = new BenchmarkSpeedtestJob($result); + $jobs[] = new CompleteSpeedtestJob($result); + + $servicePayloads[] = [ + 'service' => $service, + 'jobs' => $jobs, + ]; + } + + if ($settings->execution_mode === 'parallel') { + foreach ($servicePayloads as $payload) { + /** @var \Illuminate\Bus\PendingBatch $batch */ + Bus::batch($payload['jobs']) + ->catch(function (Batch $batch, ?Throwable $e) { + Log::error(sprintf('Speedtest batch "%s" failed for an unknown reason.', $batch->id)); + }) + ->name(ucfirst($payload['service']->value) . ' Speedtest') + ->dispatch(); + } + } else { + // Sequential + $chain = []; + foreach ($servicePayloads as $payload) { + $chain = array_merge($chain, $payload['jobs']); + } + + Bus::batch($chain) + ->name('Sequential Speedtests') + ->catch(function (Batch $batch, ?Throwable $e) { /* ... */ }) + ->dispatch(); + } + + return $results; + } +} diff --git a/app/Enums/ResultService.php b/app/Enums/ResultService.php index 39a441d39..b321ba8e3 100644 --- a/app/Enums/ResultService.php +++ b/app/Enums/ResultService.php @@ -6,14 +6,18 @@ enum ResultService: string implements HasLabel { + case Cloudflare = 'cloudflare'; case Faker = 'faker'; + case Fast = 'fast'; case Librespeed = 'librespeed'; case Ookla = 'ookla'; public function getLabel(): ?string { return match ($this) { + self::Cloudflare => 'Cloudflare', self::Faker => __('enums.service.faker'), + self::Fast => 'Fast.com', self::Librespeed => __('enums.service.librespeed'), self::Ookla => __('enums.service.ookla'), }; diff --git a/app/Filament/Pages/Settings/General.php b/app/Filament/Pages/Settings/General.php new file mode 100644 index 000000000..172803cad --- /dev/null +++ b/app/Filament/Pages/Settings/General.php @@ -0,0 +1,124 @@ +is_admin; + } + + public static function shouldRegisterNavigation(): bool + { + return Auth::check() && Auth::user()->is_admin; + } + + public function form(Schema $schema): Schema + { + return $schema + ->components([ + Tabs::make() + ->schema([ + Tab::make('General') + ->schema([ + \Filament\Forms\Components\Select::make('display_timezone') + ->label('Display Timezone') + ->options(array_combine(timezone_identifiers_list(), timezone_identifiers_list())) + ->searchable() + ->required(), + TextInput::make('datetime_format') + ->label('Date & Time Format') + ->helperText('PHP date format (e.g., j M Y, g:i A)'), + TextInput::make('chart_datetime_format') + ->label('Chart Date Format') + ->helperText('e.g., j/m g:i A'), + ]), + + Tab::make('Speedtest') + ->schema([ + Grid::make([ + 'default' => 1, + 'md' => 2, + ]) + ->schema([ + Section::make('Configuration') + ->schema([ + TextInput::make('speedtest_schedule') + ->label('Schedule (Cron Expression)') + ->helperText('Leave empty to disable scheduled tests.') + ->placeholder('0 * * * *'), + TextInput::make('speedtest_servers') + ->label('Server IDs') + ->helperText('Comma-separated list of server IDs (e.g., 1234,5678).'), + TextInput::make('speedtest_base_url') + ->label('Connectivity Check URL') + ->helperText('URL to check internet connectivity before running tests.'), + ])->columnSpan(1), + + Section::make('Services') + ->schema([ + Toggle::make('ookla_enabled') + ->label('Enable Ookla Speedtest') + ->helperText('Uses the official Ookla CLI.'), + Toggle::make('fast_enabled') + ->label('Enable Fast.com') + ->helperText('Uses fast-cli (Netflix).'), + Toggle::make('cloudflare_enabled') + ->label('Enable Cloudflare Speedtest') + ->helperText('Uses cfspeedtest (Cloudflare).'), + + Radio::make('execution_mode') + ->label('Execution Mode') + ->options([ + 'sequential' => 'Sequential (One after another)', + 'parallel' => 'Parallel (Run at the same time)', + ]) + ->default('sequential') + ->required(), + ])->columnSpan(1), + ]), + ]), + + Tab::make('System') + ->schema([ + TextInput::make('prune_results_older_than') + ->label('Prune Results (Days)') + ->numeric() + ->helperText('Set to 0 to disable pruning.') + ->required(), + ]), + ])->columnSpanFull(), + ]); + } +} diff --git a/app/Filament/Resources/Results/ResultResource.php b/app/Filament/Resources/Results/ResultResource.php index 5ff0893d7..19e44412a 100644 --- a/app/Filament/Resources/Results/ResultResource.php +++ b/app/Filament/Resources/Results/ResultResource.php @@ -6,6 +6,7 @@ use App\Filament\Resources\Results\Schemas\ResultForm; use App\Filament\Resources\Results\Tables\ResultTable; use App\Models\Result; +use App\Settings\GeneralSettings; use Filament\Resources\Resource; use Filament\Schemas\Schema; use Filament\Tables\Table; diff --git a/app/Filament/Resources/Results/Schemas/ResultForm.php b/app/Filament/Resources/Results/Schemas/ResultForm.php index 3e2e5b852..1b2fa26a0 100644 --- a/app/Filament/Resources/Results/Schemas/ResultForm.php +++ b/app/Filament/Resources/Results/Schemas/ResultForm.php @@ -13,6 +13,8 @@ use Filament\Schemas\Components\Section; use Illuminate\Support\HtmlString; +use App\Settings\GeneralSettings; + class ResultForm { public static function schema(): array @@ -30,9 +32,10 @@ public static function schema(): array TextInput::make('created_at') ->label(__('general.created_at')) ->afterStateHydrated(function (TextInput $component, $state) { + $settings = app(GeneralSettings::class); $component->state(Carbon::parse($state) - ->timezone(config('app.display_timezone')) - ->format(config('app.datetime_format'))); + ->timezone($settings->display_timezone) + ->format($settings->datetime_format)); }), TextInput::make('download') ->label(__('general.download')) diff --git a/app/Filament/Widgets/Concerns/HasChartFilters.php b/app/Filament/Widgets/Concerns/HasChartFilters.php index ce12d9384..b580d3546 100644 --- a/app/Filament/Widgets/Concerns/HasChartFilters.php +++ b/app/Filament/Widgets/Concerns/HasChartFilters.php @@ -6,10 +6,28 @@ trait HasChartFilters { protected function getFilters(): ?array { - return [ - '24h' => 'Last 24 hours', - 'week' => 'Last 7 days', - 'month' => 'Last 30 days', + $times = [ + '24h' => 'Last 24 Hours', + 'week' => 'Last 7 Days', + 'month' => 'Last 30 Days', ]; + + $services = [ + 'all' => 'All Services', + 'ookla' => 'Ookla', + 'fast' => 'Fast.com', + 'cloudflare' => 'Cloudflare', + ]; + + $filters = []; + + foreach ($times as $timeKey => $timeLabel) { + foreach ($services as $serviceKey => $serviceLabel) { + // Use a pipe separator: time|service + $filters["{$timeKey}|{$serviceKey}"] = "{$timeLabel} ({$serviceLabel})"; + } + } + + return $filters; } } diff --git a/app/Filament/Widgets/RecentDownloadChartWidget.php b/app/Filament/Widgets/RecentDownloadChartWidget.php index 098708648..c07a20146 100644 --- a/app/Filament/Widgets/RecentDownloadChartWidget.php +++ b/app/Filament/Widgets/RecentDownloadChartWidget.php @@ -30,50 +30,90 @@ public function getHeading(): ?string public function mount(): void { - $this->filter = $this->filter ?? config('speedtest.default_chart_range', '24h'); + $this->filter = $this->filter ?? (config('speedtest.default_chart_range', '24h') . '|all'); } protected function getData(): array { + // Parse filter: "time|service" (e.g. "24h|all", "week|ookla") + $filterParts = explode('|', $this->filter ?? '24h|all'); + $timeFilter = $filterParts[0] ?? '24h'; + $serviceFilter = $filterParts[1] ?? 'all'; + $results = Result::query() - ->select(['id', 'download', 'created_at']) + ->select(['id', 'service', 'download', 'created_at']) ->where('status', '=', ResultStatus::Completed) - ->when($this->filter === '24h', function ($query) { + ->when($serviceFilter !== 'all', function ($query) use ($serviceFilter) { + // Determine the enum value if possible, or just compare string if that matches DB + // DB stores 'ookla', 'fast'. + // If using SQLite/MySQL string comparison is fine. + // But strict mode might require enum mapping if strict comparison was the issue before. + // However, query builder `where` handles string vs enum casting usually if model casts it. + // But let's be safe and pass the string since DB column is string/enum. + $query->where('service', $serviceFilter); + }) + ->when($timeFilter === '24h', function ($query) { $query->where('created_at', '>=', now()->subDay()); }) - ->when($this->filter === 'week', function ($query) { + ->when($timeFilter === 'week', function ($query) { $query->where('created_at', '>=', now()->subWeek()); }) - ->when($this->filter === 'month', function ($query) { + ->when($timeFilter === 'month', function ($query) { $query->where('created_at', '>=', now()->subMonth()); }) ->orderBy('created_at') ->get(); + $datasets = []; + + // Group results by service + $services = $results->groupBy('service'); + + foreach ($services as $serviceName => $serviceResults) { + $color = match ($serviceName) { + 'ookla' => '14, 165, 233', // Sky Blue + 'fast' => '220, 38, 38', // Red + 'cloudflare' => '249, 115, 22', // Orange + default => '139, 92, 246', // Violet + }; + + // Map data to the global timeline (labels) + // Since we are using an ordinal scale (labels array), we need to ensure alignment. + // However, simply pushing data points often works if x-axis is not strict time-scale. + // But to be correct with "labels" being all timestamps, we should map values to those timestamps. + // Simpler approach for now: Just map the values and let Chart.js handle the "sparse" nature if we use explicit x/y structures, + // OR, if we just want to show lines, we can just dump the values if they are sequential. + // + // WAIT: If we have Labels [T1, T2, T3]. + // Ookla has T1. Fast has T2. Ookla has T3. + // Ookla Data: [V1, null, V3] + // Fast Data: [null, V2, null] + // This is required for correct alignment. + + $data = $results->map(function ($result) use ($serviceName) { + $serviceValue = $result->service instanceof \App\Enums\ResultService ? $result->service->value : $result->service; + if ($serviceValue === $serviceName) { + return ! blank($result->download) ? Number::bitsToMagnitude(bits: $result->download_bits, precision: 2, magnitude: 'mbit') : null; + } + return null; + }); + + $datasets[] = [ + 'label' => ucfirst($serviceName), + 'data' => $data, + 'borderColor' => "rgba($color, 1)", + 'backgroundColor' => "rgba($color, 0.1)", + 'pointBackgroundColor' => "rgba($color, 1)", + 'fill' => true, + 'cubicInterpolationMode' => 'monotone', + 'tension' => 0.4, + 'pointRadius' => count($results) <= 24 ? 3 : 0, + 'spanGaps' => true, // Connect lines across nulls? Maybe false is better to show distinct tests. Let's try true for smooth graph. + ]; + } + return [ - 'datasets' => [ - [ - 'label' => __('general.download'), - 'data' => $results->map(fn ($item) => ! blank($item->download) ? Number::bitsToMagnitude(bits: $item->download_bits, precision: 2, magnitude: 'mbit') : null), - 'borderColor' => 'rgba(14, 165, 233)', - 'backgroundColor' => 'rgba(14, 165, 233, 0.1)', - 'pointBackgroundColor' => 'rgba(14, 165, 233)', - 'fill' => true, - 'cubicInterpolationMode' => 'monotone', - 'tension' => 0.4, - 'pointRadius' => count($results) <= 24 ? 3 : 0, - ], - [ - 'label' => __('general.average'), - 'data' => array_fill(0, count($results), Average::averageDownload($results)), - 'borderColor' => 'rgb(243, 7, 6, 1)', - 'pointBackgroundColor' => 'rgb(243, 7, 6, 1)', - 'fill' => false, - 'cubicInterpolationMode' => 'monotone', - 'tension' => 0.4, - 'pointRadius' => 0, - ], - ], + 'datasets' => $datasets, 'labels' => $results->map(fn ($item) => $item->created_at->timezone(config('app.display_timezone'))->format(config('app.chart_datetime_format'))), ]; } diff --git a/app/Filament/Widgets/RecentDownloadLatencyChartWidget.php b/app/Filament/Widgets/RecentDownloadLatencyChartWidget.php index e06c86c87..da793cb61 100644 --- a/app/Filament/Widgets/RecentDownloadLatencyChartWidget.php +++ b/app/Filament/Widgets/RecentDownloadLatencyChartWidget.php @@ -6,6 +6,7 @@ use App\Filament\Widgets\Concerns\HasChartFilters; use App\Models\Result; use Filament\Widgets\ChartWidget; +use Filament\Widgets\Concerns\InteractsWithPageFilters; class RecentDownloadLatencyChartWidget extends ChartWidget { @@ -28,21 +29,28 @@ public function getHeading(): ?string public function mount(): void { - $this->filter = $this->filter ?? config('speedtest.default_chart_range', '24h'); + $this->filter = $this->filter ?? (config('speedtest.default_chart_range', '24h') . '|all'); } protected function getData(): array { + $filterParts = explode('|', $this->filter ?? '24h|all'); + $timeFilter = $filterParts[0] ?? '24h'; + $serviceFilter = $filterParts[1] ?? 'all'; + $results = Result::query() ->select(['id', 'data', 'created_at']) ->where('status', '=', ResultStatus::Completed) - ->when($this->filter === '24h', function ($query) { + ->when($serviceFilter !== 'all', function ($query) use ($serviceFilter) { + $query->where('service', $serviceFilter); + }) + ->when($timeFilter === '24h', function ($query) { $query->where('created_at', '>=', now()->subDay()); }) - ->when($this->filter === 'week', function ($query) { + ->when($timeFilter === 'week', function ($query) { $query->where('created_at', '>=', now()->subWeek()); }) - ->when($this->filter === 'month', function ($query) { + ->when($timeFilter === 'month', function ($query) { $query->where('created_at', '>=', now()->subMonth()); }) ->orderBy('created_at') diff --git a/app/Filament/Widgets/RecentJitterChartWidget.php b/app/Filament/Widgets/RecentJitterChartWidget.php index 03dd59b13..3d4579297 100644 --- a/app/Filament/Widgets/RecentJitterChartWidget.php +++ b/app/Filament/Widgets/RecentJitterChartWidget.php @@ -28,62 +28,92 @@ public function getHeading(): ?string public function mount(): void { - $this->filter = $this->filter ?? config('speedtest.default_chart_range', '24h'); + $this->filter = $this->filter ?? (config('speedtest.default_chart_range', '24h') . '|all'); } protected function getData(): array { + $filterParts = explode('|', $this->filter ?? '24h|all'); + $timeFilter = $filterParts[0] ?? '24h'; + $serviceFilter = $filterParts[1] ?? 'all'; + $results = Result::query() - ->select(['id', 'data', 'created_at']) + ->select(['id', 'service', 'data', 'created_at']) ->where('status', '=', ResultStatus::Completed) - ->when($this->filter === '24h', function ($query) { + ->when($serviceFilter !== 'all', function ($query) use ($serviceFilter) { + $query->where('service', $serviceFilter); + }) + ->when($timeFilter === '24h', function ($query) { $query->where('created_at', '>=', now()->subDay()); }) - ->when($this->filter === 'week', function ($query) { + ->when($timeFilter === 'week', function ($query) { $query->where('created_at', '>=', now()->subWeek()); }) - ->when($this->filter === 'month', function ($query) { + ->when($timeFilter === 'month', function ($query) { $query->where('created_at', '>=', now()->subMonth()); }) ->orderBy('created_at') ->get(); + $datasets = []; + + $services = $results->groupBy('service'); + + foreach ($services as $serviceName => $serviceResults) { + $isOokla = $serviceName === 'ookla'; + $borderDash = match ($serviceName) { + 'ookla' => [], // Solid + 'fast' => [5, 5], // Dashed + 'cloudflare' => [2, 2], // Dotted + default => [10, 5], // Long Dotted + }; + $labelSuffix = ' (' . ucfirst($serviceName) . ')'; + + // Download Jitter (Blue) + $datasets[] = [ + 'label' => __('general.download_ms') . $labelSuffix, + 'data' => $results->map(fn ($item) => ($item->service instanceof \App\Enums\ResultService ? $item->service->value : $item->service) === $serviceName ? $item->download_jitter : null), + 'borderColor' => 'rgba(14, 165, 233)', + 'backgroundColor' => 'rgba(14, 165, 233, 0.1)', + 'pointBackgroundColor' => 'rgba(14, 165, 233)', + 'fill' => true, + 'tension' => 0.4, + 'pointRadius' => 0, // Reduce clutter + 'borderDash' => $borderDash, + 'spanGaps' => true, + ]; + + // Upload Jitter (Violet) + $datasets[] = [ + 'label' => __('general.upload_ms') . $labelSuffix, + 'data' => $results->map(fn ($item) => ($item->service instanceof \App\Enums\ResultService ? $item->service->value : $item->service) === $serviceName ? $item->upload_jitter : null), + 'borderColor' => 'rgba(139, 92, 246)', + 'backgroundColor' => 'rgba(139, 92, 246, 0.1)', + 'pointBackgroundColor' => 'rgba(139, 92, 246)', + 'fill' => true, + 'tension' => 0.4, + 'pointRadius' => 0, + 'borderDash' => $borderDash, + 'spanGaps' => true, + ]; + + // Ping Jitter (Green) + $datasets[] = [ + 'label' => __('general.ping_ms_label') . $labelSuffix, + 'data' => $results->map(fn ($item) => ($item->service instanceof \App\Enums\ResultService ? $item->service->value : $item->service) === $serviceName ? $item->ping_jitter : null), + 'borderColor' => 'rgba(16, 185, 129)', + 'backgroundColor' => 'rgba(16, 185, 129, 0.1)', + 'pointBackgroundColor' => 'rgba(16, 185, 129)', + 'fill' => true, + 'tension' => 0.4, + 'pointRadius' => 0, + 'borderDash' => $borderDash, + 'spanGaps' => true, + ]; + } + return [ - 'datasets' => [ - [ - 'label' => __('general.download_ms'), - 'data' => $results->map(fn ($item) => $item->download_jitter), - 'borderColor' => 'rgba(14, 165, 233)', - 'backgroundColor' => 'rgba(14, 165, 233, 0.1)', - 'pointBackgroundColor' => 'rgba(14, 165, 233)', - 'fill' => true, - 'cubicInterpolationMode' => 'monotone', - 'tension' => 0.4, - 'pointRadius' => count($results) <= 24 ? 3 : 0, - ], - [ - 'label' => __('general.upload_ms'), - 'data' => $results->map(fn ($item) => $item->upload_jitter), - 'borderColor' => 'rgba(139, 92, 246)', - 'backgroundColor' => 'rgba(139, 92, 246, 0.1)', - 'pointBackgroundColor' => 'rgba(139, 92, 246)', - 'fill' => true, - 'cubicInterpolationMode' => 'monotone', - 'tension' => 0.4, - 'pointRadius' => count($results) <= 24 ? 3 : 0, - ], - [ - 'label' => __('general.ping_ms_label'), - 'data' => $results->map(fn ($item) => $item->ping_jitter), - 'borderColor' => 'rgba(16, 185, 129)', - 'backgroundColor' => 'rgba(16, 185, 129, 0.1)', - 'pointBackgroundColor' => 'rgba(16, 185, 129)', - 'fill' => true, - 'cubicInterpolationMode' => 'monotone', - 'tension' => 0.4, - 'pointRadius' => count($results) <= 24 ? 3 : 0, - ], - ], + 'datasets' => $datasets, 'labels' => $results->map(fn ($item) => $item->created_at->timezone(config('app.display_timezone'))->format(config('app.chart_datetime_format'))), ]; } diff --git a/app/Filament/Widgets/RecentPingChartWidget.php b/app/Filament/Widgets/RecentPingChartWidget.php index 096a190ec..d42e391a1 100644 --- a/app/Filament/Widgets/RecentPingChartWidget.php +++ b/app/Filament/Widgets/RecentPingChartWidget.php @@ -29,50 +29,70 @@ public function getHeading(): ?string public function mount(): void { - $this->filter = $this->filter ?? config('speedtest.default_chart_range', '24h'); + $this->filter = $this->filter ?? (config('speedtest.default_chart_range', '24h') . '|all'); } protected function getData(): array { + $filterParts = explode('|', $this->filter ?? '24h|all'); + $timeFilter = $filterParts[0] ?? '24h'; + $serviceFilter = $filterParts[1] ?? 'all'; + $results = Result::query() - ->select(['id', 'ping', 'created_at']) + ->select(['id', 'service', 'ping', 'created_at']) ->where('status', '=', ResultStatus::Completed) - ->when($this->filter === '24h', function ($query) { + ->when($serviceFilter !== 'all', function ($query) use ($serviceFilter) { + $query->where('service', $serviceFilter); + }) + ->when($timeFilter === '24h', function ($query) { $query->where('created_at', '>=', now()->subDay()); }) - ->when($this->filter === 'week', function ($query) { + ->when($timeFilter === 'week', function ($query) { $query->where('created_at', '>=', now()->subWeek()); }) - ->when($this->filter === 'month', function ($query) { + ->when($timeFilter === 'month', function ($query) { $query->where('created_at', '>=', now()->subMonth()); }) ->orderBy('created_at') ->get(); + $datasets = []; + + // Group results by service + $services = $results->groupBy('service'); + + foreach ($services as $serviceName => $serviceResults) { + $color = match ($serviceName) { + 'ookla' => '14, 165, 233', // Sky Blue + 'fast' => '220, 38, 38', // Red + 'cloudflare' => '249, 115, 22', // Orange + default => '139, 92, 246', // Violet + }; + + $data = $results->map(function ($result) use ($serviceName) { + $serviceValue = $result->service instanceof \App\Enums\ResultService ? $result->service->value : $result->service; + if ($serviceValue === $serviceName) { + return $result->ping; + } + return null; + }); + + $datasets[] = [ + 'label' => ucfirst($serviceName), + 'data' => $data, + 'borderColor' => "rgba($color, 1)", + 'backgroundColor' => "rgba($color, 0.1)", + 'pointBackgroundColor' => "rgba($color, 1)", + 'fill' => true, + 'cubicInterpolationMode' => 'monotone', + 'tension' => 0.4, + 'pointRadius' => count($results) <= 24 ? 3 : 0, + 'spanGaps' => true, + ]; + } + return [ - 'datasets' => [ - [ - 'label' => __('general.ping'), - 'data' => $results->map(fn ($item) => $item->ping), - 'borderColor' => 'rgba(16, 185, 129)', - 'backgroundColor' => 'rgba(16, 185, 129, 0.1)', - 'pointBackgroundColor' => 'rgba(16, 185, 129)', - 'fill' => true, - 'cubicInterpolationMode' => 'monotone', - 'tension' => 0.4, - 'pointRadius' => count($results) <= 24 ? 3 : 0, - ], - [ - 'label' => __('general.average'), - 'data' => array_fill(0, count($results), Average::averagePing($results)), - 'borderColor' => 'rgb(243, 7, 6, 1)', - 'pointBackgroundColor' => 'rgb(243, 7, 6, 1)', - 'fill' => false, - 'cubicInterpolationMode' => 'monotone', - 'tension' => 0.4, - 'pointRadius' => 0, - ], - ], + 'datasets' => $datasets, 'labels' => $results->map(fn ($item) => $item->created_at->timezone(config('app.display_timezone'))->format(config('app.chart_datetime_format'))), ]; } diff --git a/app/Filament/Widgets/RecentUploadChartWidget.php b/app/Filament/Widgets/RecentUploadChartWidget.php index 1bb96eb04..30fb61ae1 100644 --- a/app/Filament/Widgets/RecentUploadChartWidget.php +++ b/app/Filament/Widgets/RecentUploadChartWidget.php @@ -30,50 +30,80 @@ public function getHeading(): ?string public function mount(): void { - $this->filter = $this->filter ?? config('speedtest.default_chart_range', '24h'); + $this->filter = $this->filter ?? (config('speedtest.default_chart_range', '24h') . '|all'); } protected function getData(): array { + $filterParts = explode('|', $this->filter ?? '24h|all'); + $timeFilter = $filterParts[0] ?? '24h'; + $serviceFilter = $filterParts[1] ?? 'all'; + $results = Result::query() - ->select(['id', 'upload', 'created_at']) + ->select(['id', 'service', 'upload', 'created_at']) ->where('status', '=', ResultStatus::Completed) - ->when($this->filter === '24h', function ($query) { + ->when($serviceFilter !== 'all', function ($query) use ($serviceFilter) { + $query->where('service', $serviceFilter); + }) + ->when($timeFilter === '24h', function ($query) { $query->where('created_at', '>=', now()->subDay()); }) - ->when($this->filter === 'week', function ($query) { + ->when($timeFilter === 'week', function ($query) { $query->where('created_at', '>=', now()->subWeek()); }) - ->when($this->filter === 'month', function ($query) { + ->when($timeFilter === 'month', function ($query) { $query->where('created_at', '>=', now()->subMonth()); }) ->orderBy('created_at') ->get(); + $datasets = []; + + // Group results by service + $services = $results->groupBy('service'); + + foreach ($services as $serviceName => $serviceResults) { + $color = match ($serviceName) { + 'ookla' => '139, 92, 246', // Violet for Upload default? Or maybe generic default. + // Let's stick to consistent Service colors: ookla=Blue, fast=Red + 'ookla' => '14, 165, 233', // Sky Blue + 'fast' => '220, 38, 38', // Red + default => '139, 92, 246', // Violet + }; + + // Fix for duplicate key in match map above + // Actually, let's use the same map as Download chart for consistency. + $color = match ($serviceName) { + 'ookla' => '14, 165, 233', // Sky Blue + 'fast' => '220, 38, 38', // Red + 'cloudflare' => '249, 115, 22', // Orange + default => '139, 92, 246', // Violet + }; + + $data = $results->map(function ($result) use ($serviceName) { + $serviceValue = $result->service instanceof \App\Enums\ResultService ? $result->service->value : $result->service; + if ($serviceValue === $serviceName) { + return ! blank($result->upload) ? Number::bitsToMagnitude(bits: $result->upload_bits, precision: 2, magnitude: 'mbit') : null; + } + return null; + }); + + $datasets[] = [ + 'label' => ucfirst($serviceName), + 'data' => $data, + 'borderColor' => "rgba($color, 1)", + 'backgroundColor' => "rgba($color, 0.1)", + 'pointBackgroundColor' => "rgba($color, 1)", + 'fill' => true, + 'cubicInterpolationMode' => 'monotone', + 'tension' => 0.4, + 'pointRadius' => count($results) <= 24 ? 3 : 0, + 'spanGaps' => true, + ]; + } + return [ - 'datasets' => [ - [ - 'label' => __('general.upload'), - 'data' => $results->map(fn ($item) => ! blank($item->upload) ? Number::bitsToMagnitude(bits: $item->upload_bits, precision: 2, magnitude: 'mbit') : null), - 'borderColor' => 'rgba(139, 92, 246)', - 'backgroundColor' => 'rgba(139, 92, 246, 0.1)', - 'pointBackgroundColor' => 'rgba(139, 92, 246)', - 'fill' => true, - 'cubicInterpolationMode' => 'monotone', - 'tension' => 0.4, - 'pointRadius' => count($results) <= 24 ? 3 : 0, - ], - [ - 'label' => __('general.average'), - 'data' => array_fill(0, count($results), Average::averageUpload($results)), - 'borderColor' => 'rgb(243, 7, 6, 1)', - 'pointBackgroundColor' => 'rgb(243, 7, 6, 1)', - 'fill' => false, - 'cubicInterpolationMode' => 'monotone', - 'tension' => 0.4, - 'pointRadius' => 0, - ], - ], + 'datasets' => $datasets, 'labels' => $results->map(fn ($item) => $item->created_at->timezone(config('app.display_timezone'))->format(config('app.chart_datetime_format'))), ]; } diff --git a/app/Filament/Widgets/RecentUploadLatencyChartWidget.php b/app/Filament/Widgets/RecentUploadLatencyChartWidget.php index 90315ddd9..62972cbda 100644 --- a/app/Filament/Widgets/RecentUploadLatencyChartWidget.php +++ b/app/Filament/Widgets/RecentUploadLatencyChartWidget.php @@ -6,6 +6,7 @@ use App\Filament\Widgets\Concerns\HasChartFilters; use App\Models\Result; use Filament\Widgets\ChartWidget; +use Filament\Widgets\Concerns\InteractsWithPageFilters; class RecentUploadLatencyChartWidget extends ChartWidget { @@ -28,21 +29,28 @@ public function getHeading(): ?string public function mount(): void { - $this->filter = $this->filter ?? config('speedtest.default_chart_range', '24h'); + $this->filter = $this->filter ?? (config('speedtest.default_chart_range', '24h') . '|all'); } protected function getData(): array { + $filterParts = explode('|', $this->filter ?? '24h|all'); + $timeFilter = $filterParts[0] ?? '24h'; + $serviceFilter = $filterParts[1] ?? 'all'; + $results = Result::query() ->select(['id', 'data', 'created_at']) ->where('status', '=', ResultStatus::Completed) - ->when($this->filter === '24h', function ($query) { + ->when($serviceFilter !== 'all', function ($query) use ($serviceFilter) { + $query->where('service', $serviceFilter); + }) + ->when($timeFilter === '24h', function ($query) { $query->where('created_at', '>=', now()->subDay()); }) - ->when($this->filter === 'week', function ($query) { + ->when($timeFilter === 'week', function ($query) { $query->where('created_at', '>=', now()->subWeek()); }) - ->when($this->filter === 'month', function ($query) { + ->when($timeFilter === 'month', function ($query) { $query->where('created_at', '>=', now()->subMonth()); }) ->orderBy('created_at') diff --git a/app/Http/Controllers/Api/V1/SpeedtestController.php b/app/Http/Controllers/Api/V1/SpeedtestController.php index 19519f61e..459894184 100644 --- a/app/Http/Controllers/Api/V1/SpeedtestController.php +++ b/app/Http/Controllers/Api/V1/SpeedtestController.php @@ -36,13 +36,13 @@ public function __invoke(Request $request) ); } - $result = RunSpeedtestAction::run( + $results = \App\Actions\Speedtest\RunSpeedtest::run( serverId: $request->input('server_id'), dispatchedBy: $request->user()->id, ); return $this->sendResponse( - data: new ResultResource($result), + data: ResultResource::collection($results), message: 'Speedtest added to the queue.', code: Response::HTTP_CREATED, ); diff --git a/app/Jobs/Speedtest/RunSpeedtestJob.php b/app/Jobs/Speedtest/RunSpeedtestJob.php new file mode 100644 index 000000000..4f8da339c --- /dev/null +++ b/app/Jobs/Speedtest/RunSpeedtestJob.php @@ -0,0 +1,60 @@ +result->update(['status' => ResultStatus::Running]); + + // Dispatch SpeedtestRunning event if needed. + // App\Events\SpeedtestRunning::dispatch($this->result); + + try { + $driver = match ($this->result->service) { + ResultService::Ookla => new OoklaDriver(), + ResultService::Fast => new FastDriver(), + ResultService::Cloudflare => new CloudflareDriver(), + default => throw new \Exception("Unsupported service: {$this->result->service->value}"), + }; + + $driver->run($this->result); + + } catch (\Throwable $e) { + Log::error($e); + $this->result->update([ + 'status' => ResultStatus::Failed, + 'data->error' => $e->getMessage(), + ]); + + // App\Events\SpeedtestFailed::dispatch($this->result); + + if ($this->batch()) { + $this->batch()->cancel(); + } + } + } +} diff --git a/app/Livewire/Topbar/Actions.php b/app/Livewire/Topbar/Actions.php index 9077724c8..e1db1d2b0 100644 --- a/app/Livewire/Topbar/Actions.php +++ b/app/Livewire/Topbar/Actions.php @@ -3,7 +3,7 @@ namespace App\Livewire\Topbar; use App\Actions\GetOoklaSpeedtestServers; -use App\Actions\Ookla\RunSpeedtest; +use App\Actions\Speedtest\RunSpeedtest; use App\Helpers\Ookla; use Filament\Actions\Action; use Filament\Actions\Concerns\InteractsWithActions; diff --git a/app/Models/Result.php b/app/Models/Result.php index bba1a37d9..4a70ae53e 100644 --- a/app/Models/Result.php +++ b/app/Models/Result.php @@ -44,7 +44,7 @@ protected function casts(): array */ public function prunable(): Builder { - return static::where('created_at', '<=', now()->subDays(config('speedtest.prune_results_older_than'))); + return static::where('created_at', '<=', now()->subDays(app(\App\Settings\GeneralSettings::class)->prune_results_older_than)); } /** diff --git a/app/Services/Speedtest/Drivers/CloudflareDriver.php b/app/Services/Speedtest/Drivers/CloudflareDriver.php new file mode 100644 index 000000000..b570e2e4e --- /dev/null +++ b/app/Services/Speedtest/Drivers/CloudflareDriver.php @@ -0,0 +1,62 @@ +find('cfspeedtest') !== null; + } + + public function run(Result $result): void + { + $process = new Process(['cfspeedtest', '-n5', '-m100m', '--output-format', 'json']); + $process->setTimeout(300); + + try { + $process->mustRun(); + } catch (ProcessFailedException $exception) { + throw new \RuntimeException('Cloudflare speedtest failed: ' . $exception->getMessage()); + } + + $output = json_decode($process->getOutput(), true); + + // Filter 10MB payload size + $downloadMeasurements = collect($output['speed_measurements']) + ->where('test_type', 'Download') + ->where('payload_size', 10000000) + ->first(); + + $uploadMeasurements = collect($output['speed_measurements']) + ->where('test_type', 'Upload') + ->where('payload_size', 10000000) + ->first(); + + // Convert Mbps to Bytes: Mbps * 125000 = Bytes/s + $downloadSpeed = $downloadMeasurements['avg'] ?? 0; + $uploadSpeed = $uploadMeasurements['avg'] ?? 0; + + $result->update([ + 'ping' => $output['latency_measurement']['avg_latency_ms'] ?? null, + 'download' => $downloadSpeed * 125000, + 'upload' => $uploadSpeed * 125000, + 'data' => [ + 'server' => [ + 'id' => $output['metadata']['asn'] ?? null, + 'name' => $output['metadata']['colo'] ?? null, + 'location' => $output['metadata']['colo'] ?? null, + ], + 'latency' => $output['latency_measurement'] ?? null, + 'speed' => $output['speed_measurements'] ?? null, + ], + ]); + } +} diff --git a/app/Services/Speedtest/Drivers/FastDriver.php b/app/Services/Speedtest/Drivers/FastDriver.php new file mode 100644 index 000000000..456fa3c2f --- /dev/null +++ b/app/Services/Speedtest/Drivers/FastDriver.php @@ -0,0 +1,81 @@ +setTimeout(120); + + try { + $process->mustRun(); + } catch (ProcessFailedException $exception) { + $result->update([ + 'data->type' => 'log', + 'data->level' => 'error', + 'data->message' => $exception->getMessage(), + 'status' => ResultStatus::Failed, + ]); + + SpeedtestFailed::dispatch($result); + + throw $exception; + } + + $output = json_decode($process->getOutput(), true); + + $downloadMbps = Arr::get($output, 'downloadSpeed', 0); + $uploadMbps = Arr::get($output, 'uploadSpeed', 0); + $ping = Arr::get($output, 'latency', 0); + + $downloadBytesPerSec = $downloadMbps * 125000; + $uploadBytesPerSec = $uploadMbps * 125000; + + // Map Fast.com data to Ookla-like structure for consistency + $data = $output; + + // Map Client IP + if ($ip = Arr::get($output, 'userIp')) { + Arr::set($data, 'interface.externalIp', $ip); + } + + // Map Location to Server Name (since Fast.com doesn't give a named server) + // Map Location to Server Name from custom fast-cli output + if ($serverLocations = Arr::get($output, 'serverLocations')) { + $locations = implode(' | ', $serverLocations); + Arr::set($data, 'server.name', $locations); + Arr::set($data, 'server.location', $locations); + } else { + Arr::set($data, 'server.name', 'Fast.com'); + } + + // Ensure server ID is present (mock it or use a constant for Fast) + Arr::set($data, 'server.id', 0); + + // Map bytes transferred (downloaded/uploaded are in MB in the custom JSON) + // Convert MB to Bytes: 1 MB = 1,000,000 Bytes (approx/decimal standard for network) + // Or 1,048,576 for binary. Let's start with decimal to match Mbps logic (125000). + $downloadBytes = Arr::get($output, 'downloaded', 0) * 1000000; + $uploadBytes = Arr::get($output, 'uploaded', 0) * 1000000; + + $result->update([ + 'ping' => $ping, + 'download' => $downloadBytesPerSec, + 'upload' => $uploadBytesPerSec, + 'download_bytes' => $downloadBytes, + 'upload_bytes' => $uploadBytes, + 'data' => $data, + ]); + } +} diff --git a/app/Services/Speedtest/Drivers/OoklaDriver.php b/app/Services/Speedtest/Drivers/OoklaDriver.php new file mode 100644 index 000000000..6a217dff0 --- /dev/null +++ b/app/Services/Speedtest/Drivers/OoklaDriver.php @@ -0,0 +1,63 @@ +speedtest_servers ? '--server-id='.app(\App\Settings\GeneralSettings::class)->speedtest_servers : null, + config('speedtest.interface') ? '--interface='.config('speedtest.interface') : null, + ]); + + $process = new Process($command); + $process->setTimeout(120); + + try { + $process->mustRun(); + } catch (ProcessFailedException $exception) { + $result->update([ + 'data->type' => 'log', + 'data->level' => 'error', + 'data->message' => Ookla::getErrorMessage($exception), + 'status' => ResultStatus::Failed, + ]); + + SpeedtestFailed::dispatch($result); + + throw $exception; + } + + $output = json_decode($process->getOutput(), true); + + $result->update([ + 'ping' => Arr::get($output, 'ping.latency'), + 'download' => Arr::get($output, 'download.bandwidth'), + 'upload' => Arr::get($output, 'upload.bandwidth'), + 'download_bytes' => Arr::get($output, 'download.bytes'), + 'upload_bytes' => Arr::get($output, 'upload.bytes'), + 'data' => $output, + ]); + } +} diff --git a/app/Services/Speedtest/SpeedtestDriver.php b/app/Services/Speedtest/SpeedtestDriver.php new file mode 100644 index 000000000..6886f8df5 --- /dev/null +++ b/app/Services/Speedtest/SpeedtestDriver.php @@ -0,0 +1,27 @@ +getMessage(); + } +} diff --git a/app/Settings/GeneralSettings.php b/app/Settings/GeneralSettings.php new file mode 100644 index 000000000..8a326a5df --- /dev/null +++ b/app/Settings/GeneralSettings.php @@ -0,0 +1,38 @@ +migrator->add('general.speedtest_schedule', '0 * * * *'); + $this->migrator->add('general.speedtest_server', null); + $this->migrator->add('general.ookla_enabled', true); + $this->migrator->add('general.fast_enabled', false); + $this->migrator->add('general.execution_mode', 'sequential'); + } +}; diff --git a/database/settings/2025_12_26_000001_add_extra_general_settings.php b/database/settings/2025_12_26_000001_add_extra_general_settings.php new file mode 100644 index 000000000..c1e092c0b --- /dev/null +++ b/database/settings/2025_12_26_000001_add_extra_general_settings.php @@ -0,0 +1,16 @@ +migrator->add('general.display_timezone', 'Asia/Kuala_Lumpur'); + $this->migrator->add('general.chart_datetime_format', 'j/m g:i A'); + $this->migrator->add('general.datetime_format', 'j M Y, g:i A'); + $this->migrator->add('general.prune_results_older_than', 35); + $this->migrator->add('general.speedtest_servers', '29925,29925,60357'); + $this->migrator->add('general.speedtest_base_url', 'https://google.com'); + } +}; diff --git a/database/settings/2025_12_26_000002_add_cloudflare_enabled_setting.php b/database/settings/2025_12_26_000002_add_cloudflare_enabled_setting.php new file mode 100644 index 000000000..4572d3577 --- /dev/null +++ b/database/settings/2025_12_26_000002_add_cloudflare_enabled_setting.php @@ -0,0 +1,11 @@ +migrator->add('general.cloudflare_enabled', false); + } +}; diff --git a/docker/8.4/Dockerfile b/docker/8.4/Dockerfile index 262aa0055..be6729778 100644 --- a/docker/8.4/Dockerfile +++ b/docker/8.4/Dockerfile @@ -20,13 +20,16 @@ RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone RUN echo "Acquire::http::Pipeline-Depth 0;" > /etc/apt/apt.conf.d/99custom && \ echo "Acquire::http::No-Cache true;" >> /etc/apt/apt.conf.d/99custom && \ - echo "Acquire::BrokenProxy true;" >> /etc/apt/apt.conf.d/99custom + echo "Acquire::BrokenProxy true;" >> /etc/apt/apt.conf.d/99custom && \ + echo "Package: chromium*\nPin: origin ppa.launchpadcontent.net\nPin-Priority: 1001" > /etc/apt/preferences.d/chromium RUN apt-get update && apt-get upgrade -y \ && mkdir -p /etc/apt/keyrings \ && apt-get install -y gnupg gosu curl ca-certificates zip unzip git supervisor sqlite3 libcap2-bin libpng-dev python3 dnsutils iputils-ping librsvg2-bin fswatch \ && curl -sS 'https://keyserver.ubuntu.com/pks/lookup?op=get&search=0xb8dc7e53946656efbce4c1dd71daeaab4ad4cab6' | gpg --dearmor | tee /etc/apt/keyrings/ppa_ondrej_php.gpg > /dev/null \ && echo "deb [signed-by=/etc/apt/keyrings/ppa_ondrej_php.gpg] https://ppa.launchpadcontent.net/ondrej/php/ubuntu noble main" > /etc/apt/sources.list.d/ppa_ondrej_php.list \ + && curl -sS 'https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x82BB6851C64F6880' | gpg --dearmor | tee /etc/apt/keyrings/xtradeb_apps.gpg > /dev/null \ + && echo "deb [signed-by=/etc/apt/keyrings/xtradeb_apps.gpg] https://ppa.launchpadcontent.net/xtradeb/apps/ubuntu noble main" > /etc/apt/sources.list.d/xtradeb_apps.list \ && apt-get update \ && apt-get install -y php8.4-cli php8.4-dev \ php8.4-pgsql php8.4-sqlite3 php8.4-gd \ @@ -42,6 +45,11 @@ RUN apt-get update && apt-get upgrade -y \ && apt-get update \ && apt-get install -y nodejs \ && npm install -g npm \ + && git clone https://github.com/rizaljamhari/fast-cli.git /serverdata/fast-cli \ + && cd /serverdata/fast-cli \ + && npm install \ + && npm run build \ + && npm install -g . \ && curl -sS https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor | tee /etc/apt/keyrings/pgdg.gpg >/dev/null \ && echo "deb [signed-by=/etc/apt/keyrings/pgdg.gpg] http://apt.postgresql.org/pub/repos/apt noble-pgdg main" > /etc/apt/sources.list.d/pgdg.list \ && apt-get update \ @@ -63,10 +71,18 @@ RUN apt-get update && apt-get upgrade -y \ && curl -o /tmp/librespeed-cli.tar.gz -L \ "https://github.com/librespeed/speedtest-cli/releases/download/v$LIBRESPEED_VERSION/librespeed-cli_${LIBRESPEED_VERSION}_linux_${LIBRESPEED_PLATFORM}.tar.gz" \ && tar -xzf /tmp/librespeed-cli.tar.gz -C /usr/bin \ + && apt-get install -y chromium \ + && curl -o /tmp/cfspeedtest.tar.gz -L \ + "https://github.com/code-inflation/cfspeedtest/releases/download/v2.0.2/cfspeedtest-${PLATFORM}-unknown-linux-gnu.tar.gz" \ + && tar -xzf /tmp/cfspeedtest.tar.gz -C /usr/bin \ + && chmod +x /usr/bin/cfspeedtest \ && apt-get -y autoremove \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* +ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true +ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium + RUN setcap "cap_net_bind_service=+ep" /usr/bin/php8.4 RUN userdel -r ubuntu diff --git a/package-lock.json b/package-lock.json index 6e8087046..78c89bf98 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "html", + "name": "speedtest-tracker", "lockfileVersion": 3, "requires": true, "packages": { diff --git a/routes/console.php b/routes/console.php index e4a670f0b..d63a89260 100644 --- a/routes/console.php +++ b/routes/console.php @@ -9,7 +9,7 @@ Schedule::command('model:prune') ->daily() ->when(function () { - return config('speedtest.prune_results_older_than') > 0; + return app(\App\Settings\GeneralSettings::class)->prune_results_older_than > 0; }); /** diff --git a/tests/Feature/MultiServiceSpeedtestTest.php b/tests/Feature/MultiServiceSpeedtestTest.php new file mode 100644 index 000000000..2313ea639 --- /dev/null +++ b/tests/Feature/MultiServiceSpeedtestTest.php @@ -0,0 +1,85 @@ + true, + 'fast_enabled' => true, + 'execution_mode' => 'parallel', + ]); + + $action = app(RunSpeedtest::class); + $results = $action->handle(); + + $this->assertCount(2, $results); + $this->assertEquals(ResultService::Ookla, $results[0]->service); + $this->assertEquals(ResultService::Fast, $results[1]->service); + + Bus::assertBatched(function ($batch) { + return $batch->name === 'Ookla Speedtest'; + }); + + Bus::assertBatched(function ($batch) { + return $batch->name === 'Fast Speedtest'; + }); + } + + public function test_it_runs_only_ookla_when_fast_disabled() + { + Bus::fake(); + + GeneralSettings::fake([ + 'ookla_enabled' => true, + 'fast_enabled' => false, + 'execution_mode' => 'parallel', + ]); + + $action = app(RunSpeedtest::class); + $results = $action->handle(); + + $this->assertCount(1, $results); + $this->assertEquals(ResultService::Ookla, $results[0]->service); + + Bus::assertBatched(function ($batch) { + return $batch->name === 'Ookla Speedtest'; + }); + + Bus::assertNothingBatched(function ($batch) { + return $batch->name === 'Fast Speedtest'; + }); + } + + public function test_it_dispatches_single_batch_for_sequential_mode() + { + Bus::fake(); + + GeneralSettings::fake([ + 'ookla_enabled' => true, + 'fast_enabled' => true, + 'execution_mode' => 'sequential', + ]); + + $action = app(RunSpeedtest::class); + $action->handle(); + + Bus::assertBatched(function ($batch) { + return $batch->name === 'Sequential Speedtests'; + }); + + Bus::assertNothingBatched(function ($batch) { + return $batch->name === 'Ookla Speedtest'; + }); + } +} From d9b68a299591fcc114370f0c6b8a70732442c27f Mon Sep 17 00:00:00 2001 From: Rizal Jamhari Date: Fri, 26 Dec 2025 14:39:26 +0800 Subject: [PATCH 2/2] chore: add compose --- docker-compose.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 docker-compose.yml diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 000000000..2d2b64283 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,16 @@ +services: + speedtest-tracker-multi: + build: + context: ./docker/8.4 + dockerfile: Dockerfile + args: + WWWGROUP: '${WWWGROUP}' + image: speedtest-tracker-8.4/app + restart: unless-stopped + ports: + - "8081:80" + container_name: speedtest-tracker-multi + volumes: + - /serverdata/speedtest-tracker-data/config:/config + cap_add: + - SYS_ADMIN