From 9c723d6b912be95dfce3a13ed9587f1d6f1df773 Mon Sep 17 00:00:00 2001 From: Matteo Pietro Dazzi Date: Mon, 21 Apr 2025 17:44:58 +0200 Subject: [PATCH 01/18] Add speedtest result bytes to results table (#2150) --- app/Actions/Influxdb/v2/BuildPointData.php | 4 ++- app/Filament/Exports/ResultExporter.php | 4 +++ app/Filament/Resources/ResultResource.php | 19 +++++++++++++ app/Http/Resources/V1/ResultResource.php | 2 ++ app/Jobs/Ookla/RunSpeedtestJob.php | 2 ++ database/factories/ResultFactory.php | 2 ++ ...2025_04_16_000050_update_results_table.php | 27 +++++++++++++++++++ 7 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 database/migrations/2025_04_16_000050_update_results_table.php diff --git a/app/Actions/Influxdb/v2/BuildPointData.php b/app/Actions/Influxdb/v2/BuildPointData.php index 9bead5a7a..508b86e94 100644 --- a/app/Actions/Influxdb/v2/BuildPointData.php +++ b/app/Actions/Influxdb/v2/BuildPointData.php @@ -48,7 +48,9 @@ public function handle(Result $result): Point ->addField('upload_latency_avg', Number::castToType(Arr::get($result->data, 'upload.latency.iqm'), 'float')) ->addField('upload_latency_high', Number::castToType(Arr::get($result->data, 'upload.latency.high'), 'float')) ->addField('upload_latency_low', Number::castToType(Arr::get($result->data, 'upload.latency.low'), 'float')) - ->addField('packet_loss', Number::castToType(Arr::get($result->data, 'packetLoss'), 'float')); + ->addField('packet_loss', Number::castToType(Arr::get($result->data, 'packetLoss'), 'float')) + ->addField('downloaded_bytes', Number::castToType($result->downloaded_bytes, 'int')) + ->addField('uploaded_bytes', Number::castToType($result->uploaded_bytes, 'int')); return $point; } diff --git a/app/Filament/Exports/ResultExporter.php b/app/Filament/Exports/ResultExporter.php index ab844ec99..c81e86afc 100644 --- a/app/Filament/Exports/ResultExporter.php +++ b/app/Filament/Exports/ResultExporter.php @@ -113,6 +113,10 @@ public static function getColumns(): array return $record->download_latency_iqm; }) ->enabledByDefault(false), + ExportColumn::make('downloaded_bytes') + ->enabledByDefault(false), + ExportColumn::make('uploaded_bytes') + ->enabledByDefault(false), ExportColumn::make('result_url') ->state(function (Result $record) { return $record->result_url; diff --git a/app/Filament/Resources/ResultResource.php b/app/Filament/Resources/ResultResource.php index 9e768b1bf..08069103c 100644 --- a/app/Filament/Resources/ResultResource.php +++ b/app/Filament/Resources/ResultResource.php @@ -20,6 +20,7 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Facades\Auth; use Illuminate\Support\HtmlString; +use Illuminate\Support\Number as LaravelNumber; class ResultResource extends Resource { @@ -83,6 +84,11 @@ public static function form(Form $form): Form ->formatStateUsing(function ($state) { return number_format((float) $state, 0, '.', '').' ms'; }), + Forms\Components\TextInput::make('downloaded_bytes') + ->label('Downloaded bytes') + ->afterStateHydrated(function (TextInput $component, Result $record) { + $component->state(! blank($record->downloaded_bytes) ? LaravelNumber::fileSize(bytes: $record->downloaded_bytes, precision: 2) : ''); + }), Forms\Components\TextInput::make('data.upload.latency.jitter') ->label('Upload Jitter') ->formatStateUsing(function ($state) { @@ -103,6 +109,11 @@ public static function form(Form $form): Form ->formatStateUsing(function ($state) { return number_format((float) $state, 0, '.', '').' ms'; }), + Forms\Components\TextInput::make('uploaded_bytes') + ->label('Uploaded bytes') + ->afterStateHydrated(function (TextInput $component, Result $record) { + $component->state(! blank($record->downloaded_bytes) ? LaravelNumber::fileSize(bytes: $record->uploaded_bytes, precision: 2) : ''); + }), Forms\Components\TextInput::make('data.ping.jitter') ->label('Ping Jitter') ->formatStateUsing(function ($state) { @@ -196,9 +207,17 @@ public static function table(Table $table): Table Tables\Columns\TextColumn::make('download') ->getStateUsing(fn (Result $record): ?string => ! blank($record->download) ? Number::toBitRate(bits: $record->download_bits, precision: 2) : null) ->sortable(), + Tables\Columns\TextColumn::make('downloaded_bytes') + ->toggleable() + ->getStateUsing(fn (Result $record): ?string => ! blank($record->download) ? LaravelNumber::fileSize(bytes: $record->downloaded_bytes, precision: 2) : null) + ->sortable(), Tables\Columns\TextColumn::make('upload') ->getStateUsing(fn (Result $record): ?string => ! blank($record->upload) ? Number::toBitRate(bits: $record->upload_bits, precision: 2) : null) ->sortable(), + Tables\Columns\TextColumn::make('uploaded_bytes') + ->toggleable() + ->getStateUsing(fn (Result $record): ?string => ! blank($record->download) ? LaravelNumber::fileSize(bytes: $record->uploaded_bytes, precision: 2) : null) + ->sortable(), Tables\Columns\TextColumn::make('ping') ->toggleable() ->sortable() diff --git a/app/Http/Resources/V1/ResultResource.php b/app/Http/Resources/V1/ResultResource.php index e4b1aacad..10ac8ed65 100644 --- a/app/Http/Resources/V1/ResultResource.php +++ b/app/Http/Resources/V1/ResultResource.php @@ -20,7 +20,9 @@ public function toArray(Request $request): array 'service' => $this->service, 'ping' => $this->ping, 'download' => $this->download, + 'downloaded_bytes' => $this->downloaded_bytes, 'upload' => $this->upload, + 'uploaded_bytes' => $this->uploaded_bytes, 'download_bits' => $this->when($this->download, fn (): int|float => Bitrate::bytesToBits($this->download)), 'upload_bits' => $this->when($this->upload, fn (): int|float => Bitrate::bytesToBits($this->upload)), 'download_bits_human' => $this->when($this->download, fn (): string => Bitrate::formatBits(Bitrate::bytesToBits($this->download)).'ps'), diff --git a/app/Jobs/Ookla/RunSpeedtestJob.php b/app/Jobs/Ookla/RunSpeedtestJob.php index 058c73363..e88a49c7a 100644 --- a/app/Jobs/Ookla/RunSpeedtestJob.php +++ b/app/Jobs/Ookla/RunSpeedtestJob.php @@ -89,7 +89,9 @@ public function handle(): void $this->result->update([ 'ping' => Arr::get($output, 'ping.latency'), 'download' => Arr::get($output, 'download.bandwidth'), + 'downloaded_bytes' => Arr::get($output, 'download.bytes'), 'upload' => Arr::get($output, 'upload.bandwidth'), + 'uploaded_bytes' => Arr::get($output, 'upload.bytes'), 'data' => $output, ]); } diff --git a/database/factories/ResultFactory.php b/database/factories/ResultFactory.php index 9fa2375ba..8de01da01 100644 --- a/database/factories/ResultFactory.php +++ b/database/factories/ResultFactory.php @@ -44,7 +44,9 @@ public function definition(): array 'service' => ResultService::Faker, 'ping' => Arr::get($output, 'ping.latency'), 'download' => Arr::get($output, 'download.bandwidth'), + 'downloaded_bytes' => Arr::get($output, 'download.bytes'), 'upload' => Arr::get($output, 'upload.bandwidth'), + 'uploaded_bytes' => Arr::get($output, 'upload.bytes'), 'data' => $output, 'status' => ResultStatus::Completed, 'scheduled' => false, diff --git a/database/migrations/2025_04_16_000050_update_results_table.php b/database/migrations/2025_04_16_000050_update_results_table.php new file mode 100644 index 000000000..01de010e8 --- /dev/null +++ b/database/migrations/2025_04_16_000050_update_results_table.php @@ -0,0 +1,27 @@ +unsignedBigInteger('downloaded_bytes')->nullable()->after('download'); + $table->unsignedBigInteger('uploaded_bytes')->nullable()->after('upload'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + // + } +}; From 532364594a4e4c4f9d2ad75b4d72b0702d779dec Mon Sep 17 00:00:00 2001 From: Alex Justesen <1144087+alexjustesen@users.noreply.github.com> Date: Mon, 28 Jul 2025 08:56:06 -0400 Subject: [PATCH 02/18] Add migration to include downloaded and uploaded bytes in results table --- ...table.php => 2025_07_28_124048_add_bytes_to_results_table.php} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename database/migrations/{2025_04_16_000050_update_results_table.php => 2025_07_28_124048_add_bytes_to_results_table.php} (100%) diff --git a/database/migrations/2025_04_16_000050_update_results_table.php b/database/migrations/2025_07_28_124048_add_bytes_to_results_table.php similarity index 100% rename from database/migrations/2025_04_16_000050_update_results_table.php rename to database/migrations/2025_07_28_124048_add_bytes_to_results_table.php From f59f32fdc335b71854c70325c36a50c8f941d65d Mon Sep 17 00:00:00 2001 From: Alex Justesen <1144087+alexjustesen@users.noreply.github.com> Date: Mon, 28 Jul 2025 08:56:10 -0400 Subject: [PATCH 03/18] Add SyncResultBytes command to synchronize downloaded and uploaded bytes for results --- app/Console/Commands/SyncResultBytes.php | 84 ++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 app/Console/Commands/SyncResultBytes.php diff --git a/app/Console/Commands/SyncResultBytes.php b/app/Console/Commands/SyncResultBytes.php new file mode 100644 index 000000000..6caceaf8d --- /dev/null +++ b/app/Console/Commands/SyncResultBytes.php @@ -0,0 +1,84 @@ +where(function ($query) { + $query->whereNull('downloaded_bytes') + ->orWhereNull('uploaded_bytes'); + }) + ->get(); + + if ($results->isEmpty()) { + $this->info('No results found that need bytes synchronization.'); + return 0; + } + + $this->info("Found {$results->count()} results to sync."); + + $progressBar = $this->output->createProgressBar($results->count()); + $progressBar->start(); + + $updated = 0; + + foreach ($results as $result) { + $downloadBytes = Arr::get($result->data, 'download.bytes'); + $uploadBytes = Arr::get($result->data, 'upload.bytes'); + + $needsUpdate = false; + $updates = []; + + // Check if download bytes need to be updated + if ($downloadBytes !== null && $result->downloaded_bytes === null) { + $updates['downloaded_bytes'] = $downloadBytes; + $needsUpdate = true; + } + + // Check if upload bytes need to be updated + if ($uploadBytes !== null && $result->uploaded_bytes === null) { + $updates['uploaded_bytes'] = $uploadBytes; + $needsUpdate = true; + } + + if ($needsUpdate) { + $result->update($updates); + $updated++; + } + + $progressBar->advance(); + } + + $progressBar->finish(); + $this->newLine(); + + $this->info("Sync completed. {$updated} results updated successfully."); + + return 0; + } +} From 00404e8b2ee3fcb9d0cb660b2eceadc136790d24 Mon Sep 17 00:00:00 2001 From: Alex Justesen <1144087+alexjustesen@users.noreply.github.com> Date: Mon, 28 Jul 2025 08:56:59 -0400 Subject: [PATCH 04/18] lint --- app/Console/Commands/SyncResultBytes.php | 3 ++- app/Filament/Resources/ResultResource.php | 1 - 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/Console/Commands/SyncResultBytes.php b/app/Console/Commands/SyncResultBytes.php index 6caceaf8d..b2f08ad37 100644 --- a/app/Console/Commands/SyncResultBytes.php +++ b/app/Console/Commands/SyncResultBytes.php @@ -31,12 +31,13 @@ public function handle() $results = Result::whereNotNull('data') ->where(function ($query) { $query->whereNull('downloaded_bytes') - ->orWhereNull('uploaded_bytes'); + ->orWhereNull('uploaded_bytes'); }) ->get(); if ($results->isEmpty()) { $this->info('No results found that need bytes synchronization.'); + return 0; } diff --git a/app/Filament/Resources/ResultResource.php b/app/Filament/Resources/ResultResource.php index b574a2625..6197d0b67 100644 --- a/app/Filament/Resources/ResultResource.php +++ b/app/Filament/Resources/ResultResource.php @@ -35,7 +35,6 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Facades\Auth; use Illuminate\Support\HtmlString; -use Illuminate\Support\Number as LaravelNumber; class ResultResource extends Resource { From 7022376b67d32cf45ba5b8f5aa2500d53f2e06f7 Mon Sep 17 00:00:00 2001 From: Alex Justesen <1144087+alexjustesen@users.noreply.github.com> Date: Mon, 28 Jul 2025 09:00:19 -0400 Subject: [PATCH 05/18] Refactor downloaded and uploaded bytes fields to use direct properties from Result model --- app/Actions/Influxdb/v2/BuildPointData.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/Actions/Influxdb/v2/BuildPointData.php b/app/Actions/Influxdb/v2/BuildPointData.php index 7b2f0b3da..fb7e3e643 100644 --- a/app/Actions/Influxdb/v2/BuildPointData.php +++ b/app/Actions/Influxdb/v2/BuildPointData.php @@ -50,8 +50,8 @@ public function handle(Result $result): Point ->addField('upload_latency_avg', Number::castToType(Arr::get($result->data, 'upload.latency.iqm'), 'float')) ->addField('upload_latency_high', Number::castToType(Arr::get($result->data, 'upload.latency.high'), 'float')) ->addField('upload_latency_low', Number::castToType(Arr::get($result->data, 'upload.latency.low'), 'float')) - ->addField('downloaded_bytes', Number::castToType(Arr::get($result->data, 'downloaded_bytes'), 'float')) - ->addField('uploaded_bytes', Number::castToType(Arr::get($result->data, 'uploaded_bytes'), 'float')) + ->addField('downloaded_bytes', Number::castToType($result->downloaded_bytes, 'float')) + ->addField('uploaded_bytes', Number::castToType($result->uploaded_bytes, 'float')) ->addField('packet_loss', Number::castToType(Arr::get($result->data, 'packetLoss'), 'float')) ->addField('log_message', Arr::get($result->data, 'message')); From 6b767d8e949975d0f5b41a810993cf00cdaca34f Mon Sep 17 00:00:00 2001 From: Alex Justesen <1144087+alexjustesen@users.noreply.github.com> Date: Mon, 28 Jul 2025 09:10:03 -0400 Subject: [PATCH 06/18] Add fileSizeToBytes method and corresponding tests for file size conversion --- app/Helpers/Number.php | 34 +++++++++++++++++++ tests/Unit/NumberTest.php | 71 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+) create mode 100644 tests/Unit/NumberTest.php diff --git a/app/Helpers/Number.php b/app/Helpers/Number.php index b191632a2..f4bf33fc2 100644 --- a/app/Helpers/Number.php +++ b/app/Helpers/Number.php @@ -79,4 +79,38 @@ public static function toBitRate(int|float $bits, int $precision = 0, ?int $maxP return sprintf('%s %s', static::format($bits, $precision, $maxPrecision), $units[$i]); } + + /** + * Convert a file size string (e.g., "100 MB", "1 TB") to bytes. + */ + public static function fileSizeToBytes(string $fileSize): int + { + $fileSize = trim($fileSize); + + // Extract the numeric value and unit + if (!preg_match('/^(\d+(?:\.\d+)?)\s*([KMGTPEZY]?B)$/i', $fileSize, $matches)) { + throw new \InvalidArgumentException("Invalid file size format: {$fileSize}"); + } + + $value = (float) $matches[1]; + $unit = strtoupper($matches[2]); + + $multipliers = [ + 'B' => 1, + 'KB' => 1000, + 'MB' => 1000 ** 2, + 'GB' => 1000 ** 3, + 'TB' => 1000 ** 4, + 'PB' => 1000 ** 5, + 'EB' => 1000 ** 6, + 'ZB' => 1000 ** 7, + 'YB' => 1000 ** 8, + ]; + + if (!isset($multipliers[$unit])) { + throw new \InvalidArgumentException("Unsupported unit: {$unit}"); + } + + return (int) ($value * $multipliers[$unit]); + } } diff --git a/tests/Unit/NumberTest.php b/tests/Unit/NumberTest.php new file mode 100644 index 000000000..e7a3c520e --- /dev/null +++ b/tests/Unit/NumberTest.php @@ -0,0 +1,71 @@ +toBe(100); + expect(Number::fileSizeToBytes('1 KB'))->toBe(1000); + expect(Number::fileSizeToBytes('1 MB'))->toBe(1000000); + expect(Number::fileSizeToBytes('1 GB'))->toBe(1000000000); + expect(Number::fileSizeToBytes('1 TB'))->toBe(1000000000000); + expect(Number::fileSizeToBytes('1 PB'))->toBe(1000000000000000); +}); + +test('can convert file size strings with decimals to bytes', function () { + expect(Number::fileSizeToBytes('1.5 KB'))->toBe(1500); + expect(Number::fileSizeToBytes('2.5 MB'))->toBe(2500000); + expect(Number::fileSizeToBytes('0.5 GB'))->toBe(500000000); + expect(Number::fileSizeToBytes('1.25 TB'))->toBe(1250000000000); +}); + +test('handles case insensitive units', function () { + expect(Number::fileSizeToBytes('100 mb'))->toBe(100000000); + expect(Number::fileSizeToBytes('1 Gb'))->toBe(1000000000); + expect(Number::fileSizeToBytes('500 KB'))->toBe(500000); + expect(Number::fileSizeToBytes('2 tb'))->toBe(2000000000000); +}); + +test('handles whitespace variations', function () { + expect(Number::fileSizeToBytes('100MB'))->toBe(100000000); + expect(Number::fileSizeToBytes(' 1 GB '))->toBe(1000000000); + expect(Number::fileSizeToBytes('500 KB'))->toBe(500000); +}); + +test('handles large units correctly', function () { + // Use string comparison for very large numbers to avoid PHP integer overflow + expect(Number::fileSizeToBytes('1 EB'))->toBe(1000000000000000000); + + // For ZB and YB, we'll test smaller values due to PHP integer limits + expect(Number::fileSizeToBytes('1 PB'))->toBe(1000000000000000); +}); + +test('throws exception for invalid format', function () { + expect(fn() => Number::fileSizeToBytes('invalid')) + ->toThrow(InvalidArgumentException::class, 'Invalid file size format: invalid'); + + expect(fn() => Number::fileSizeToBytes('100')) + ->toThrow(InvalidArgumentException::class, 'Invalid file size format: 100'); + + expect(fn() => Number::fileSizeToBytes('MB')) + ->toThrow(InvalidArgumentException::class, 'Invalid file size format: MB'); + + // XB is not a valid unit according to our regex pattern + expect(fn() => Number::fileSizeToBytes('100 XB')) + ->toThrow(InvalidArgumentException::class, 'Invalid file size format: 100 XB'); +}); + +test('handles edge cases', function () { + expect(Number::fileSizeToBytes('0 B'))->toBe(0); + expect(Number::fileSizeToBytes('0 MB'))->toBe(0); + expect(Number::fileSizeToBytes('1 B'))->toBe(1); +}); + +test('handles realistic file sizes', function () { + // Common file sizes + expect(Number::fileSizeToBytes('5 MB'))->toBe(5000000); + expect(Number::fileSizeToBytes('100 MB'))->toBe(100000000); + expect(Number::fileSizeToBytes('4.7 GB'))->toBe(4700000000); + expect(Number::fileSizeToBytes('25 GB'))->toBe(25000000000); + expect(Number::fileSizeToBytes('1.5 TB'))->toBe(1500000000000); +}); From 65f12abae348d187c862409a0f4c584d5de67846 Mon Sep 17 00:00:00 2001 From: Alex Justesen <1144087+alexjustesen@users.noreply.github.com> Date: Mon, 28 Jul 2025 09:13:17 -0400 Subject: [PATCH 07/18] add data_cap config --- config/speedtest.php | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/config/speedtest.php b/config/speedtest.php index 870304753..7b59beede 100644 --- a/config/speedtest.php +++ b/config/speedtest.php @@ -21,16 +21,19 @@ /** * Speedtest settings. */ + 'schedule' => env('SPEEDTEST_SCHEDULE', false), 'servers' => env('SPEEDTEST_SERVERS'), 'blocked_servers' => env('SPEEDTEST_BLOCKED_SERVERS'), - 'interface' => env('SPEEDTEST_INTERFACE'), - 'checkinternet_url' => env('SPEEDTEST_CHECKINTERNET_URL', 'https://icanhazip.com'), + 'data_cap' => env('SPEEDTEST_DATA_CAP'), + + 'interface' => env('SPEEDTEST_INTERFACE'), + /** * IP filtering settings. From 2d99b061f968c264bdcf0c927f989997abda9339 Mon Sep 17 00:00:00 2001 From: Alex Justesen <1144087+alexjustesen@users.noreply.github.com> Date: Thu, 31 Jul 2025 19:30:17 -0400 Subject: [PATCH 08/18] removed duplicate migration --- ...7_28_124048_add_bytes_to_results_table.php | 27 ------------------- 1 file changed, 27 deletions(-) delete mode 100644 database/migrations/2025_07_28_124048_add_bytes_to_results_table.php diff --git a/database/migrations/2025_07_28_124048_add_bytes_to_results_table.php b/database/migrations/2025_07_28_124048_add_bytes_to_results_table.php deleted file mode 100644 index 01de010e8..000000000 --- a/database/migrations/2025_07_28_124048_add_bytes_to_results_table.php +++ /dev/null @@ -1,27 +0,0 @@ -unsignedBigInteger('downloaded_bytes')->nullable()->after('download'); - $table->unsignedBigInteger('uploaded_bytes')->nullable()->after('upload'); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - // - } -}; From fd56a24c7677b53d0acc16a9cc5c447d182940b0 Mon Sep 17 00:00:00 2001 From: Alex Justesen <1144087+alexjustesen@users.noreply.github.com> Date: Thu, 31 Jul 2025 19:32:18 -0400 Subject: [PATCH 09/18] removed duplicate resource fields --- app/Http/Resources/V1/ResultResource.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/Http/Resources/V1/ResultResource.php b/app/Http/Resources/V1/ResultResource.php index 34e376cf0..e00710297 100644 --- a/app/Http/Resources/V1/ResultResource.php +++ b/app/Http/Resources/V1/ResultResource.php @@ -21,9 +21,7 @@ public function toArray(Request $request): array 'service' => $this->service, 'ping' => $this->ping, 'download' => $this->download, - 'downloaded_bytes' => $this->downloaded_bytes, 'upload' => $this->upload, - 'uploaded_bytes' => $this->uploaded_bytes, 'download_bits' => $this->when($this->download, fn (): int|float => Bitrate::bytesToBits($this->download)), 'upload_bits' => $this->when($this->upload, fn (): int|float => Bitrate::bytesToBits($this->upload)), 'download_bits_human' => $this->when($this->download, fn (): string => Bitrate::formatBits(Bitrate::bytesToBits($this->download)).'ps'), From 762150ecf45e1c90410c88b1ba2ba6587cbccac9 Mon Sep 17 00:00:00 2001 From: Alex Justesen <1144087+alexjustesen@users.noreply.github.com> Date: Thu, 31 Jul 2025 20:09:39 -0400 Subject: [PATCH 10/18] add quota settings --- config/speedtest.php | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/config/speedtest.php b/config/speedtest.php index d749ad5ad..519fe71e6 100644 --- a/config/speedtest.php +++ b/config/speedtest.php @@ -30,10 +30,6 @@ 'checkinternet_url' => env('SPEEDTEST_CHECKINTERNET_URL', 'https://icanhazip.com'), - 'data_cap' => env('SPEEDTEST_DATA_CAP'), - - 'interface' => env('SPEEDTEST_INTERFACE'), - /** * IP filtering settings. @@ -44,6 +40,19 @@ 'skip_ips' => env('SPEEDTEST_SKIP_IPS', ''), + /** + * Quota settings. + */ + + 'quota_enabled' => (bool) env('SPEEDTEST_QUOTA_ENABLED', false), // enable quota tracking + + 'quota_size' => (string) env('SPEEDTEST_QUOTA_SIZE', '500G'), // like 500G or 1T + + 'quota_period' => (string) env('SPEEDTEST_QUOTA_PERIOD', 'month'), // like month or week + + 'quota_reset_day' => (int) env('SPEEDTEST_QUOTA_RESET_DAY', 0), // day of the month or week + + /** * Threshold settings. */ From 4c07dcb099a932eb23edadaa8cf5681cd0607808 Mon Sep 17 00:00:00 2001 From: Alex Justesen <1144087+alexjustesen@users.noreply.github.com> Date: Thu, 31 Jul 2025 20:11:43 -0400 Subject: [PATCH 11/18] add interface setting to speedtest configuration --- config/speedtest.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/config/speedtest.php b/config/speedtest.php index 519fe71e6..392080e32 100644 --- a/config/speedtest.php +++ b/config/speedtest.php @@ -28,6 +28,8 @@ 'blocked_servers' => env('SPEEDTEST_BLOCKED_SERVERS'), + 'interface' => env('SPEEDTEST_INTERFACE'), + 'checkinternet_url' => env('SPEEDTEST_CHECKINTERNET_URL', 'https://icanhazip.com'), From c25d112875824f5cf1a9aefe0d82f6c25a0ca541 Mon Sep 17 00:00:00 2001 From: Alex Justesen <1144087+alexjustesen@users.noreply.github.com> Date: Thu, 31 Jul 2025 20:12:37 -0400 Subject: [PATCH 12/18] remove SyncResultBytes command as it is no longer needed --- app/Console/Commands/SyncResultBytes.php | 85 ------------------------ 1 file changed, 85 deletions(-) delete mode 100644 app/Console/Commands/SyncResultBytes.php diff --git a/app/Console/Commands/SyncResultBytes.php b/app/Console/Commands/SyncResultBytes.php deleted file mode 100644 index b2f08ad37..000000000 --- a/app/Console/Commands/SyncResultBytes.php +++ /dev/null @@ -1,85 +0,0 @@ -where(function ($query) { - $query->whereNull('downloaded_bytes') - ->orWhereNull('uploaded_bytes'); - }) - ->get(); - - if ($results->isEmpty()) { - $this->info('No results found that need bytes synchronization.'); - - return 0; - } - - $this->info("Found {$results->count()} results to sync."); - - $progressBar = $this->output->createProgressBar($results->count()); - $progressBar->start(); - - $updated = 0; - - foreach ($results as $result) { - $downloadBytes = Arr::get($result->data, 'download.bytes'); - $uploadBytes = Arr::get($result->data, 'upload.bytes'); - - $needsUpdate = false; - $updates = []; - - // Check if download bytes need to be updated - if ($downloadBytes !== null && $result->downloaded_bytes === null) { - $updates['downloaded_bytes'] = $downloadBytes; - $needsUpdate = true; - } - - // Check if upload bytes need to be updated - if ($uploadBytes !== null && $result->uploaded_bytes === null) { - $updates['uploaded_bytes'] = $uploadBytes; - $needsUpdate = true; - } - - if ($needsUpdate) { - $result->update($updates); - $updated++; - } - - $progressBar->advance(); - } - - $progressBar->finish(); - $this->newLine(); - - $this->info("Sync completed. {$updated} results updated successfully."); - - return 0; - } -} From 63b5c90a36ba9ea6e5879b5be9ff09adc7b929bd Mon Sep 17 00:00:00 2001 From: Alex Justesen <1144087+alexjustesen@users.noreply.github.com> Date: Thu, 31 Jul 2025 20:16:01 -0400 Subject: [PATCH 13/18] fix: correct quota size format in speedtest configuration --- config/speedtest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/speedtest.php b/config/speedtest.php index 392080e32..0d49b8c88 100644 --- a/config/speedtest.php +++ b/config/speedtest.php @@ -48,7 +48,7 @@ 'quota_enabled' => (bool) env('SPEEDTEST_QUOTA_ENABLED', false), // enable quota tracking - 'quota_size' => (string) env('SPEEDTEST_QUOTA_SIZE', '500G'), // like 500G or 1T + 'quota_size' => (string) env('SPEEDTEST_QUOTA_SIZE', '500 GB'), // like 500 GB or 1 TB 'quota_period' => (string) env('SPEEDTEST_QUOTA_PERIOD', 'month'), // like month or week From 9f27fc1a15da174c540dc6dc57e45e29be989937 Mon Sep 17 00:00:00 2001 From: Alex Justesen <1144087+alexjustesen@users.noreply.github.com> Date: Thu, 31 Jul 2025 21:50:53 -0400 Subject: [PATCH 14/18] add quota settings --- app/Settings/QuotaSettings.php | 23 +++++++++++++++++++ config/speedtest.php | 4 ++-- ...025_08_01_004550_create_quota_settings.php | 15 ++++++++++++ 3 files changed, 40 insertions(+), 2 deletions(-) create mode 100644 app/Settings/QuotaSettings.php create mode 100644 database/settings/2025_08_01_004550_create_quota_settings.php diff --git a/app/Settings/QuotaSettings.php b/app/Settings/QuotaSettings.php new file mode 100644 index 000000000..28dacb213 --- /dev/null +++ b/app/Settings/QuotaSettings.php @@ -0,0 +1,23 @@ + (string) env('SPEEDTEST_QUOTA_SIZE', '500 GB'), // like 500 GB or 1 TB - 'quota_period' => (string) env('SPEEDTEST_QUOTA_PERIOD', 'month'), // like month or week + 'quota_period' => (string) env('SPEEDTEST_QUOTA_PERIOD', 'month'), // like day, month or week - 'quota_reset_day' => (int) env('SPEEDTEST_QUOTA_RESET_DAY', 0), // day of the month or week + 'quota_reset_day' => (int) env('SPEEDTEST_QUOTA_RESET_DAY', 1), // day of the month or week /** diff --git a/database/settings/2025_08_01_004550_create_quota_settings.php b/database/settings/2025_08_01_004550_create_quota_settings.php new file mode 100644 index 000000000..487839a11 --- /dev/null +++ b/database/settings/2025_08_01_004550_create_quota_settings.php @@ -0,0 +1,15 @@ +migrator->add('quota.enabled', config('speedtest.quota_enabled')); // Enable quota tracking + $this->migrator->add('quota.size', config('speedtest.quota_size')); // Quota size, e.g., '500 GB' or '1 TB' + $this->migrator->add('quota.period', config('speedtest.quota_period')); // Options: day, week, month + $this->migrator->add('quota.reset_day', config('speedtest.quota_reset_day')); // Day of the month or week for quota reset + $this->migrator->add('quota.used', 0); // Track used quota, default to 0 + } +}; From a6b188e7e9f4f0ba862fe1eb4e7f8805ceb9ed45 Mon Sep 17 00:00:00 2001 From: Alex Justesen <1144087+alexjustesen@users.noreply.github.com> Date: Thu, 31 Jul 2025 21:51:14 -0400 Subject: [PATCH 15/18] started work on quota settings page --- app/Filament/Pages/Settings/QuotaPage.php | 111 ++++++++++++++++++ .../Pages/Settings/ThresholdsPage.php | 2 +- app/Filament/Resources/ApiTokenResource.php | 2 + app/Filament/Resources/UserResource.php | 2 +- 4 files changed, 115 insertions(+), 2 deletions(-) create mode 100644 app/Filament/Pages/Settings/QuotaPage.php diff --git a/app/Filament/Pages/Settings/QuotaPage.php b/app/Filament/Pages/Settings/QuotaPage.php new file mode 100644 index 000000000..5938264a3 --- /dev/null +++ b/app/Filament/Pages/Settings/QuotaPage.php @@ -0,0 +1,111 @@ +is_admin; + } + + public static function shouldRegisterNavigation(): bool + { + return Auth::check() && Auth::user()->is_admin; + } + + public function form(Form $form): Form + { + return $form + ->schema([ + Grid::make([ + 'default' => 1, + ])->schema([ + Section::make('Settings') + ->headerActions([ + Action::make('Documentation') + ->icon('tabler-books') + ->iconButton() + ->color('gray') + ->url('https://docs.speedtest-tracker.dev/') + ->openUrlInNewTab(), + ]) + ->schema([ + Toggle::make('enabled') + ->label('Enable quota tracking') + ->helperText(fn (): ?string => config('speedtest.quota_enabled') ? 'Quota is being configured using environment variables, UI control has been disabled.' : null) + ->reactive() + ->disabled(fn (): bool => config('speedtest.quota_enabled')) + ->columnSpanFull(), + + TextInput::make('size') + ->helperText('Specify the quota size, e.g., "500 GB" or "1 TB".') + ->required() + ->disabled(fn (): bool => config('speedtest.quota_enabled')), + + Select::make('period') + ->options([ + 'day' => 'Day', + 'week' => 'Week', + 'month' => 'Month', + ]) + ->required() + ->disabled(fn (): bool => config('speedtest.quota_enabled')), + + TextInput::make('reset_day') + ->helperText('Specify the day of the month or day of the week for the quota to reset.') + ->required() + ->minValue(0) + ->maxValue(31) + ->numeric() + ->disabled(fn (): bool => config('speedtest.quota_enabled')), + ]), + ]), + ]); + } + + public function getMaxContentWidth(): MaxWidth + { + return MaxWidth::TwoExtraLarge; + } + + public static function getNavigationBadge(): ?string + { + return config('speedtest.quota_enabled') ? 'Disabled' : null; + } + + public static function getNavigationBadgeColor(): ?string + { + return config('speedtest.quota_enabled') ? 'gray' : null; + } + + public static function getNavigationBadgeTooltip(): ?string + { + return config('speedtest.quota_enabled') ? 'Quota is enabled as .env variable' : null; + } +} diff --git a/app/Filament/Pages/Settings/ThresholdsPage.php b/app/Filament/Pages/Settings/ThresholdsPage.php index 3dc749bd7..1858f6887 100644 --- a/app/Filament/Pages/Settings/ThresholdsPage.php +++ b/app/Filament/Pages/Settings/ThresholdsPage.php @@ -20,7 +20,7 @@ class ThresholdsPage extends SettingsPage protected static ?string $navigationGroup = 'Settings'; - protected static ?int $navigationSort = 4; + protected static ?int $navigationSort = 5; protected static ?string $title = 'Thresholds'; diff --git a/app/Filament/Resources/ApiTokenResource.php b/app/Filament/Resources/ApiTokenResource.php index 3d71df012..7bded7c90 100644 --- a/app/Filament/Resources/ApiTokenResource.php +++ b/app/Filament/Resources/ApiTokenResource.php @@ -29,6 +29,8 @@ class ApiTokenResource extends Resource protected static ?string $navigationGroup = 'Settings'; + protected static ?int $navigationSort = 1; + protected static ?string $label = 'API Token'; protected static ?string $pluralLabel = 'API Tokens'; diff --git a/app/Filament/Resources/UserResource.php b/app/Filament/Resources/UserResource.php index 90bb95540..29b57b7ba 100644 --- a/app/Filament/Resources/UserResource.php +++ b/app/Filament/Resources/UserResource.php @@ -29,7 +29,7 @@ class UserResource extends Resource protected static ?string $navigationGroup = 'Settings'; - protected static ?int $navigationSort = 4; + protected static ?int $navigationSort = 6; public static function form(Form $form): Form { From 5de7741331eef9693ead2ba97ccc31daa38c4266 Mon Sep 17 00:00:00 2001 From: Alex Justesen <1144087+alexjustesen@users.noreply.github.com> Date: Thu, 31 Jul 2025 21:51:40 -0400 Subject: [PATCH 16/18] scaffold command and job --- app/Console/Commands/ResetQuota.php | 30 +++++++++++++++++++++++++++++ app/Jobs/ResetQuota.php | 27 ++++++++++++++++++++++++++ 2 files changed, 57 insertions(+) create mode 100644 app/Console/Commands/ResetQuota.php create mode 100644 app/Jobs/ResetQuota.php diff --git a/app/Console/Commands/ResetQuota.php b/app/Console/Commands/ResetQuota.php new file mode 100644 index 000000000..eb67df420 --- /dev/null +++ b/app/Console/Commands/ResetQuota.php @@ -0,0 +1,30 @@ + Date: Tue, 5 Aug 2025 06:55:46 -0400 Subject: [PATCH 17/18] remove: delete unused ResetQuota job class --- app/Jobs/ResetQuota.php | 27 --------------------------- 1 file changed, 27 deletions(-) delete mode 100644 app/Jobs/ResetQuota.php diff --git a/app/Jobs/ResetQuota.php b/app/Jobs/ResetQuota.php deleted file mode 100644 index 869aaf8a4..000000000 --- a/app/Jobs/ResetQuota.php +++ /dev/null @@ -1,27 +0,0 @@ - Date: Tue, 5 Aug 2025 06:55:52 -0400 Subject: [PATCH 18/18] refactor: enhance ResetQuota command with confirmation option and improve description --- app/Console/Commands/ResetQuota.php | 18 +++++++++++++++--- app/Settings/QuotaSettings.php | 2 +- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/app/Console/Commands/ResetQuota.php b/app/Console/Commands/ResetQuota.php index eb67df420..f908c84ef 100644 --- a/app/Console/Commands/ResetQuota.php +++ b/app/Console/Commands/ResetQuota.php @@ -2,6 +2,7 @@ namespace App\Console\Commands; +use App\Settings\QuotaSettings; use Illuminate\Console\Command; class ResetQuota extends Command @@ -11,20 +12,31 @@ class ResetQuota extends Command * * @var string */ - protected $signature = 'app:reset-quota'; + protected $signature = 'app:reset-quota + {--force : Force the reset without confirmation}'; /** * The console command description. * * @var string */ - protected $description = 'Command description'; + protected $description = 'Reset the used quota to zero.'; /** * Execute the console command. */ public function handle() { - // + if (! $this->option('force')) { + if (! $this->confirm('Do you want to continue?')) { + $this->fail('Command cancelled.'); + } + } + + $settings = new QuotaSettings(); + $settings->used = 0; + $settings->save(); + + $this->info('Quota has been reset successfully.'); } } diff --git a/app/Settings/QuotaSettings.php b/app/Settings/QuotaSettings.php index 28dacb213..00e0c03a2 100644 --- a/app/Settings/QuotaSettings.php +++ b/app/Settings/QuotaSettings.php @@ -20,4 +20,4 @@ public static function group(): string { return 'quota'; } -} \ No newline at end of file +}