diff --git a/app/Console/Commands/ResetQuota.php b/app/Console/Commands/ResetQuota.php new file mode 100644 index 000000000..f908c84ef --- /dev/null +++ b/app/Console/Commands/ResetQuota.php @@ -0,0 +1,42 @@ +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/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 { 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/app/Settings/QuotaSettings.php b/app/Settings/QuotaSettings.php new file mode 100644 index 000000000..00e0c03a2 --- /dev/null +++ b/app/Settings/QuotaSettings.php @@ -0,0 +1,23 @@ + env('SPEEDTEST_SCHEDULE', false), 'servers' => env('SPEEDTEST_SERVERS'), @@ -41,6 +42,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', '500 GB'), // like 500 GB or 1 TB + + 'quota_period' => (string) env('SPEEDTEST_QUOTA_PERIOD', 'month'), // like day, month or week + + 'quota_reset_day' => (int) env('SPEEDTEST_QUOTA_RESET_DAY', 1), // day of the month or week + + /** * Threshold settings. */ 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 + } +}; 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); +});