diff --git a/app/Actions/CheckForScheduledSpeedtests.php b/app/Actions/CheckForScheduledSpeedtests.php index 47098bf34..55fc8f7fd 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; diff --git a/app/Actions/Iperf3/RunSpeedtest.php b/app/Actions/Iperf3/RunSpeedtest.php new file mode 100644 index 000000000..bcee57f2b --- /dev/null +++ b/app/Actions/Iperf3/RunSpeedtest.php @@ -0,0 +1,59 @@ +server->name' => $host, + 'data->server->host' => $host, + 'data->server->port' => $port, + 'data->iperf3->parallel' => max($parallel, 1), + 'service' => ResultService::Iperf3, + 'status' => ResultStatus::Waiting, + 'scheduled' => $scheduled, + 'dispatched_by' => $dispatchedBy, + ]); + + SpeedtestWaiting::dispatch($result); + + Bus::batch([ + [ + new StartSpeedtestJob($result), + new CheckForInternetConnectionJob($result), + new SkipSpeedtestJob($result), + new RunSpeedtestJob($result), + new BenchmarkSpeedtestJob($result), + new CompleteSpeedtestJob($result), + ], + ])->catch(function (Batch $batch, ?Throwable $e) { + Log::error(sprintf('Speedtest batch "%s" failed for an unknown reason.', $batch->id)); + })->name('iPerf3 Speedtest')->dispatch(); + + return $result; + } +} diff --git a/app/Actions/RunSpeedtest.php b/app/Actions/RunSpeedtest.php new file mode 100644 index 000000000..cae23b852 --- /dev/null +++ b/app/Actions/RunSpeedtest.php @@ -0,0 +1,42 @@ +value); + + $service = $service ?? ResultService::tryFrom((string) $configuredService) ?? ResultService::Ookla; + + return match ($service) { + ResultService::Iperf3 => RunIperf3Speedtest::run( + scheduled: $scheduled, + host: $host, + port: $port, + parallel: $parallel, + dispatchedBy: $dispatchedBy, + ), + default => RunOoklaSpeedtest::run( + scheduled: $scheduled, + serverId: $serverId, + dispatchedBy: $dispatchedBy, + ), + }; + } +} diff --git a/app/Enums/ResultService.php b/app/Enums/ResultService.php index 39a441d39..3a961e637 100644 --- a/app/Enums/ResultService.php +++ b/app/Enums/ResultService.php @@ -7,6 +7,7 @@ enum ResultService: string implements HasLabel { case Faker = 'faker'; + case Iperf3 = 'iperf3'; case Librespeed = 'librespeed'; case Ookla = 'ookla'; @@ -14,6 +15,7 @@ public function getLabel(): ?string { return match ($this) { self::Faker => __('enums.service.faker'), + self::Iperf3 => __('enums.service.iperf3'), self::Librespeed => __('enums.service.librespeed'), self::Ookla => __('enums.service.ookla'), }; diff --git a/app/Filament/Resources/Results/Tables/ResultTable.php b/app/Filament/Resources/Results/Tables/ResultTable.php index 6808e9f7d..967d93759 100644 --- a/app/Filament/Resources/Results/Tables/ResultTable.php +++ b/app/Filament/Resources/Results/Tables/ResultTable.php @@ -241,7 +241,7 @@ public static function table(Table $table): Table ->label(__('results.view_on_speedtest_net')) ->icon('heroicon-o-link') ->url(fn (Result $record): ?string => $record->result_url) - ->hidden(fn (Result $record): bool => $record->status !== ResultStatus::Completed) + ->hidden(fn (Result $record): bool => $record->status !== ResultStatus::Completed || blank($record->result_url)) ->openUrlInNewTab(), Action::make('updateComments') ->label(__('results.update_comments')) diff --git a/app/Filament/Tables/Columns/ResultServerColumn.php b/app/Filament/Tables/Columns/ResultServerColumn.php index 357951e15..75876e2fa 100644 --- a/app/Filament/Tables/Columns/ResultServerColumn.php +++ b/app/Filament/Tables/Columns/ResultServerColumn.php @@ -2,6 +2,7 @@ namespace App\Filament\Tables\Columns; +use App\Enums\ResultService; use Filament\Tables\Columns\Column; class ResultServerColumn extends Column @@ -25,4 +26,9 @@ public function getServerId(): ?int return $this->serverId; } + + public function shouldShowServerId(): bool + { + return $this->record->service === ResultService::Ookla; + } } diff --git a/app/Http/Controllers/Api/V1/SpeedtestController.php b/app/Http/Controllers/Api/V1/SpeedtestController.php index d605d945c..6619ab745 100644 --- a/app/Http/Controllers/Api/V1/SpeedtestController.php +++ b/app/Http/Controllers/Api/V1/SpeedtestController.php @@ -2,17 +2,19 @@ namespace App\Http\Controllers\Api\V1; -use App\Actions\Ookla\RunSpeedtest as RunSpeedtestAction; +use App\Actions\RunSpeedtest as RunSpeedtestAction; +use App\Enums\ResultService; use App\Http\Resources\V1\ResultResource; use Illuminate\Http\Request; use Illuminate\Http\Response; +use Illuminate\Validation\Rule; use Illuminate\Support\Facades\Validator; class SpeedtestController extends ApiController { /** * POST /api/v1/speedtests/run - * Run a new Ookla speedtest. + * Run a new speedtest. */ public function __invoke(Request $request) { @@ -24,8 +26,31 @@ public function __invoke(Request $request) ); } + if ($request->filled('service')) { + $request->merge([ + 'service' => strtolower((string) $request->input('service')), + ]); + } + + $service = ResultService::tryFrom((string) $request->input('service', config('speedtest.service', ResultService::Ookla->value))) + ?? ResultService::Ookla; + $validator = Validator::make($request->all(), [ - 'server_id' => 'sometimes|integer', + 'service' => ['sometimes', 'string', Rule::in([ResultService::Ookla->value, ResultService::Iperf3->value])], + 'server_id' => [ + 'nullable', + 'integer', + ], + 'host' => [ + 'nullable', + 'string', + 'max:255', + ], + 'port' => [ + 'nullable', + 'integer', + 'between:1,65535', + ], ]); if ($validator->fails()) { @@ -38,7 +63,10 @@ public function __invoke(Request $request) $result = RunSpeedtestAction::run( scheduled: true, + service: $service, serverId: $request->input('server_id'), + host: $request->input('host'), + port: $request->integer('port') ?: null, dispatchedBy: $request->user()->id, ); diff --git a/app/Jobs/Iperf3/RunSpeedtestJob.php b/app/Jobs/Iperf3/RunSpeedtestJob.php new file mode 100644 index 000000000..71a7d62b0 --- /dev/null +++ b/app/Jobs/Iperf3/RunSpeedtestJob.php @@ -0,0 +1,166 @@ +result->update([ + 'status' => ResultStatus::Running, + ]); + + SpeedtestRunning::dispatch($this->result); + + try { + $forward = $this->runPass(reverse: false); + $reverse = $this->runPass(reverse: true); + } catch (Throwable $exception) { + $message = $exception instanceof ProcessFailedException + ? (trim($exception->getProcess()->getErrorOutput()) ?: trim($exception->getMessage())) + : trim($exception->getMessage()); + + $this->result->update([ + 'data->type' => 'log', + 'data->level' => 'error', + 'data->message' => $message, + 'status' => ResultStatus::Failed, + ]); + + $this->batch()->cancel(); + + SpeedtestFailed::dispatch($this->result); + + return; + } + + $upload = $this->extractBandwidthBytesPerSecond($forward); + $download = $this->extractBandwidthBytesPerSecond($reverse); + $uploadBytes = $this->extractTransferredBytes($forward); + $downloadBytes = $this->extractTransferredBytes($reverse); + $ping = $this->extractPingMilliseconds($forward) ?? $this->extractPingMilliseconds($reverse) ?? 0.0; + + $this->result->update([ + 'ping' => $ping, + 'download' => $download, + 'upload' => $upload, + 'download_bytes' => $downloadBytes, + 'upload_bytes' => $uploadBytes, + 'data->server->name' => Arr::get($forward, 'start.connected.0.remote_host', $this->result->server_name), + 'data->server->host' => Arr::get($forward, 'start.connected.0.remote_host', $this->result->server_host), + 'data->server->port' => Arr::get($forward, 'start.connected.0.remote_port', $this->result->server_port), + 'data->iperf3->forward' => $forward, + 'data->iperf3->reverse' => $reverse, + 'data->interface->externalIp' => Arr::get($forward, 'start.connected.0.local_host', $this->result->ip_address), + ]); + } + + private function runPass(bool $reverse): array + { + $host = $this->result->server_host ?? config('speedtest.iperf3.host'); + $port = (int) ($this->result->server_port ?? config('speedtest.iperf3.port')); + $duration = (int) config('speedtest.iperf3.duration', 10); + $parallel = (int) ($this->result->data['iperf3']['parallel'] ?? config('speedtest.iperf3.parallel', 1)); + $bind = config('speedtest.iperf3.bind'); + + if (blank($host)) { + throw new \RuntimeException('No iPerf3 host is configured. Set IPERF3_HOST or provide a host when running the test.'); + } + + $command = array_filter([ + 'iperf3', + '--client', + $host, + '--port', + (string) $port, + '--json', + '--time', + (string) max($duration, 1), + '--parallel', + (string) max($parallel, 1), + $bind ? '--bind' : null, + $bind, + $reverse ? '--reverse' : null, + ]); + + $process = new Process($command); + $process->setTimeout(max(20, $duration + 15)); + $process->mustRun(); + + $decoded = json_decode($process->getOutput(), true); + + return is_array($decoded) ? $decoded : []; + } + + private function extractBandwidthBytesPerSecond(array $output): int + { + $sent = Arr::get($output, 'end.sum_sent.bits_per_second'); + $received = Arr::get($output, 'end.sum_received.bits_per_second'); + $bitsPerSecond = max((float) ($sent ?? 0), (float) ($received ?? 0)); + + return (int) round($bitsPerSecond / 8); + } + + private function extractTransferredBytes(array $output): ?int + { + $sent = Arr::get($output, 'end.sum_sent.bytes'); + $received = Arr::get($output, 'end.sum_received.bytes'); + $bytes = max((float) ($sent ?? 0), (float) ($received ?? 0)); + + return $bytes > 0 ? (int) round($bytes) : null; + } + + private function extractPingMilliseconds(array $output): ?float + { + $meanRttMicroseconds = Arr::get($output, 'end.streams.0.sender.mean_rtt') + ?? Arr::get($output, 'end.streams.0.receiver.mean_rtt'); + + if (blank($meanRttMicroseconds)) { + return null; + } + + return round(((float) $meanRttMicroseconds) / 1000, 3); + } +} diff --git a/app/Livewire/Topbar/Actions.php b/app/Livewire/Topbar/Actions.php index 9077724c8..d847477e4 100644 --- a/app/Livewire/Topbar/Actions.php +++ b/app/Livewire/Topbar/Actions.php @@ -2,13 +2,16 @@ namespace App\Livewire\Topbar; +use App\Actions\RunSpeedtest; +use App\Enums\ResultService; use App\Actions\GetOoklaSpeedtestServers; -use App\Actions\Ookla\RunSpeedtest; use App\Helpers\Ookla; use Filament\Actions\Action; use Filament\Actions\Concerns\InteractsWithActions; use Filament\Actions\Contracts\HasActions; +use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Select; +use Filament\Schemas\Components\Utilities\Get; use Filament\Forms\Concerns\InteractsWithForms; use Filament\Forms\Contracts\HasForms; use Filament\Notifications\Notification; @@ -39,22 +42,66 @@ public function speedtestAction(): Action { return Action::make('speedtest') ->schema([ + Select::make('service') + ->label(__('results.service')) + ->default(function (): string { + $configured = ResultService::tryFrom((string) config('speedtest.service', ResultService::Ookla->value)); + + return in_array($configured, [ResultService::Ookla, ResultService::Iperf3], true) + ? $configured->value + : ResultService::Ookla->value; + }) + ->options([ + ResultService::Ookla->value => ResultService::Ookla->getLabel(), + ResultService::Iperf3->value => ResultService::Iperf3->getLabel(), + ]) + ->live(), Select::make('server_id') ->label(__('results.select_server')) ->helperText(__('results.select_server_helper')) - ->options(function (): array { + ->options(function (Get $get): array { + if (($get('service') ?? config('speedtest.service', ResultService::Ookla->value)) !== ResultService::Ookla->value) { + return []; + } + return array_filter([ __('results.manual_servers') => Ookla::getConfigServers(), __('results.closest_servers') => GetOoklaSpeedtestServers::run(), ]); }) - ->searchable(), + ->searchable() + ->visible(fn (Get $get): bool => ($get('service') ?? config('speedtest.service', ResultService::Ookla->value)) === ResultService::Ookla->value), + TextInput::make('host') + ->label('iPerf3 host') + ->placeholder(config('speedtest.iperf3.host')) + ->maxLength(255) + ->visible(fn (Get $get): bool => ($get('service') ?? config('speedtest.service', ResultService::Ookla->value)) === ResultService::Iperf3->value), + TextInput::make('port') + ->label('iPerf3 port') + ->numeric() + ->minValue(1) + ->maxValue(65535) + ->placeholder((string) config('speedtest.iperf3.port', 5201)) + ->visible(fn (Get $get): bool => ($get('service') ?? config('speedtest.service', ResultService::Ookla->value)) === ResultService::Iperf3->value), + TextInput::make('parallel') + ->label('Concurrent connections') + ->numeric() + ->minValue(1) + ->maxValue(128) + ->placeholder((string) config('speedtest.iperf3.parallel', 1)) + ->visible(fn (Get $get): bool => ($get('service') ?? config('speedtest.service', ResultService::Ookla->value)) === ResultService::Iperf3->value), ]) ->action(function (array $data) { + $service = ResultService::tryFrom((string) ($data['service'] ?? config('speedtest.service', ResultService::Ookla->value))) + ?? ResultService::Ookla; $serverId = $data['server_id'] ?? null; RunSpeedtest::run( + service: $service, serverId: $serverId, + host: $data['host'] ?? null, + port: filled($data['port'] ?? null) ? (int) $data['port'] : null, + parallel: filled($data['parallel'] ?? null) ? (int) $data['parallel'] : null, dispatchedBy: Auth::id(), ); diff --git a/app/OpenApi/Annotations/V1/SpeedtestAnnotations.php b/app/OpenApi/Annotations/V1/SpeedtestAnnotations.php index 2a18ecbf2..61dd3a053 100644 --- a/app/OpenApi/Annotations/V1/SpeedtestAnnotations.php +++ b/app/OpenApi/Annotations/V1/SpeedtestAnnotations.php @@ -13,15 +13,36 @@ class SpeedtestAnnotations { #[OA\Post( path: '/api/v1/speedtests/run', - summary: 'Run a new Ookla speedtest', + summary: 'Run a new speedtest', operationId: 'runSpeedtest', tags: ['Speedtests'], parameters: [ new OA\Parameter(ref: '#/components/parameters/AcceptHeader'), + new OA\Parameter( + name: 'service', + in: 'query', + description: 'Speedtest service to run. Defaults to configured service.', + required: false, + schema: new OA\Schema(type: 'string', enum: ['ookla', 'iperf3']) + ), new OA\Parameter( name: 'server_id', in: 'query', - description: 'Optional Ookla speedtest server ID', + description: 'Optional Ookla speedtest server ID (Ookla only)', + required: false, + schema: new OA\Schema(type: 'integer') + ), + new OA\Parameter( + name: 'host', + in: 'query', + description: 'Optional iperf3 server hostname or IP address', + required: false, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'port', + in: 'query', + description: 'Optional iperf3 server port', required: false, schema: new OA\Schema(type: 'integer') ), diff --git a/app/OpenApi/Schemas/SpeedtestRunSchema.php b/app/OpenApi/Schemas/SpeedtestRunSchema.php index 0509802bd..229dccc94 100644 --- a/app/OpenApi/Schemas/SpeedtestRunSchema.php +++ b/app/OpenApi/Schemas/SpeedtestRunSchema.php @@ -34,6 +34,9 @@ type: 'object', properties: [ new OA\Property(property: 'id', type: 'integer', nullable: true), + new OA\Property(property: 'name', type: 'string', nullable: true), + new OA\Property(property: 'host', type: 'string', nullable: true), + new OA\Property(property: 'port', type: 'integer', nullable: true), ] ), ] diff --git a/config/speedtest.php b/config/speedtest.php index 9faa99341..b62a05452 100644 --- a/config/speedtest.php +++ b/config/speedtest.php @@ -2,6 +2,17 @@ use Carbon\Carbon; +$legacyConnectivityUrl = env('SPEEDTEST_CHECKINTERNET_URL'); +$preflightUrl = env('SPEEDTEST_EXTERNAL_IP_URL', $legacyConnectivityUrl ?: 'http://connectivitycheck.gstatic.com/generate_204'); +$preflightHostname = env('SPEEDTEST_INTERNET_CHECK_HOSTNAME'); + +if ($preflightHostname === null || $preflightHostname === '') { + $derivedHostname = parse_url((string) $preflightUrl, PHP_URL_HOST); + $preflightHostname = is_string($derivedHostname) && $derivedHostname !== '' + ? $derivedHostname + : '1.1.1.1'; +} + return [ /** * General settings. @@ -18,6 +29,8 @@ 'default_chart_range' => strtolower(env('DEFAULT_CHART_RANGE', '24h')), + 'service' => strtolower(env('SPEEDTEST_SERVICE', 'ookla')), + /** * Speedtest settings. */ @@ -29,9 +42,17 @@ 'interface' => env('SPEEDTEST_INTERFACE'), + 'iperf3' => [ + 'host' => env('IPERF3_HOST'), + 'port' => (int) env('IPERF3_PORT', 5201), + 'duration' => (int) env('IPERF3_DURATION', 10), + 'parallel' => (int) env('IPERF3_PARALLEL', 1), + 'bind' => env('IPERF3_BIND'), + ], + 'preflight' => [ - 'external_ip_url' => env('SPEEDTEST_CHECKINTERNET_URL') ?? env('SPEEDTEST_EXTERNAL_IP_URL', 'https://icanhazip.com'), - 'internet_check_hostname' => env('SPEEDTEST_CHECKINTERNET_URL') ?? env('SPEEDTEST_INTERNET_CHECK_HOSTNAME', 'icanhazip.com'), + 'external_ip_url' => $preflightUrl, + 'internet_check_hostname' => $preflightHostname, 'skip_ips' => env('SPEEDTEST_SKIP_IPS'), ], diff --git a/docker/8.4/Dockerfile b/docker/8.4/Dockerfile index 262aa0055..e2bd07485 100644 --- a/docker/8.4/Dockerfile +++ b/docker/8.4/Dockerfile @@ -24,7 +24,7 @@ RUN echo "Acquire::http::Pipeline-Depth 0;" > /etc/apt/apt.conf.d/99custom && \ 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 \ + && apt-get install -y gnupg gosu curl ca-certificates zip unzip git supervisor sqlite3 libcap2-bin libpng-dev python3 dnsutils iputils-ping iperf3 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 \ && apt-get update \ diff --git a/lang/en/enums.php b/lang/en/enums.php index c1ff432af..4eb4dd676 100644 --- a/lang/en/enums.php +++ b/lang/en/enums.php @@ -16,6 +16,8 @@ // Service enum values 'service' => [ 'faker' => 'Faker', + 'iperf3' => 'iPerf3', + 'librespeed' => 'LibreSpeed', 'ookla' => 'Ookla', ], ]; diff --git a/package-lock.json b/package-lock.json index 8a6a64dd1..fa1997879 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/resources/views/filament/tables/columns/result-server-column.blade.php b/resources/views/filament/tables/columns/result-server-column.blade.php index f16445828..ccccecf1c 100644 --- a/resources/views/filament/tables/columns/result-server-column.blade.php +++ b/resources/views/filament/tables/columns/result-server-column.blade.php @@ -1,7 +1,7 @@
{{ $getServerName() }} - @isset($getServerId) + @if($shouldShowServerId() && filled($getServerId())) (#{{ $getServerId() }}) - @endisset + @endif
diff --git a/tests/Feature/RunSpeedtestActionTest.php b/tests/Feature/RunSpeedtestActionTest.php new file mode 100644 index 000000000..f32628b47 --- /dev/null +++ b/tests/Feature/RunSpeedtestActionTest.php @@ -0,0 +1,47 @@ +set('speedtest.service', ResultService::Ookla->value); + + $result = RunSpeedtest::run(); + + expect($result->service)->toBe(ResultService::Ookla); + + Bus::assertBatched(fn ($batch) => $batch->name === 'Ookla Speedtest'); +}); + +test('run speedtest action dispatches iperf3 service when requested', function () { + Bus::fake(); + + $result = RunSpeedtest::run( + service: ResultService::Iperf3, + host: 'iperf3.example.com', + port: 5202, + ); + + expect($result->service)->toBe(ResultService::Iperf3) + ->and($result->data['server']['host'])->toBe('iperf3.example.com') + ->and($result->data['server']['port'])->toBe(5202); + + Bus::assertBatched(fn ($batch) => $batch->name === 'iPerf3 Speedtest'); +}); + +test('run speedtest action uses configured default service', function () { + Bus::fake(); + + config()->set('speedtest.service', ResultService::Iperf3->value); + config()->set('speedtest.iperf3.host', 'iperf3.default.example.com'); + + $result = RunSpeedtest::run(); + + expect($result->service)->toBe(ResultService::Iperf3) + ->and($result->data['server']['host'])->toBe('iperf3.default.example.com'); + + Bus::assertBatched(fn ($batch) => $batch->name === 'iPerf3 Speedtest'); +});