diff --git a/app/Actions/MigrateBadJsonResults.php b/app/Actions/MigrateBadJsonResults.php new file mode 100644 index 000000000..9bb9f08df --- /dev/null +++ b/app/Actions/MigrateBadJsonResults.php @@ -0,0 +1,105 @@ +bad_json_migrated) { + Notification::make() + ->title('❌ Hmmm it seems someone has already migrated the data!') + ->body('Check your results table and make sure you\'re not triggering a duplicate data migration.') + ->danger() + ->sendToDatabase($user); + + return; + } + + if (! Schema::hasTable('results')) { + Notification::make() + ->title('❌ Could not migrate bad json results!') + ->body('The "results" table is missing.') + ->danger() + ->sendToDatabase($user); + + return; + } + + if (! Schema::hasTable($tableName)) { + Notification::make() + ->title('❌ Could not migrate bad json results!') + ->body('The "results_bad_json" table is missing.') + ->danger() + ->sendToDatabase($user); + + return; + } + + /** + * Copy backup data to the new results table and reformat it. + */ + try { + DB::table($tableName)->chunkById(100, function ($results) { + foreach ($results as $result) { + $record = [ + 'service' => 'ookla', + 'ping' => $result->ping, + 'download' => $result->download, + 'upload' => $result->upload, + 'comments' => $result->comments, + 'data' => json_decode($result->data), + 'status' => match ($result->successful) { + 1 => ResultStatus::Completed, + default => ResultStatus::Failed, + }, + 'scheduled' => $result->scheduled, + 'created_at' => $result->created_at, + 'updated_at' => now(), + ]; + + DB::table('results')->insert($record); + } + }); + } catch (\Throwable $e) { + Log::error($e); + + Notification::make() + ->title('There was an issue migrating the data!') + ->body('Check the logs for an output of the issue.') + ->danger() + ->sendToDatabase($user); + + return; + } + + $dataSettings->bad_json_migrated = true; + + $dataSettings->save(); + + Notification::make() + ->title('Data migration completed!') + ->body('Your history has been successfully migrated.') + ->success() + ->sendToDatabase($user); + } +} diff --git a/app/Enums/ResultStatus.php b/app/Enums/ResultStatus.php new file mode 100644 index 000000000..448dd7237 --- /dev/null +++ b/app/Enums/ResultStatus.php @@ -0,0 +1,17 @@ +name; + } +} diff --git a/app/Exports/ResultsSelectedBulkExport.php b/app/Exports/ResultsSelectedBulkExport.php index 68b655322..7d6d529de 100644 --- a/app/Exports/ResultsSelectedBulkExport.php +++ b/app/Exports/ResultsSelectedBulkExport.php @@ -19,6 +19,9 @@ public function array(): array return $this->results; } + /** + * TODO: fix it + */ public function headings(): array { return [ diff --git a/app/Filament/Resources/ResultResource.php b/app/Filament/Resources/ResultResource.php index e41059c9e..a7b333318 100644 --- a/app/Filament/Resources/ResultResource.php +++ b/app/Filament/Resources/ResultResource.php @@ -2,23 +2,29 @@ namespace App\Filament\Resources; +use App\Actions\MigrateBadJsonResults; +use App\Enums\ResultStatus; use App\Exports\ResultsSelectedBulkExport; use App\Filament\Resources\ResultResource\Pages; +use App\Helpers\Number; use App\Helpers\TimeZoneHelper; use App\Models\Result; +use App\Settings\DataMigrationSettings; use App\Settings\GeneralSettings; use Carbon\Carbon; use Filament\Forms; use Filament\Forms\Components\TextInput; use Filament\Forms\Form; +use Filament\Notifications\Notification; use Filament\Resources\Resource; +use Filament\Support\Enums\Alignment; use Filament\Tables; use Filament\Tables\Actions\Action; -use Filament\Tables\Columns\IconColumn; -use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; +use Illuminate\Support\Facades\Auth; +use Illuminate\Support\HtmlString; use Maatwebsite\Excel\Facades\Excel; class ResultResource extends Resource @@ -52,17 +58,6 @@ public static function form(Form $form): Form $component->state(Carbon::parse($state)->format($settings->time_format ?? 'M j, Y G:i:s')); }) ->columnSpan(2), - Forms\Components\TextInput::make('server_id') - ->label('Server ID'), - Forms\Components\TextInput::make('server_name') - ->label('Server name') - ->columnSpan(2), - Forms\Components\TextInput::make('server_host') - ->label('Server host') - ->columnSpan([ - 'default' => 2, - 'md' => 3, - ]), Forms\Components\TextInput::make('download') ->label('Download (Mbps)') ->afterStateHydrated(function (TextInput $component, $state) { @@ -75,11 +70,26 @@ public static function form(Form $form): Form }), Forms\Components\TextInput::make('ping') ->label('Ping (Ms)'), + Forms\Components\TextInput::make('data.download.latency.jitter') + ->label('Download Jitter (Ms)'), + Forms\Components\TextInput::make('data.upload.latency.jitter') + ->label('Upload Jitter (Ms)'), + Forms\Components\TextInput::make('data.ping.jitter') + ->label('Ping Jitter (Ms)'), ]) ->columnSpan(2), Forms\Components\Section::make() ->schema([ - Forms\Components\Checkbox::make('successful'), + Forms\Components\Placeholder::make('service') + ->content(fn (Result $result): string => $result->service), + Forms\Components\Placeholder::make('server_name') + ->content(fn (Result $result): string => $result->server_name), + Forms\Components\Placeholder::make('server_id') + ->label('Server ID') + ->content(fn (Result $result): string => $result->server_id), + Forms\Components\Placeholder::make('server_host') + ->label('Server ID') + ->content(fn (Result $result): string => $result->server_id), Forms\Components\Checkbox::make('scheduled'), ]) ->columns(1) @@ -88,65 +98,93 @@ public static function form(Form $form): Form 'md' => 1, ]), ]), - Forms\Components\Textarea::make('data') - ->rows(10) - ->columnSpan(2), ]); } public static function table(Table $table): Table { + $dataSettings = new DataMigrationSettings(); + $settings = new GeneralSettings(); return $table ->columns([ - TextColumn::make('id') + Tables\Columns\TextColumn::make('id') ->label('ID') ->sortable(), - TextColumn::make('server') - ->getStateUsing(fn (Result $record): ?string => ! blank($record->server_id) ? $record->server_id.' ('.$record->server_name.')' : null) + Tables\Columns\TextColumn::make('ip_address') + ->label('IP address') ->toggleable() + ->toggledHiddenByDefault() ->sortable(), - IconColumn::make('successful') - ->boolean() - ->toggleable(), - IconColumn::make('scheduled') - ->boolean() - ->toggleable(), - TextColumn::make('download') - ->label('Download (Mbps)') - ->getStateUsing(fn (Result $record): ?string => ! blank($record->download) ? toBits(convertSize($record->download), 2) : null) + Tables\Columns\TextColumn::make('service') + ->toggleable() + ->toggledHiddenByDefault() + ->sortable(), + Tables\Columns\TextColumn::make('server_id') + ->label('Server ID') + ->toggleable() + ->sortable(), + Tables\Columns\TextColumn::make('server_name') + ->toggleable() + ->sortable(), + Tables\Columns\TextColumn::make('download') + ->getStateUsing(fn (Result $record): ?string => ! blank($record->download) ? Number::fileSizeBits(bits: $record->download, precision: 2, perSecond: true) : null) ->sortable(), - TextColumn::make('upload') - ->label('Upload (Mbps)') - ->getStateUsing(fn (Result $record): ?string => ! blank($record->upload) ? toBits(convertSize($record->upload), 2) : null) + Tables\Columns\TextColumn::make('upload') + ->getStateUsing(fn (Result $record): ?string => ! blank($record->upload) ? Number::fileSizeBits(bits: $record->upload, precision: 2, perSecond: true) : null) ->sortable(), - TextColumn::make('ping') - ->label('Ping (Ms)') + Tables\Columns\TextColumn::make('ping') ->toggleable() ->sortable(), - TextColumn::make('download_jitter') - ->getStateUsing(fn (Result $record): ?string => json_decode($record->data, true)['download']['latency']['jitter'] ?? null) + Tables\Columns\TextColumn::make('download_jitter') ->toggleable() ->toggledHiddenByDefault() ->sortable(), - TextColumn::make('upload_jitter') - ->getStateUsing(fn (Result $record): ?string => json_decode($record->data, true)['upload']['latency']['jitter'] ?? null) + Tables\Columns\TextColumn::make('upload_jitter') ->toggleable() ->toggledHiddenByDefault() ->sortable(), - TextColumn::make('ping_jitter') - ->getStateUsing(fn (Result $record): ?string => json_decode($record->data, true)['ping']['jitter'] ?? null) + Tables\Columns\TextColumn::make('ping_jitter') ->toggleable() ->toggledHiddenByDefault() ->sortable(), - TextColumn::make('created_at') - ->label('Created') + Tables\Columns\TextColumn::make('status') + ->toggleable() + ->sortable(), + Tables\Columns\IconColumn::make('scheduled') + ->boolean() + ->toggleable() + ->toggledHiddenByDefault() + ->alignment(Alignment::Center), + Tables\Columns\TextColumn::make('created_at') ->dateTime($settings->time_format ?? 'M j, Y G:i:s') ->timezone(TimeZoneHelper::displayTimeZone($settings)) - ->sortable(), + ->sortable() + ->alignment(Alignment::End), + Tables\Columns\TextColumn::make('updated_at') + ->dateTime($settings->time_format ?? 'M j, Y G:i:s') + ->timezone(TimeZoneHelper::displayTimeZone($settings)) + ->toggleable() + ->toggledHiddenByDefault() + ->sortable() + ->alignment(Alignment::End), ]) ->filters([ + Tables\Filters\SelectFilter::make('ip_address') + ->label('IP address') + ->multiple() + ->options(function (): array { + return Result::query() + ->select('data->interface->externalIp AS public_ip_address') + ->distinct() + ->get() + ->mapWithKeys(function (Result $item, int $key) { + return [$item['public_ip_address'] => $item['public_ip_address']]; + }) + ->toArray(); + }) + ->attribute('data->interface->externalIp'), Tables\Filters\TernaryFilter::make('scheduled') ->placeholder('-') ->trueLabel('Only scheduled speedtests') @@ -156,23 +194,17 @@ public static function table(Table $table): Table false: fn (Builder $query) => $query->where('scheduled', false), blank: fn (Builder $query) => $query, ), - Tables\Filters\TernaryFilter::make('successful') - ->placeholder('-') - ->trueLabel('Only successful speedtests') - ->falseLabel('Only failed speedtests') - ->queries( - true: fn (Builder $query) => $query->where('successful', true), - false: fn (Builder $query) => $query->where('successful', false), - blank: fn (Builder $query) => $query, - ), + Tables\Filters\SelectFilter::make('status') + ->multiple() + ->options(ResultStatus::class), ]) ->actions([ Tables\Actions\ActionGroup::make([ Action::make('view result') ->label('View on Speedtest.net') ->icon('heroicon-o-link') - ->url(fn (Result $record): ?string => $record?->url) - ->hidden(fn (Result $record): bool => ! $record->is_successful) + ->url(fn (Result $record): ?string => $record->result_url) + ->hidden(fn (Result $record): bool => $record->status !== ResultStatus::Completed) ->openUrlInNewTab(), Tables\Actions\ViewAction::make(), Tables\Actions\Action::make('updateComments') @@ -206,14 +238,26 @@ public static function table(Table $table): Table }), Tables\Actions\DeleteBulkAction::make(), ]) - ->defaultSort('created_at', 'desc'); - } + ->headerActions([ + Tables\Actions\Action::make('migrate') + ->action(function (): void { + Notification::make() + ->title('Starting data migration...') + ->body('This can take a little bit depending how much data you have.') + ->warning() + ->sendToDatabase(Auth::user()); - public static function getRelations(): array - { - return [ - // - ]; + MigrateBadJsonResults::dispatch(Auth::user()); + }) + ->hidden($dataSettings->bad_json_migrated) + ->requiresConfirmation() + ->modalHeading('Migrate History') + ->modalDescription(new HtmlString('
v0.16.0 archived the old "results" table, to migrate your history click the button below.
For more information read the docs.
')) + ->modalSubmitActionLabel('Yes, migrate it'), + ]) + ->defaultSort('created_at', 'desc') + ->paginated([5, 15, 25, 50, 100]) + ->defaultPaginationPageOption(15); } public static function getPages(): array diff --git a/app/Filament/Widgets/RecentJitterChartWidget.php b/app/Filament/Widgets/RecentJitterChartWidget.php index f7d9d2bad..b52fce9b3 100644 --- a/app/Filament/Widgets/RecentJitterChartWidget.php +++ b/app/Filament/Widgets/RecentJitterChartWidget.php @@ -2,6 +2,7 @@ namespace App\Filament\Widgets; +use App\Enums\ResultStatus; use App\Helpers\TimeZoneHelper; use App\Models\Result; use App\Settings\GeneralSettings; @@ -37,6 +38,7 @@ protected function getData(): array $results = Result::query() ->select(['data', 'created_at']) + ->where('status', '=', ResultStatus::Completed) ->when($this->filter == '24h', function ($query) { $query->where('created_at', '>=', now()->subDay()); }) @@ -52,7 +54,7 @@ protected function getData(): array 'datasets' => [ [ 'label' => 'Download (ms)', - 'data' => $results->map(fn ($item) => $item->getJitterData()['download'] ? number_format($item->getJitterData()['download'], 2) : 0), + 'data' => $results->map(fn ($item) => $item->download_jitter ? number_format($item->download_jitter, 2) : 0), 'borderColor' => '#0ea5e9', 'backgroundColor' => '#0ea5e9', 'fill' => false, @@ -61,7 +63,7 @@ protected function getData(): array ], [ 'label' => 'Upload (ms)', - 'data' => $results->map(fn ($item) => $item->getJitterData()['upload'] ? number_format($item->getJitterData()['upload'], 2) : 0), + 'data' => $results->map(fn ($item) => $item->upload_jitter ? number_format($item->upload_jitter, 2) : 0), 'borderColor' => '#8b5cf6', 'backgroundColor' => '#8b5cf6', 'fill' => false, @@ -70,7 +72,7 @@ protected function getData(): array ], [ 'label' => 'Ping (ms)', - 'data' => $results->map(fn ($item) => $item->getJitterData()['ping'] ? number_format($item->getJitterData()['ping'], 2) : 0), + 'data' => $results->map(fn ($item) => $item->ping_jitter ? number_format($item->ping_jitter, 2) : 0), 'borderColor' => '#10b981', 'backgroundColor' => '#10b981', 'fill' => false, diff --git a/app/Filament/Widgets/RecentPingChartWidget.php b/app/Filament/Widgets/RecentPingChartWidget.php index 873f1f9f8..a50199cff 100644 --- a/app/Filament/Widgets/RecentPingChartWidget.php +++ b/app/Filament/Widgets/RecentPingChartWidget.php @@ -2,6 +2,7 @@ namespace App\Filament\Widgets; +use App\Enums\ResultStatus; use App\Helpers\TimeZoneHelper; use App\Models\Result; use App\Settings\GeneralSettings; @@ -37,6 +38,7 @@ protected function getData(): array $results = Result::query() ->select(['ping', 'created_at']) + ->where('status', '=', ResultStatus::Completed) ->when($this->filter == '24h', function ($query) { $query->where('created_at', '>=', now()->subDay()); }) diff --git a/app/Filament/Widgets/RecentSpeedChartWidget.php b/app/Filament/Widgets/RecentSpeedChartWidget.php index 9be0032e5..b02156c94 100644 --- a/app/Filament/Widgets/RecentSpeedChartWidget.php +++ b/app/Filament/Widgets/RecentSpeedChartWidget.php @@ -2,6 +2,7 @@ namespace App\Filament\Widgets; +use App\Enums\ResultStatus; use App\Helpers\TimeZoneHelper; use App\Models\Result; use App\Settings\GeneralSettings; @@ -37,6 +38,7 @@ 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()); }) diff --git a/app/Filament/Widgets/StatsOverviewWidget.php b/app/Filament/Widgets/StatsOverviewWidget.php index 1237d6c82..0d0f43174 100644 --- a/app/Filament/Widgets/StatsOverviewWidget.php +++ b/app/Filament/Widgets/StatsOverviewWidget.php @@ -2,6 +2,8 @@ namespace App\Filament\Widgets; +use App\Enums\ResultStatus; +use App\Helpers\Number; use App\Models\Result; use Filament\Widgets\StatsOverviewWidget as BaseWidget; use Filament\Widgets\StatsOverviewWidget\Stat; @@ -18,10 +20,12 @@ protected function getPollingInterval(): ?string protected function getCards(): array { $this->result = Result::query() + ->select(['id', 'ping', 'download', 'upload', 'status', 'created_at']) + ->where('status', '=', ResultStatus::Completed) ->latest() ->first(); - if (blank($this->result) || ! $this->result->successful) { + if (blank($this->result)) { return [ Stat::make('Latest download', '-') ->icon('heroicon-o-arrow-down-tray'), @@ -33,17 +37,19 @@ protected function getCards(): array } $previous = Result::query() + ->select(['id', 'ping', 'download', 'upload', 'status', 'created_at']) ->where('id', '<', $this->result->id) + ->where('status', '=', ResultStatus::Completed) ->latest() ->first(); - if (! $previous || ! $previous->successful) { + if (! $previous) { return [ - Stat::make('Latest download', fn (): string => ! blank($this->result) ? toBits(convertSize($this->result->download), 2).' (Mbps)' : 'n/a') + Stat::make('Latest download', fn (): string => ! blank($this->result) ? Number::fileSizeBits(bits: $this->result->download_bits, precision: 2, perSecond: true) : 'n/a') ->icon('heroicon-o-arrow-down-tray'), - Stat::make('Latest upload', fn (): string => ! blank($this->result) ? toBits(convertSize($this->result->upload), 2).' (Mbps)' : 'n/a') + Stat::make('Latest upload', fn (): string => ! blank($this->result) ? Number::fileSizeBits(bits: $this->result->upload_bits, precision: 2, perSecond: true) : 'n/a') ->icon('heroicon-o-arrow-up-tray'), - Stat::make('Latest ping', fn (): string => ! blank($this->result) ? number_format($this->result->ping, 2).' (ms)' : 'n/a') + Stat::make('Latest ping', fn (): string => ! blank($this->result) ? number_format($this->result->ping, 2).' Ms' : 'n/a') ->icon('heroicon-o-clock'), ]; } @@ -53,17 +59,17 @@ protected function getCards(): array $pingChange = percentChange($this->result->ping, $previous->ping, 2); return [ - Stat::make('Latest download', fn (): string => ! blank($this->result) ? toBits(convertSize($this->result->download), 2).' (Mbps)' : 'n/a') + Stat::make('Latest download', fn (): string => ! blank($this->result) ? Number::fileSizeBits(bits: $this->result->download_bits, precision: 2, perSecond: true) : 'n/a') ->icon('heroicon-o-arrow-down-tray') ->description($downloadChange > 0 ? $downloadChange.'% faster' : abs($downloadChange).'% slower') ->descriptionIcon($downloadChange > 0 ? 'heroicon-m-arrow-trending-up' : 'heroicon-m-arrow-trending-down') ->color($downloadChange > 0 ? 'success' : 'danger'), - Stat::make('Latest upload', fn (): string => ! blank($this->result) ? toBits(convertSize($this->result->upload), 2).' (Mbps)' : 'n/a') + Stat::make('Latest upload', fn (): string => ! blank($this->result) ? Number::fileSizeBits(bits: $this->result->upload_bits, precision: 2, perSecond: true) : 'n/a') ->icon('heroicon-o-arrow-up-tray') ->description($uploadChange > 0 ? $uploadChange.'% faster' : abs($uploadChange).'% slower') ->descriptionIcon($uploadChange > 0 ? 'heroicon-m-arrow-trending-up' : 'heroicon-m-arrow-trending-down') ->color($uploadChange > 0 ? 'success' : 'danger'), - Stat::make('Latest ping', fn (): string => ! blank($this->result) ? number_format($this->result->ping, 2).' (ms)' : 'n/a') + Stat::make('Latest ping', fn (): string => ! blank($this->result) ? number_format($this->result->ping, 2).' Ms' : 'n/a') ->icon('heroicon-o-clock') ->description($pingChange > 0 ? $pingChange.'% slower' : abs($pingChange).'% faster') ->descriptionIcon($pingChange > 0 ? 'heroicon-m-arrow-trending-up' : 'heroicon-m-arrow-trending-down') diff --git a/app/Http/Controllers/API/Speedtest/GetLatestController.php b/app/Http/Controllers/API/Speedtest/GetLatestController.php index 9ff4bc454..7cd5d6be3 100644 --- a/app/Http/Controllers/API/Speedtest/GetLatestController.php +++ b/app/Http/Controllers/API/Speedtest/GetLatestController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers\API\Speedtest; +use App\Enums\ResultStatus; use App\Http\Controllers\Controller; use App\Models\Result; use Illuminate\Http\JsonResponse; @@ -14,6 +15,7 @@ class GetLatestController extends Controller public function __invoke(): JsonResponse { $latest = Result::query() + ->whereIn('status', [ResultStatus::Completed, ResultStatus::Failed]) ->latest() ->first(); @@ -28,16 +30,16 @@ public function __invoke(): JsonResponse 'data' => [ 'id' => $latest->id, 'ping' => $latest->ping, - 'download' => ! blank($latest->download) ? toBits(convertSize($latest->download)) : null, - 'upload' => ! blank($latest->upload) ? toBits(convertSize($latest->upload)) : null, + 'download' => $latest->download_bits, + 'upload' => $latest->upload_bits, 'server_id' => $latest->server_id, 'server_host' => $latest->server_host, 'server_name' => $latest->server_name, - 'url' => $latest->url, + 'url' => $latest->result_url, 'scheduled' => $latest->scheduled, - 'failed' => ! $latest->successful, + 'failed' => $latest->status === ResultStatus::Failed, 'created_at' => $latest->created_at->toISOString(true), - 'updated_at' => $latest->created_at->toISOString(true), // faking updated at to match legacy api payload + 'updated_at' => $latest->updated_at->toISOString(true), ], ]); } diff --git a/app/Http/Controllers/HomeController.php b/app/Http/Controllers/HomeController.php index c00b2efb1..7ea5aa13f 100644 --- a/app/Http/Controllers/HomeController.php +++ b/app/Http/Controllers/HomeController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers; +use App\Enums\ResultStatus; use App\Models\Result; use App\Settings\GeneralSettings; use Illuminate\Http\Request; @@ -20,7 +21,8 @@ public function __invoke(Request $request) } $latestResult = Result::query() - ->select(['id', 'ping', 'download', 'upload', 'successful', 'created_at']) + ->select(['id', 'ping', 'download', 'upload', 'status', 'created_at']) + ->where('status', '=', ResultStatus::Completed) ->latest() ->first(); diff --git a/app/Jobs/ExecSpeedtest.php b/app/Jobs/ExecSpeedtest.php index dd76f2032..7706a7ac6 100644 --- a/app/Jobs/ExecSpeedtest.php +++ b/app/Jobs/ExecSpeedtest.php @@ -2,12 +2,14 @@ namespace App\Jobs; +use App\Enums\ResultStatus; use App\Models\Result; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; +use Illuminate\Support\Arr; use Illuminate\Support\Facades\Log; use Symfony\Component\Process\Exception\ProcessFailedException; use Symfony\Component\Process\Process; @@ -50,28 +52,25 @@ public function handle(): void $message = collect(array_filter($messages, 'json_validate'))->last(); Result::create([ - 'scheduled' => $this->scheduled, - 'successful' => false, 'data' => $message, + 'status' => ResultStatus::Failed, + 'scheduled' => $this->scheduled, ]); return; } try { - $output = $process->getOutput(); - $results = json_decode($output, true); + $results = json_decode($process->getOutput(), true); Result::create([ - 'ping' => $results['ping']['latency'], - 'download' => $results['download']['bandwidth'], - 'upload' => $results['upload']['bandwidth'], - 'server_id' => $results['server']['id'], - 'server_name' => $results['server']['name'], - 'server_host' => $results['server']['host'].':'.$results['server']['port'], - 'url' => $results['result']['url'], + 'service' => 'ookla', + 'ping' => Arr::get($results, 'ping.latency'), + 'download' => Arr::get($results, 'download.bandwidth'), + 'upload' => Arr::get($results, 'upload.bandwidth'), + 'data' => $results, + 'status' => ResultStatus::Completed, 'scheduled' => $this->scheduled, - 'data' => $output, ]); } catch (\Exception $e) { Log::error($e->getMessage()); diff --git a/app/Models/Result.php b/app/Models/Result.php index 684c7d815..43425a781 100644 --- a/app/Models/Result.php +++ b/app/Models/Result.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Enums\ResultStatus; use App\Events\ResultCreated; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -13,30 +14,11 @@ class Result extends Model use HasFactory; /** - * Indicates if the model should be timestamped. - * - * @var bool - */ - public $timestamps = false; - - /** - * The attributes that are mass assignable. + * The attributes that aren't mass assignable. * * @var array */ - protected $fillable = [ - 'ping', - 'download', - 'upload', - 'server_id', - 'server_host', - 'server_name', - 'url', - 'comments', - 'scheduled', - 'successful', - 'data', - ]; + protected $guarded = []; /** * The attributes that should be cast. @@ -44,10 +26,9 @@ class Result extends Model * @var array */ protected $casts = [ - 'scheduled' => 'boolean', - 'successful' => 'boolean', 'data' => 'array', - 'created_at' => 'datetime', + 'status' => ResultStatus::class, + 'scheduled' => 'boolean', ]; /** @@ -76,35 +57,22 @@ public function formatTagsForInfluxDB2(): array */ public function formatForInfluxDB2() { - $data = json_decode($this->data, true); - return [ 'id' => $this->id, 'ping' => $this?->ping, 'download' => $this?->download, 'upload' => $this?->upload, - 'download_bits' => $this->download ? $this->download * 8 : null, - 'upload_bits' => $this->upload ? $this->upload * 8 : null, - 'ping_jitter' => Arr::get($data, 'ping.jitter'), - 'download_jitter' => Arr::get($data, 'download.latency.jitter'), - 'upload_jitter' => Arr::get($data, 'upload.latency.jitter'), + 'download_bits' => $this->download_bits, + 'upload_bits' => $this->upload_bits, + 'ping_jitter' => $this->ping_jitter, + 'download_jitter' => $this->download_jitter, + 'upload_jitter' => $this->upload_jitter, 'server_id' => $this?->server_id, 'server_host' => $this?->server_host, 'server_name' => $this?->server_name, 'scheduled' => $this->scheduled, - 'successful' => $this->successful, - 'packet_loss' => (float) Arr::get($data, 'packetLoss', 0), - ]; - } - - public function getJitterData(): array - { - $data = json_decode($this->data, true); - - return [ - 'download' => Arr::get($data, 'download.latency.jitter'), - 'upload' => Arr::get($data, 'upload.latency.jitter'), - 'ping' => Arr::get($data, 'ping.jitter'), + 'successful' => $this->status === ResultStatus::Completed, + 'packet_loss' => (float) $this->packet_loss, ]; } @@ -114,21 +82,121 @@ public function getJitterData(): array protected function downloadBits(): Attribute { return Attribute::make( - get: fn (mixed $value): ?string => ! blank($this->download) && is_numeric($this->download) + get: fn (): ?string => ! blank($this->download) && is_numeric($this->download) ? number_format(num: $this->download * 8, decimals: 0, thousands_separator: '') : null, ); } + /** + * Get the result's download jitter in milliseconds. + */ + protected function downloadJitter(): Attribute + { + return Attribute::make( + get: fn () => Arr::get($this->data, 'download.latency.jitter'), + ); + } + + /** + * Get the result's external ip address (yours). + */ + protected function ipAddress(): Attribute + { + return Attribute::make( + get: fn () => Arr::get($this->data, 'interface.externalIp'), + ); + } + + /** + * Get the result's isp tied to the external (yours) ip address. + */ + protected function isp(): Attribute + { + return Attribute::make( + get: fn () => Arr::get($this->data, 'isp'), + ); + } + + /** + * Get the result's packet loss as a percentage. + */ + protected function packetLoss(): Attribute + { + return Attribute::make( + get: fn () => Arr::get($this->data, 'packetLoss'), + ); + } + + /** + * Get the result's ping jitter in milliseconds. + */ + protected function pingJitter(): Attribute + { + return Attribute::make( + get: fn () => Arr::get($this->data, 'ping.jitter'), + ); + } + + /** + * Get the result's server ID. + */ + protected function resultUrl(): Attribute + { + return Attribute::make( + get: fn () => Arr::get($this->data, 'result.url'), + ); + } + + /** + * Get the result's server host. + */ + protected function serverHost(): Attribute + { + return Attribute::make( + get: fn () => Arr::get($this->data, 'server.host'), + ); + } + + /** + * Get the result's server ID. + */ + protected function serverId(): Attribute + { + return Attribute::make( + get: fn () => Arr::get($this->data, 'server.id'), + ); + } + + /** + * Get the result's server name. + */ + protected function serverName(): Attribute + { + return Attribute::make( + get: fn () => Arr::get($this->data, 'server.name'), + ); + } + /** * Get the result's upload in bits. */ protected function uploadBits(): Attribute { return Attribute::make( - get: fn (mixed $value): ?string => ! blank($this->upload) && is_numeric($this->upload) + get: fn (): ?string => ! blank($this->upload) && is_numeric($this->upload) ? number_format(num: $this->upload * 8, decimals: 0, thousands_separator: '') : null, ); } + + /** + * Get the result's upload jitter in milliseconds. + */ + protected function uploadJitter(): Attribute + { + return Attribute::make( + get: fn () => Arr::get($this->data, 'upload.latency.jitter'), + ); + } } diff --git a/app/Settings/DataMigrationSettings.php b/app/Settings/DataMigrationSettings.php new file mode 100644 index 000000000..8823f7cbd --- /dev/null +++ b/app/Settings/DataMigrationSettings.php @@ -0,0 +1,15 @@ +id(); + $table->string('service')->default('ookla'); + $table->float('ping', 8, 3)->nullable(); + $table->unsignedBigInteger('download')->nullable(); + $table->unsignedBigInteger('upload')->nullable(); + $table->text('comments')->nullable(); + $table->json('data')->nullable(); + $table->string('status'); + $table->boolean('scheduled')->default(false); + $table->timestamps(); + }); + } + + /** + * Don't disable the schedule or send a notification if there are no records. + */ + if (! DB::table('results_bad_json')->count()) { + $dataSettings = new DataMigrationSettings(); + + $dataSettings->bad_json_migrated = true; + + $dataSettings->save(); + + return; + } + + $settings = new GeneralSettings(); + + $settings->speedtest_schedule = ''; + + $settings->save(); + + $admins = User::select(['id', 'name', 'email', 'role']) + ->where('role', 'admin') + ->get(); + + foreach ($admins as $user) { + Notification::make() + ->title('Breaking change, action required!') + ->body('v0.16.0 includes a breaking change to resolve a data quality issue. Read the docs below to migrate your data.') + ->danger() + ->actions([ + Action::make('docs') + ->button() + ->url('https://docs.speedtest-tracker.dev/') + ->openUrlInNewTab(), + ]) + ->sendToDatabase($user); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::drop('results'); + + if (! Schema::hasTable('results')) { + Schema::rename('results_bad_json', 'results'); + } + } +}; diff --git a/database/settings/2024_02_18_000000_create_data_migration_settings.php b/database/settings/2024_02_18_000000_create_data_migration_settings.php new file mode 100644 index 000000000..0b0b06e1c --- /dev/null +++ b/database/settings/2024_02_18_000000_create_data_migration_settings.php @@ -0,0 +1,11 @@ +migrator->add('data_migration.bad_json_migrated', false); + } +};