Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
9c723d6
Add speedtest result bytes to results table (#2150)
ilteoood Apr 21, 2025
b8966a9
Merge branch 'main' into 2146-feature-implement-a-data-cap
alexjustesen Jul 28, 2025
5323645
Add migration to include downloaded and uploaded bytes in results table
alexjustesen Jul 28, 2025
f59f32f
Add SyncResultBytes command to synchronize downloaded and uploaded by…
alexjustesen Jul 28, 2025
00404e8
lint
alexjustesen Jul 28, 2025
7022376
Refactor downloaded and uploaded bytes fields to use direct propertie…
alexjustesen Jul 28, 2025
6b767d8
Add fileSizeToBytes method and corresponding tests for file size conv…
alexjustesen Jul 28, 2025
65f12ab
add data_cap config
alexjustesen Jul 28, 2025
8f19d96
Merge branch 'main' into 2146-feature-implement-a-data-cap
alexjustesen Jul 31, 2025
2d99b06
removed duplicate migration
alexjustesen Jul 31, 2025
fd56a24
removed duplicate resource fields
alexjustesen Jul 31, 2025
762150e
add quota settings
alexjustesen Aug 1, 2025
4c07dcb
add interface setting to speedtest configuration
alexjustesen Aug 1, 2025
c25d112
remove SyncResultBytes command as it is no longer needed
alexjustesen Aug 1, 2025
63b5c90
fix: correct quota size format in speedtest configuration
alexjustesen Aug 1, 2025
9f27fc1
add quota settings
alexjustesen Aug 1, 2025
a6b188e
started work on quota settings page
alexjustesen Aug 1, 2025
5de7741
scaffold command and job
alexjustesen Aug 1, 2025
e87bdd4
remove: delete unused ResetQuota job class
alexjustesen Aug 5, 2025
562596d
refactor: enhance ResetQuota command with confirmation option and imp…
alexjustesen Aug 5, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions app/Console/Commands/ResetQuota.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

namespace App\Console\Commands;

use App\Settings\QuotaSettings;
use Illuminate\Console\Command;

class ResetQuota extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'app:reset-quota
{--force : Force the reset without confirmation}';

/**
* The console command description.
*
* @var string
*/
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.');
}
}
111 changes: 111 additions & 0 deletions app/Filament/Pages/Settings/QuotaPage.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
<?php

namespace App\Filament\Pages\Settings;

use App\Settings\QuotaSettings;
use Filament\Forms;
use Filament\Forms\Components\Actions\Action;
use Filament\Forms\Components\Grid;
use Filament\Forms\Components\Section;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Form;
use Filament\Pages\SettingsPage;
use Filament\Support\Enums\MaxWidth;
use Illuminate\Support\Facades\Auth;

class QuotaPage extends SettingsPage
{
protected static ?string $navigationIcon = 'tabler-antenna-bars-3';

protected static ?string $navigationGroup = 'Settings';

protected static ?int $navigationSort = 4;

protected static ?string $title = 'Quota';

protected static ?string $navigationLabel = 'Quota';

protected static string $settings = QuotaSettings::class;

public static function canAccess(): bool
{
return Auth::check() && Auth::user()->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;
}
}
2 changes: 1 addition & 1 deletion app/Filament/Pages/Settings/ThresholdsPage.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
2 changes: 2 additions & 0 deletions app/Filament/Resources/ApiTokenResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
2 changes: 1 addition & 1 deletion app/Filament/Resources/UserResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
34 changes: 34 additions & 0 deletions app/Helpers/Number.php
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
}
}
23 changes: 23 additions & 0 deletions app/Settings/QuotaSettings.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

namespace App\Settings;

use Spatie\LaravelSettings\Settings;

class QuotaSettings extends Settings
{
public bool $enabled; // Enable quota tracking

public string $size; // Quota size, e.g., '500 GB' or '1 TB'

public string $period; // Options: day, week, month

public int $reset_day; // Day of the month or week for quota reset

public int $used; // Track used quota, default to 0

public static function group(): string
{
return 'quota';
}
}
14 changes: 14 additions & 0 deletions config/speedtest.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
/**
* Speedtest settings.
*/

'schedule' => env('SPEEDTEST_SCHEDULE', false),

'servers' => env('SPEEDTEST_SERVERS'),
Expand All @@ -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.
*/
Expand Down
15 changes: 15 additions & 0 deletions database/settings/2025_08_01_004550_create_quota_settings.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

use Spatie\LaravelSettings\Migrations\SettingsMigration;

return new class extends SettingsMigration
{
public function up(): void
{
$this->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
}
};
71 changes: 71 additions & 0 deletions tests/Unit/NumberTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<?php

use App\Helpers\Number;

test('can convert file size strings to bytes', function () {
// Test basic units
expect(Number::fileSizeToBytes('100 B'))->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);
});