From 5869a833890c177c5f4da3ff4eac5c7471ef0f0a Mon Sep 17 00:00:00 2001 From: sveng93 Date: Thu, 20 Nov 2025 22:56:23 +0100 Subject: [PATCH 01/15] add model and ui --- app/Actions/CheckCronOverlap.php | 55 +++++++ app/Actions/ExplainCronExpression.php | 36 ++++ app/Actions/UpdateNextRun.php | 57 +++++++ .../Schedules/Pages/CreateSchedule.php | 11 ++ .../Schedules/Pages/EditSchedule.php | 19 +++ .../Schedules/Pages/ListSchedules.php | 19 +++ .../Resources/Schedules/ScheduleResource.php | 65 ++++++++ .../Schedules/Schemas/ScheduleForm.php | 155 ++++++++++++++++++ .../Schedules/Tables/ScheduleTable.php | 148 +++++++++++++++++ app/Helpers/Cron.php | 46 ++++++ app/Models/Result.php | 5 + app/Models/Schedule.php | 73 +++++++++ app/Models/Traits/HasOwner.php | 69 ++++++++ app/Observers/ScheduleObserver.php | 65 ++++++++ app/Rules/NoCronOverlap.php | 53 ++++++ database/factories/ScheduleFactory.php | 23 +++ ...5_11_20_205859_create_schedules_table..php | 40 +++++ ...05917_add_schedule_id_to_results_table.php | 26 +++ 18 files changed, 965 insertions(+) create mode 100644 app/Actions/CheckCronOverlap.php create mode 100644 app/Actions/ExplainCronExpression.php create mode 100644 app/Actions/UpdateNextRun.php create mode 100644 app/Filament/Resources/Schedules/Pages/CreateSchedule.php create mode 100644 app/Filament/Resources/Schedules/Pages/EditSchedule.php create mode 100644 app/Filament/Resources/Schedules/Pages/ListSchedules.php create mode 100644 app/Filament/Resources/Schedules/ScheduleResource.php create mode 100644 app/Filament/Resources/Schedules/Schemas/ScheduleForm.php create mode 100644 app/Filament/Resources/Schedules/Tables/ScheduleTable.php create mode 100644 app/Helpers/Cron.php create mode 100644 app/Models/Schedule.php create mode 100644 app/Models/Traits/HasOwner.php create mode 100644 app/Observers/ScheduleObserver.php create mode 100644 app/Rules/NoCronOverlap.php create mode 100644 database/factories/ScheduleFactory.php create mode 100644 database/migrations/2025_11_20_205859_create_schedules_table..php create mode 100644 database/migrations/2025_11_20_205917_add_schedule_id_to_results_table.php diff --git a/app/Actions/CheckCronOverlap.php b/app/Actions/CheckCronOverlap.php new file mode 100644 index 000000000..228e16590 --- /dev/null +++ b/app/Actions/CheckCronOverlap.php @@ -0,0 +1,55 @@ +is_active) { + + return; + } + + $cronExpression = $schedule->options['cron_expression']; + $scheduleId = $schedule->id; + + // Track overlapping schedules + $overlappingSchedules = []; + + // Find other active schedules that have cron expressions + $existingCrons = Schedule::query() + ->where('is_active', true) + ->where('id', '!=', $scheduleId) + ->get(['id', 'options']) + ->pluck('options.cron_expression', 'id'); + + // Check for overlaps with the modified schedule's cron expression + foreach ($existingCrons as $existingScheduleId => $existingCron) { + if (Cron::hasOverlap($existingCron, $cronExpression)) { + $overlappingSchedules[] = $existingScheduleId; + } + } + + // Send a notification if overlaps exist + if (count($overlappingSchedules) > 0) { + $overlapList = implode(', ', $overlappingSchedules); + + // Send a single notification for all overlaps + Notification::make() + ->title('Schedule Overlap Detected') + ->warning() + ->body("The cron expression for this schedule overlaps with the following active schedule ids: $overlapList.") + ->send(); + } + } +} diff --git a/app/Actions/ExplainCronExpression.php b/app/Actions/ExplainCronExpression.php new file mode 100644 index 000000000..090a7f21e --- /dev/null +++ b/app/Actions/ExplainCronExpression.php @@ -0,0 +1,36 @@ +explain($expression); + } +} diff --git a/app/Actions/UpdateNextRun.php b/app/Actions/UpdateNextRun.php new file mode 100644 index 000000000..108dc2ad9 --- /dev/null +++ b/app/Actions/UpdateNextRun.php @@ -0,0 +1,57 @@ +is_active) { + if ($schedule->next_run_at !== null) { + // Update without firing events + $schedule->next_run_at = null; + $schedule->save(); + } + + return; + } + + // Get the cron expression from the schedule options + $expression = data_get($schedule, 'options.cron_expression'); + + if ($expression) { + // Calculate the next run time based on the cron expression + $nextRun = $this->getNextRunAt($expression); + + // Only update if the next_run_at field is different from the calculated next run time + if ($schedule->next_run_at !== $nextRun) { + $schedule->next_run_at = $nextRun; + $schedule->save(); + } + } + + }); // End of withoutEvents closure + } + + // Calculate the next run time based on the cron expression + private function getNextRunAt(string $expression): Carbon + { + // Create a CronExpression instance from the cron expression + $cron = CronExpression::factory($expression); + + // Get the next valid run time + return Carbon::parse($cron->getNextRunDate()); + } +} diff --git a/app/Filament/Resources/Schedules/Pages/CreateSchedule.php b/app/Filament/Resources/Schedules/Pages/CreateSchedule.php new file mode 100644 index 000000000..b19550031 --- /dev/null +++ b/app/Filament/Resources/Schedules/Pages/CreateSchedule.php @@ -0,0 +1,11 @@ +is_admin; + } + + public static function shouldRegisterNavigation(): bool + { + return Auth::check() && Auth::user()->is_admin; + } + + public static function form(Schema $schema): Schema + { + return $schema->components(ScheduleForm::schema()); + } + + public static function table(Table $table): Table + { + return ScheduleTable::table($table); + } + + public static function getPages(): array + { + return [ + 'index' => ListSchedules::route('/'), + 'create' => CreateSchedule::route('/create'), + 'edit' => EditSchedule::route('/{record}/edit'), + ]; + } +} diff --git a/app/Filament/Resources/Schedules/Schemas/ScheduleForm.php b/app/Filament/Resources/Schedules/Schemas/ScheduleForm.php new file mode 100644 index 000000000..03a827569 --- /dev/null +++ b/app/Filament/Resources/Schedules/Schemas/ScheduleForm.php @@ -0,0 +1,155 @@ + 1, + 'lg' => 2, + ])->schema([ + Section::make('Details') + ->schema([ + TextInput::make('name') + ->placeholder('Enter a name for the test.') + ->maxLength(255) + ->unique(ignoreRecord: true) + ->required(), + TextInput::make('description') + ->maxLength(255), + ]) + ->columnSpan([ + 'default' => 1, + ]), + + Section::make('Settings') + ->schema([ + Toggle::make('is_active') + ->label('Active') + ->required(), + Select::make('owned_by_id') + ->label('Owner') + ->placeholder('Select an owner.') + ->relationship('ownedBy', 'name') + ->default(Auth::id()) + ->searchable(), + Select::make('type') + ->label('Type') + ->options([ + 'Ookla' => 'Ookla', + ]) + ->default('Ookla') + ->native(false) + ->required(), + TextInput::make('token') + ->helperText(new HtmlString('This is a secret token that can be used to authenticate requests to the test.')) + ->readOnly() + ->hiddenOn('create'), + ]) + ->columnSpan([ + 'default' => 1, + ]), + + Tabs::make('Options') + ->tabs([ + Tab::make('Schedule') + ->schema([ + TextInput::make('options.cron_expression') + ->placeholder('Enter a cron expression.') + ->helperText(fn (Get $get) => ExplainCronExpression::run($get('options.cron_expression'))) + ->required() + ->rules([new Cron]) + ->live(), + Placeholder::make('next_run_at') + ->label('Next Run At') + ->content(function (Get $get) { + $expression = $get('options.cron_expression'); + + if (! $expression) { + return '—'; + } + + try { + $cron = new CronExpression($expression); + + return Carbon::instance( + $cron->getNextRunDate(now(), 0, false, config('app.display_timezone')) + )->toDayDateTimeString(); + } catch (\Exception $e) { + return 'Invalid cron expression'; + } + }), + ]), + + Tab::make('Servers') + ->schema([ + Radio::make('options.server_preference') + ->options([ + 'auto' => 'Automatically select a server', + 'prefer' => 'Prefer servers from the list', + 'ignore' => 'Ignore servers from the list', + ]) + ->default('auto') + ->required() + ->live(), + + Repeater::make('options.servers') + ->schema([ + Select::make('server_id') + ->label('Server ID') + ->placeholder('Select the ID of the server.') + ->options(function (): array { + return GetOoklaSpeedtestServers::run(); + }) + ->searchable() + ->required(), + ]) + ->minItems(1) + ->maxItems(20) + ->hidden(fn (Get $get) => $get('options.server_preference') === 'auto'), + ]), + + Tab::make('Advanced') + ->schema([ + TagsInput::make('options.skip_ips') + ->label('Skip IP addresses') + ->placeholder('8.8.8.8') + ->nestedRecursiveRules([ + 'ip', + ]) + ->live() + ->helpertext('Add external IP addresses that should be skipped.'), + TextInput::make('options.interface') + ->label('Network Interface') + ->placeholder('eth0') + ->helpertext('Set the network interface to use for the test. This need to be the network interface available inside the container'), + ]), + ]) + ->columnSpan('full'), + ]), + ]; + } +} diff --git a/app/Filament/Resources/Schedules/Tables/ScheduleTable.php b/app/Filament/Resources/Schedules/Tables/ScheduleTable.php new file mode 100644 index 000000000..d75bd9d42 --- /dev/null +++ b/app/Filament/Resources/Schedules/Tables/ScheduleTable.php @@ -0,0 +1,148 @@ +columns([ + TextColumn::make('id') + ->label('ID') + ->sortable(), + TextColumn::make('token') + ->copyable() + ->toggleable(isToggledHiddenByDefault: true), + TextColumn::make('name'), + TextColumn::make('type') + ->label('Type') + ->toggleable(isToggledHiddenByDefault: false) + ->sortable(), + TextColumn::make('description') + ->toggleable(isToggledHiddenByDefault: true), + TextColumn::make('options.cron_expression') + ->label('Schedule') + ->sortable() + ->toggleable(isToggledHiddenByDefault: false) + ->formatStateUsing(fn (?string $state) => ExplainCronExpression::run($state)), + TextColumn::make('options.server_preference') + ->label('Server Preference') + ->sortable() + ->toggleable(isToggledHiddenByDefault: true) + ->formatStateUsing(function (?string $state) { + return match ($state) { + 'auto' => 'Automatic', + 'prefer' => 'Prefer Specific Servers', + 'ignore' => 'Ignore Specific Servers', + default => $state, + }; + }) + ->tooltip(fn ($record) => $record->getServerTooltip()), + IconColumn::make('is_active') + ->label('Active') + ->alignCenter() + ->toggleable(isToggledHiddenByDefault: false) + ->boolean(), + TextColumn::make('ownedBy.name') + ->toggleable(isToggledHiddenByDefault: true), + TextColumn::make('next_run_at') + ->alignEnd() + ->dateTime() + ->toggleable(isToggledHiddenByDefault: false) + ->sortable(), + TextColumn::make('created_at') + ->alignEnd() + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: false), + TextColumn::make('updated_at') + ->alignEnd() + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->filters([ + SelectFilter::make('type') + ->label('Type') + ->options(function () { + return Schedule::distinct() + ->pluck('type', 'type') + ->toArray(); + }) + ->native(false), + TernaryFilter::make('Active') + ->nullable() + ->trueLabel('Active schedules only') + ->falseLabel('Inactive schedules only') + ->queries( + true: fn (Builder $query) => $query->where('is_active', true), + false: fn (Builder $query) => $query->where('is_active', false), + blank: fn (Builder $query) => $query, + ) + ->native(false), + SelectFilter::make('options.server_preference') + ->label('Server Preference') + ->options(function () { + return Schedule::distinct() + ->get() + ->pluck('options') + ->map(function ($options) { + return $options['server_preference'] ?? null; + }) + ->filter() + ->unique() + ->mapWithKeys(function ($value) { + return [ + $value => match ($value) { + 'auto' => 'Automatic', + 'prefer' => 'Prefer Specific Servers', + 'ignore' => 'Ignore Specific Servers', + default => $value, + }, + ]; + }) + ->toArray(); + }) + ->native(false), + ]) + ->actions([ + ActionGroup::make([ + EditAction::make(), + Action::make('changeScheduleStatus') + ->label('Change Schedule Status') + ->action(function ($record) { + $record->update(['is_active' => ! $record->is_active]); + }) + ->icon('heroicon-c-arrow-path'), + Action::make('viewResults') + ->label('View Results') + ->action(function ($record) { + return redirect()->route('filament.admin.resources.results.index', [ + 'tableFilters[schedule_id][values][0]' => $record->id, + ]); + }) + ->icon('heroicon-s-eye'), + DeleteAction::make(), + ]), + ]) + ->bulkActions([ + DeleteBulkAction::make(), + ]) + ->poll('60s'); + } +} diff --git a/app/Helpers/Cron.php b/app/Helpers/Cron.php new file mode 100644 index 000000000..8519324f9 --- /dev/null +++ b/app/Helpers/Cron.php @@ -0,0 +1,46 @@ +addHours($checkHours); + + $schedule1 = []; + $schedule2 = []; + + $current = clone $startTime; + + // Get all run times for both cron expressions + while ($current <= $endTime) { + if ($cron1Expression->getNextRunDate($current, 0) <= $endTime) { + $schedule1[] = $cron1Expression->getNextRunDate($current)->format('Y-m-d H:i'); + } + + if ($cron2Expression->getNextRunDate($current, 0) <= $endTime) { + $schedule2[] = $cron2Expression->getNextRunDate($current)->format('Y-m-d H:i'); + } + + $current->addMinute(); + } + + // Check for any common execution times + return ! empty(array_intersect($schedule1, $schedule2)); + } +} diff --git a/app/Models/Result.php b/app/Models/Result.php index 3ac4caa77..f06ad6f37 100644 --- a/app/Models/Result.php +++ b/app/Models/Result.php @@ -38,6 +38,11 @@ protected function casts(): array ]; } + public function schedule() + { + return $this->belongsTo(Schedule::class); + } + /** * Get the prunable model query. */ diff --git a/app/Models/Schedule.php b/app/Models/Schedule.php new file mode 100644 index 000000000..1293e2d4b --- /dev/null +++ b/app/Models/Schedule.php @@ -0,0 +1,73 @@ + + */ + protected function casts(): array + { + return [ + 'options' => 'array', + 'is_active' => 'boolean', + 'next_run_at' => 'datetime', + 'thresholds' => 'array', + ]; + } + + public function getServerTooltip(): ?string + { + $preference = $this->options['server_preference'] ?? 'auto'; + + if ($preference === 'auto') { + return null; + } + + $servers = collect($this->options['servers'] ?? []) + ->pluck('server_id'); + + $lookup = GetOoklaSpeedtestServers::run(); + + return $servers + ->map(fn ($id) => $lookup[$id] ?? "Unknown ($id)") + ->implode(', '); + } + + public function getThresholdTooltip(): ?string + { + $thresholds = $this->thresholds; + + if (! ($thresholds['enabled'] ?? false)) { + return null; + } + + return sprintf( + "Download: %s Mbps\nUpload: %s Mbps\nPing: %s ms", + $thresholds['download'] ?? '—', + $thresholds['upload'] ?? '—', + $thresholds['ping'] ?? '—', + ); + } +} diff --git a/app/Models/Traits/HasOwner.php b/app/Models/Traits/HasOwner.php new file mode 100644 index 000000000..786a99703 --- /dev/null +++ b/app/Models/Traits/HasOwner.php @@ -0,0 +1,69 @@ +belongsTo(User::class, 'owned_by_id'); + } + + /** + * Owner of the model, alias for `ownedBy()` method. + */ + public function owner(): BelongsTo + { + return $this->ownedBy(); + } + + /** + * Determines if the model has an owner. + */ + public function hasOwner(): bool + { + return ! is_null($this->owned_by_id); + } + + /** + * Checks if the model is owned by the given user. + */ + public function isOwnedBy(User $owner): bool + { + if (! $this->hasOwner()) { + return false; + } + + return $owner->id === $this->owned_by_id; + } + + /** + * Scope a query to only include models by the owner. + */ + public function scopeWhereOwnedBy(Builder $query, User $owner): Builder + { + return $query->where('owned_by_id', '=', $owner->id); + } + + /** + * Scope a query to exclude models by owner. + */ + public function scopeWhereNotOwnedBy(Builder $query, User $owner): Builder + { + return $query->where('owned_by_id', '!=', $owner->id); + } +} diff --git a/app/Observers/ScheduleObserver.php b/app/Observers/ScheduleObserver.php new file mode 100644 index 000000000..0e75daab8 --- /dev/null +++ b/app/Observers/ScheduleObserver.php @@ -0,0 +1,65 @@ +exists()); + + $schedule->token = $token; + } + + /** + * Handle the Schedule "created" event. + */ + public function created(Schedule $schedule): void + { + UpdateNextRun::run($schedule); + CheckCronOverlap::run($schedule); + } + + /** + * Handle the Schedule "updated" event. + */ + public function updated(Schedule $schedule): void + { + UpdateNextRun::run($schedule); + CheckCronOverlap::run($schedule); + } + + /** + * Handle the Schedule "deleted" event. + */ + public function deleted(Schedule $schedule): void + { + // + } + + /** + * Handle the Schedule "restored" event. + */ + public function restored(Schedule $schedule): void + { + // + } + + /** + * Handle the Schedule "force deleted" event. + */ + public function forceDeleted(Schedule $schedule): void + { + // + } +} diff --git a/app/Rules/NoCronOverlap.php b/app/Rules/NoCronOverlap.php new file mode 100644 index 000000000..9d70a4faa --- /dev/null +++ b/app/Rules/NoCronOverlap.php @@ -0,0 +1,53 @@ +schedule = $schedule; + $this->ignoreId = $ignoreId; + $this->shouldCheck = $shouldCheck; + } + + public function passes($attribute, $value): bool + { + if (! $this->shouldCheck || ! $this->schedule || ! is_string($value)) { + return true; + } + + // Fetch all cron expressions of the same type, excluding the current schedule (if any) + $existingCrons = Schedule::query() + ->where('is_active', true) + ->where('type', $this->schedule->type) // Dynamically use the type from the schedule + ->when($this->ignoreId, fn ($q) => $q->where('id', '!=', $this->ignoreId)) + ->get() + ->pluck('options.cron_expression'); // Extract cron expressions + + foreach ($existingCrons as $existingCron) { + \Log::info('Comparing:', ['existing' => $existingCron, 'new' => $value]); + + // Check if there's an overlap + if (Cron::hasOverlap($existingCron, $value)) { + + return false; // Return false if overlap is detected + } + } + + return true; // Return true if no overlap is detected + } + + public function message(): string + { + return 'This cron expression overlaps with another active schedule of the same type.'; + } +} diff --git a/database/factories/ScheduleFactory.php b/database/factories/ScheduleFactory.php new file mode 100644 index 000000000..cfcec6428 --- /dev/null +++ b/database/factories/ScheduleFactory.php @@ -0,0 +1,23 @@ + + */ +class ScheduleFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + // + ]; + } +} diff --git a/database/migrations/2025_11_20_205859_create_schedules_table..php b/database/migrations/2025_11_20_205859_create_schedules_table..php new file mode 100644 index 000000000..0befd1c9e --- /dev/null +++ b/database/migrations/2025_11_20_205859_create_schedules_table..php @@ -0,0 +1,40 @@ +id(); + $table->foreignId('owned_by_id')->nullable(); + $table->string('type')->nullable(); + $table->string('name')->nullable(); + $table->text('description')->nullable(); + $table->json('options')->nullable(); + $table->string('token')->nullable(); + $table->boolean('is_active')->default(true); + $table->dateTime('next_run_at')->nullable(); + $table->timestamps(); + + $table->foreign('owned_by_id') + ->references('id') + ->on('users') + ->nullOnDelete(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('schedules'); + } +}; diff --git a/database/migrations/2025_11_20_205917_add_schedule_id_to_results_table.php b/database/migrations/2025_11_20_205917_add_schedule_id_to_results_table.php new file mode 100644 index 000000000..0ff78a439 --- /dev/null +++ b/database/migrations/2025_11_20_205917_add_schedule_id_to_results_table.php @@ -0,0 +1,26 @@ +foreignId('schedule_id')->nullable()->after('id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + // + } +}; From 41b424835de7ee08e5328d92cd64adda67e8f41d Mon Sep 17 00:00:00 2001 From: sveng93 Date: Fri, 21 Nov 2025 15:46:16 +0100 Subject: [PATCH 02/15] Update UI --- .../Resources/Results/Tables/ResultTable.php | 12 + .../Resources/Schedules/ScheduleResource.php | 6 +- .../Schedules/Schemas/ScheduleForm.php | 207 ++++++++---------- .../Schedules/Tables/ScheduleTable.php | 2 + app/Models/Schedule.php | 3 +- app/Observers/ScheduleObserver.php | 6 +- composer.json | 1 + composer.lock | 168 +++++++++++++- ...5_11_20_205859_create_schedules_table..php | 6 - 9 files changed, 278 insertions(+), 133 deletions(-) diff --git a/app/Filament/Resources/Results/Tables/ResultTable.php b/app/Filament/Resources/Results/Tables/ResultTable.php index e09d98082..09d301d63 100644 --- a/app/Filament/Resources/Results/Tables/ResultTable.php +++ b/app/Filament/Resources/Results/Tables/ResultTable.php @@ -7,6 +7,7 @@ use App\Helpers\Number; use App\Jobs\TruncateResults; use App\Models\Result; +use App\Models\Schedule; use Filament\Actions\Action; use Filament\Actions\ActionGroup; use Filament\Actions\DeleteAction; @@ -174,6 +175,7 @@ public static function table(Table $table): Table ->label(__('results.scheduled')) ->boolean() ->toggleable(isToggledHiddenByDefault: true) + ->tooltip(fn ($record) => $record->schedule->name ?? null) ->alignment(Alignment::Center), IconColumn::make('healthy') ->label(__('general.healthy')) @@ -262,6 +264,16 @@ public static function table(Table $table): Table ->toArray(); }) ->attribute('data->server->name'), + SelectFilter::make('schedule_id') + ->label('Schedule') + ->multiple() + ->attribute('schedule_id') + ->options(function (): array { + return Schedule::query() + ->orderBy('name') + ->pluck('name', 'id') + ->toArray(); + }), TernaryFilter::make('scheduled') ->label(__('results.scheduled')) ->nullable() diff --git a/app/Filament/Resources/Schedules/ScheduleResource.php b/app/Filament/Resources/Schedules/ScheduleResource.php index f412cc61b..b4345ed74 100644 --- a/app/Filament/Resources/Schedules/ScheduleResource.php +++ b/app/Filament/Resources/Schedules/ScheduleResource.php @@ -18,6 +18,8 @@ class ScheduleResource extends Resource protected static ?string $model = Schedule::class; protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-calendar'; + + protected static string|\UnitEnum|null $navigationGroup = 'Settings'; public static function getNavigationLabel(): string { @@ -58,8 +60,8 @@ public static function getPages(): array { return [ 'index' => ListSchedules::route('/'), - 'create' => CreateSchedule::route('/create'), - 'edit' => EditSchedule::route('/{record}/edit'), + # 'create' => CreateSchedule::route('/create'), + # 'edit' => EditSchedule::route('/{record}/edit'), ]; } } diff --git a/app/Filament/Resources/Schedules/Schemas/ScheduleForm.php b/app/Filament/Resources/Schedules/Schemas/ScheduleForm.php index 03a827569..a6ff3e84c 100644 --- a/app/Filament/Resources/Schedules/Schemas/ScheduleForm.php +++ b/app/Filament/Resources/Schedules/Schemas/ScheduleForm.php @@ -27,129 +27,104 @@ class ScheduleForm public static function schema(): array { return [ - Grid::make([ - 'default' => 1, - 'lg' => 2, - ])->schema([ - Section::make('Details') - ->schema([ - TextInput::make('name') - ->placeholder('Enter a name for the test.') - ->maxLength(255) - ->unique(ignoreRecord: true) - ->required(), - TextInput::make('description') - ->maxLength(255), - ]) - ->columnSpan([ - 'default' => 1, - ]), + Section::make('Details') + ->schema([ + Toggle::make('is_active') + ->label('Active') + ->required(), + TextInput::make('name') + ->placeholder('Enter a name for the test.') + ->maxLength(255) + ->unique(ignoreRecord: true) + ->required(), + TextInput::make('description') + ->maxLength(255), + Select::make('type') + ->label('Type') + ->options([ + 'Ookla' => 'Ookla', + ]) + ->default('Ookla') + ->native(false) + ->required(), + ]) + ->columnSpan('full'), - Section::make('Settings') - ->schema([ - Toggle::make('is_active') - ->label('Active') - ->required(), - Select::make('owned_by_id') - ->label('Owner') - ->placeholder('Select an owner.') - ->relationship('ownedBy', 'name') - ->default(Auth::id()) - ->searchable(), - Select::make('type') - ->label('Type') - ->options([ - 'Ookla' => 'Ookla', - ]) - ->default('Ookla') - ->native(false) - ->required(), - TextInput::make('token') - ->helperText(new HtmlString('This is a secret token that can be used to authenticate requests to the test.')) - ->readOnly() - ->hiddenOn('create'), - ]) - ->columnSpan([ - 'default' => 1, - ]), + Tabs::make('Options') + ->tabs([ + Tab::make('Schedule') + ->schema([ + TextInput::make('options.cron_expression') + ->placeholder('Enter a cron expression.') + ->helperText(fn (Get $get) => ExplainCronExpression::run($get('options.cron_expression'))) + ->required() + ->rules([new Cron]) + ->live(), + Placeholder::make('next_run_at') + ->label('Next Run At') + ->content(function (Get $get) { + $expression = $get('options.cron_expression'); - Tabs::make('Options') - ->tabs([ - Tab::make('Schedule') - ->schema([ - TextInput::make('options.cron_expression') - ->placeholder('Enter a cron expression.') - ->helperText(fn (Get $get) => ExplainCronExpression::run($get('options.cron_expression'))) - ->required() - ->rules([new Cron]) - ->live(), - Placeholder::make('next_run_at') - ->label('Next Run At') - ->content(function (Get $get) { - $expression = $get('options.cron_expression'); + if (! $expression) { + return '—'; + } - if (! $expression) { - return '—'; - } + try { + $cron = new CronExpression($expression); - try { - $cron = new CronExpression($expression); + return Carbon::instance( + $cron->getNextRunDate(now(), 0, false, config('app.display_timezone')) + )->toDayDateTimeString(); + } catch (\Exception $e) { + return 'Invalid cron expression'; + } + }), + ]), - return Carbon::instance( - $cron->getNextRunDate(now(), 0, false, config('app.display_timezone')) - )->toDayDateTimeString(); - } catch (\Exception $e) { - return 'Invalid cron expression'; - } - }), - ]), + Tab::make('Servers') + ->schema([ + Radio::make('options.server_preference') + ->options([ + 'auto' => 'Automatically select a server', + 'prefer' => 'Prefer servers from the list', + 'ignore' => 'Ignore servers from the list', + ]) + ->default('auto') + ->required() + ->live(), - Tab::make('Servers') - ->schema([ - Radio::make('options.server_preference') - ->options([ - 'auto' => 'Automatically select a server', - 'prefer' => 'Prefer servers from the list', - 'ignore' => 'Ignore servers from the list', - ]) - ->default('auto') - ->required() - ->live(), + Repeater::make('options.servers') + ->schema([ + Select::make('server_id') + ->label('Server ID') + ->placeholder('Select the ID of the server.') + ->options(function (): array { + return GetOoklaSpeedtestServers::run(); + }) + ->searchable() + ->required(), + ]) + ->minItems(1) + ->maxItems(20) + ->hidden(fn (Get $get) => $get('options.server_preference') === 'auto'), + ]), - Repeater::make('options.servers') - ->schema([ - Select::make('server_id') - ->label('Server ID') - ->placeholder('Select the ID of the server.') - ->options(function (): array { - return GetOoklaSpeedtestServers::run(); - }) - ->searchable() - ->required(), - ]) - ->minItems(1) - ->maxItems(20) - ->hidden(fn (Get $get) => $get('options.server_preference') === 'auto'), - ]), - - Tab::make('Advanced') - ->schema([ - TagsInput::make('options.skip_ips') - ->label('Skip IP addresses') - ->placeholder('8.8.8.8') - ->nestedRecursiveRules([ - 'ip', - ]) - ->live() - ->helpertext('Add external IP addresses that should be skipped.'), - TextInput::make('options.interface') - ->label('Network Interface') - ->placeholder('eth0') - ->helpertext('Set the network interface to use for the test. This need to be the network interface available inside the container'), - ]), - ]) - ->columnSpan('full'), - ]), + Tab::make('Advanced') + ->schema([ + TagsInput::make('options.skip_ips') + ->label('Skip IP addresses') + ->placeholder('8.8.8.8') + ->nestedRecursiveRules([ + 'ip', + ]) + ->live() + ->helpertext('Add external IP addresses that should be skipped.'), + TextInput::make('options.interface') + ->label('Network Interface') + ->placeholder('eth0') + ->helpertext('Set the network interface to use for the test. This need to be the network interface available inside the container'), + ]), + ])->columnSpan('full'), ]; } } diff --git a/app/Filament/Resources/Schedules/Tables/ScheduleTable.php b/app/Filament/Resources/Schedules/Tables/ScheduleTable.php index d75bd9d42..1c50b6dbc 100644 --- a/app/Filament/Resources/Schedules/Tables/ScheduleTable.php +++ b/app/Filament/Resources/Schedules/Tables/ScheduleTable.php @@ -76,6 +76,8 @@ public static function table(Table $table): Table ->sortable() ->toggleable(isToggledHiddenByDefault: true), ]) + ->deferFilters(false) + ->deferColumnManager(false) ->filters([ SelectFilter::make('type') ->label('Type') diff --git a/app/Models/Schedule.php b/app/Models/Schedule.php index 1293e2d4b..54d79568c 100644 --- a/app/Models/Schedule.php +++ b/app/Models/Schedule.php @@ -3,7 +3,6 @@ namespace App\Models; use App\Actions\GetOoklaSpeedtestServers; -use App\Models\Traits\HasOwner; use App\Observers\ScheduleObserver; use Illuminate\Database\Eloquent\Attributes\ObservedBy; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -13,7 +12,7 @@ class Schedule extends Model { - use HasFactory, HasOwner; + use HasFactory; /** * The attributes that aren't mass assignable. diff --git a/app/Observers/ScheduleObserver.php b/app/Observers/ScheduleObserver.php index 0e75daab8..5b8386ae8 100644 --- a/app/Observers/ScheduleObserver.php +++ b/app/Observers/ScheduleObserver.php @@ -14,11 +14,7 @@ class ScheduleObserver */ public function creating(Schedule $schedule): void { - do { - $token = Str::lower(Str::random(16)); - } while (Schedule::where('token', $token)->exists()); - - $schedule->token = $token; + // } /** diff --git a/composer.json b/composer.json index 2b38b1ec7..6584fee10 100644 --- a/composer.json +++ b/composer.json @@ -28,6 +28,7 @@ "livewire/livewire": "^3.6.4", "lorisleiva/laravel-actions": "^2.9.1", "maennchen/zipstream-php": "^2.4", + "orisai/cron-expression-explainer": "^1.1", "secondnetwork/blade-tabler-icons": "^3.35.0", "spatie/laravel-json-api-paginate": "^1.16.3", "spatie/laravel-query-builder": "^6.3.6", diff --git a/composer.lock b/composer.lock index b5d427f75..b888dca6e 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "39020dcee9d9965e781ef550aca663ac", + "content-hash": "6104f4844809019cc0acd53365732f7b", "packages": [ { "name": "anourvalar/eloquent-serialize", @@ -4632,6 +4632,80 @@ ], "time": "2025-09-03T16:03:54+00:00" }, + { + "name": "orisai/cron-expression-explainer", + "version": "1.1.1", + "source": { + "type": "git", + "url": "https://github.com/orisai/cron-expression-explainer.git", + "reference": "f36c3405b36c7ac0c9e9837e5bfc3161579e0fc3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/orisai/cron-expression-explainer/zipball/f36c3405b36c7ac0c9e9837e5bfc3161579e0fc3", + "reference": "f36c3405b36c7ac0c9e9837e5bfc3161579e0fc3", + "shasum": "" + }, + "require": { + "dragonmantank/cron-expression": "^3.3.0", + "php": "7.4 - 8.4", + "symfony/intl": "^5.4.35|^6.4.3|^7.0.3", + "symfony/polyfill-php80": "^1.29" + }, + "require-dev": { + "brianium/paratest": "^6.3.0", + "infection/infection": "^0.26.0", + "nette/utils": "^3.1.0|^4.0.0", + "orisai/coding-standard": "^3.0.0", + "phpstan/extension-installer": "^1.0.0", + "phpstan/phpstan": "^1.0.0", + "phpstan/phpstan-deprecation-rules": "^1.0.0", + "phpstan/phpstan-phpunit": "^1.0.0", + "phpstan/phpstan-strict-rules": "^1.0.0", + "phpunit/phpunit": "^9.5.0", + "staabm/annotate-pull-request-from-checkstyle": "^1.7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Orisai\\CronExpressionExplainer\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MPL-2.0" + ], + "authors": [ + { + "name": "Marek Bartoš", + "homepage": "https://github.com/mabar" + } + ], + "description": "Human-readable cron expressions", + "homepage": "https://github.com/orisai/cron-expression-explainer", + "keywords": [ + "cron", + "crontab", + "frequency", + "i18n", + "internationalization", + "interval", + "l10n", + "localization", + "orisai", + "recurring", + "schedule", + "scheduler", + "scheduling", + "task", + "translation" + ], + "support": { + "issues": "https://github.com/orisai/cron-expression-explainer/issues", + "source": "https://github.com/orisai/cron-expression-explainer/tree/1.1.1" + }, + "time": "2024-06-20T21:57:28+00:00" + }, { "name": "paragonie/constant_time_encoding", "version": "v3.1.3", @@ -7638,6 +7712,96 @@ ], "time": "2025-11-12T11:38:40+00:00" }, + { + "name": "symfony/intl", + "version": "v7.3.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/intl.git", + "reference": "9eccaaa94ac6f9deb3620c9d47a057d965baeabf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/intl/zipball/9eccaaa94ac6f9deb3620c9d47a057d965baeabf", + "reference": "9eccaaa94ac6f9deb3620c9d47a057d965baeabf", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/string": "<7.1" + }, + "require-dev": { + "symfony/filesystem": "^6.4|^7.0", + "symfony/var-exporter": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Intl\\": "" + }, + "exclude-from-classmap": [ + "/Tests/", + "/Resources/data/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + }, + { + "name": "Eriksen Costa", + "email": "eriksen.costa@infranology.com.br" + }, + { + "name": "Igor Wiedler", + "email": "igor@wiedler.ch" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides access to the localization data of the ICU library", + "homepage": "https://symfony.com", + "keywords": [ + "i18n", + "icu", + "internationalization", + "intl", + "l10n", + "localization" + ], + "support": { + "source": "https://github.com/symfony/intl/tree/v7.3.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-10-01T06:11:17+00:00" + }, { "name": "symfony/mailer", "version": "v7.3.5", @@ -13714,5 +13878,5 @@ "platform-overrides": { "php": "8.4" }, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } diff --git a/database/migrations/2025_11_20_205859_create_schedules_table..php b/database/migrations/2025_11_20_205859_create_schedules_table..php index 0befd1c9e..69d2ca869 100644 --- a/database/migrations/2025_11_20_205859_create_schedules_table..php +++ b/database/migrations/2025_11_20_205859_create_schedules_table..php @@ -18,15 +18,9 @@ public function up(): void $table->string('name')->nullable(); $table->text('description')->nullable(); $table->json('options')->nullable(); - $table->string('token')->nullable(); $table->boolean('is_active')->default(true); $table->dateTime('next_run_at')->nullable(); $table->timestamps(); - - $table->foreign('owned_by_id') - ->references('id') - ->on('users') - ->nullOnDelete(); }); } From 7e34694d041383ef6b6115075f95c608f5253020 Mon Sep 17 00:00:00 2001 From: sveng93 Date: Fri, 21 Nov 2025 16:14:36 +0100 Subject: [PATCH 03/15] Remove thresholds for now --- app/Models/Schedule.php | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/app/Models/Schedule.php b/app/Models/Schedule.php index 54d79568c..58a8def72 100644 --- a/app/Models/Schedule.php +++ b/app/Models/Schedule.php @@ -32,7 +32,6 @@ protected function casts(): array 'options' => 'array', 'is_active' => 'boolean', 'next_run_at' => 'datetime', - 'thresholds' => 'array', ]; } @@ -53,20 +52,4 @@ public function getServerTooltip(): ?string ->map(fn ($id) => $lookup[$id] ?? "Unknown ($id)") ->implode(', '); } - - public function getThresholdTooltip(): ?string - { - $thresholds = $this->thresholds; - - if (! ($thresholds['enabled'] ?? false)) { - return null; - } - - return sprintf( - "Download: %s Mbps\nUpload: %s Mbps\nPing: %s ms", - $thresholds['download'] ?? '—', - $thresholds['upload'] ?? '—', - $thresholds['ping'] ?? '—', - ); - } } From 37938bc6dbbf39daac975b11903e74b84f123bec Mon Sep 17 00:00:00 2001 From: sveng93 Date: Mon, 24 Nov 2025 19:23:13 +0100 Subject: [PATCH 04/15] change input --- .../Resources/Schedules/Schemas/ScheduleForm.php | 11 +++-------- .../Resources/Schedules/Tables/ScheduleTable.php | 4 +--- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/app/Filament/Resources/Schedules/Schemas/ScheduleForm.php b/app/Filament/Resources/Schedules/Schemas/ScheduleForm.php index a6ff3e84c..46d915f10 100644 --- a/app/Filament/Resources/Schedules/Schemas/ScheduleForm.php +++ b/app/Filament/Resources/Schedules/Schemas/ScheduleForm.php @@ -41,6 +41,7 @@ public static function schema(): array ->maxLength(255), Select::make('type') ->label('Type') + ->hidden(true) ->options([ 'Ookla' => 'Ookla', ]) @@ -95,17 +96,11 @@ public static function schema(): array Repeater::make('options.servers') ->schema([ - Select::make('server_id') + TextInput::make('server_id') ->label('Server ID') - ->placeholder('Select the ID of the server.') - ->options(function (): array { - return GetOoklaSpeedtestServers::run(); - }) - ->searchable() + ->placeholder('Enter the ID of the server.') ->required(), ]) - ->minItems(1) - ->maxItems(20) ->hidden(fn (Get $get) => $get('options.server_preference') === 'auto'), ]), diff --git a/app/Filament/Resources/Schedules/Tables/ScheduleTable.php b/app/Filament/Resources/Schedules/Tables/ScheduleTable.php index 1c50b6dbc..41d1f8ec4 100644 --- a/app/Filament/Resources/Schedules/Tables/ScheduleTable.php +++ b/app/Filament/Resources/Schedules/Tables/ScheduleTable.php @@ -31,7 +31,7 @@ public static function table(Table $table): Table TextColumn::make('name'), TextColumn::make('type') ->label('Type') - ->toggleable(isToggledHiddenByDefault: false) + ->toggleable(isToggledHiddenByDefault: true) ->sortable(), TextColumn::make('description') ->toggleable(isToggledHiddenByDefault: true), @@ -58,8 +58,6 @@ public static function table(Table $table): Table ->alignCenter() ->toggleable(isToggledHiddenByDefault: false) ->boolean(), - TextColumn::make('ownedBy.name') - ->toggleable(isToggledHiddenByDefault: true), TextColumn::make('next_run_at') ->alignEnd() ->dateTime() From 697022a6e3ccb8cd72582cbd2a63a5278d214625 Mon Sep 17 00:00:00 2001 From: sveng93 Date: Mon, 24 Nov 2025 19:30:09 +0100 Subject: [PATCH 05/15] getServerLabel --- .../Resources/Schedules/Schemas/ScheduleForm.php | 9 +++++---- app/Models/Schedule.php | 11 ++++++++--- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/app/Filament/Resources/Schedules/Schemas/ScheduleForm.php b/app/Filament/Resources/Schedules/Schemas/ScheduleForm.php index 46d915f10..c719ae37d 100644 --- a/app/Filament/Resources/Schedules/Schemas/ScheduleForm.php +++ b/app/Filament/Resources/Schedules/Schemas/ScheduleForm.php @@ -3,7 +3,7 @@ namespace App\Filament\Resources\Schedules\Schemas; use App\Actions\ExplainCronExpression; -use App\Actions\GetOoklaSpeedtestServers; +use App\Models\Schedule; use App\Rules\Cron; use Carbon\Carbon; use Cron\CronExpression; @@ -14,13 +14,10 @@ use Filament\Forms\Components\TagsInput; use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Toggle; -use Filament\Schemas\Components\Grid; use Filament\Schemas\Components\Section; use Filament\Schemas\Components\Tabs; use Filament\Schemas\Components\Tabs\Tab; use Filament\Schemas\Components\Utilities\Get; -use Illuminate\Support\Facades\Auth; -use Illuminate\Support\HtmlString; class ScheduleForm { @@ -101,6 +98,10 @@ public static function schema(): array ->placeholder('Enter the ID of the server.') ->required(), ]) + ->itemLabel(fn (array $state): ?string => isset($state['server_id']) + ? Schedule::getServerLabel($state['server_id']) + : null + ) ->hidden(fn (Get $get) => $get('options.server_preference') === 'auto'), ]), diff --git a/app/Models/Schedule.php b/app/Models/Schedule.php index 58a8def72..805ef6837 100644 --- a/app/Models/Schedule.php +++ b/app/Models/Schedule.php @@ -35,6 +35,13 @@ protected function casts(): array ]; } + public static function getServerLabel(string|int $serverId): string + { + $lookup = GetOoklaSpeedtestServers::run(); + + return $lookup[$serverId] ?? "Server ID: {$serverId}"; + } + public function getServerTooltip(): ?string { $preference = $this->options['server_preference'] ?? 'auto'; @@ -46,10 +53,8 @@ public function getServerTooltip(): ?string $servers = collect($this->options['servers'] ?? []) ->pluck('server_id'); - $lookup = GetOoklaSpeedtestServers::run(); - return $servers - ->map(fn ($id) => $lookup[$id] ?? "Unknown ($id)") + ->map(fn ($id) => self::getServerLabel($id)) ->implode(', '); } } From 5d235dca102c24ecc818eb2a21dbde59953b2587 Mon Sep 17 00:00:00 2001 From: sveng93 Date: Thu, 27 Nov 2025 22:18:05 +0100 Subject: [PATCH 06/15] Add lang files change db colunms --- app/Actions/CheckCronOverlap.php | 10 +- app/Actions/UpdateNextRun.php | 4 +- app/Enums/ScheduleStatus.php | 34 + .../Schedules/Pages/CreateSchedule.php | 13 + .../Resources/Schedules/ScheduleResource.php | 6 +- .../Schedules/Schemas/ScheduleForm.php | 59 +- .../Schedules/Tables/ScheduleTable.php | 86 ++- app/Models/Schedule.php | 14 +- app/Models/Traits/HasOwner.php | 18 +- app/Rules/NoCronOverlap.php | 2 +- composer.lock | 602 +++++++++++------- ...5_11_20_205859_create_schedules_table..php | 7 +- lang/en/schedules.php | 58 ++ 13 files changed, 617 insertions(+), 296 deletions(-) create mode 100644 app/Enums/ScheduleStatus.php create mode 100644 lang/en/schedules.php diff --git a/app/Actions/CheckCronOverlap.php b/app/Actions/CheckCronOverlap.php index 228e16590..ad27f1bb5 100644 --- a/app/Actions/CheckCronOverlap.php +++ b/app/Actions/CheckCronOverlap.php @@ -20,7 +20,7 @@ public static function run(Schedule $schedule): void return; } - $cronExpression = $schedule->options['cron_expression']; + $cronExpression = $schedule->schedule; $scheduleId = $schedule->id; // Track overlapping schedules @@ -30,8 +30,8 @@ public static function run(Schedule $schedule): void $existingCrons = Schedule::query() ->where('is_active', true) ->where('id', '!=', $scheduleId) - ->get(['id', 'options']) - ->pluck('options.cron_expression', 'id'); + ->get(['id', 'schedule']) + ->pluck('schedule', 'id'); // Check for overlaps with the modified schedule's cron expression foreach ($existingCrons as $existingScheduleId => $existingCron) { @@ -46,9 +46,9 @@ public static function run(Schedule $schedule): void // Send a single notification for all overlaps Notification::make() - ->title('Schedule Overlap Detected') + ->title(__('schedules.overlap_detected')) ->warning() - ->body("The cron expression for this schedule overlaps with the following active schedule ids: $overlapList.") + ->body(__('schedules.overlap_body', ['ids' => $overlapList])) ->send(); } } diff --git a/app/Actions/UpdateNextRun.php b/app/Actions/UpdateNextRun.php index 108dc2ad9..d655a8f18 100644 --- a/app/Actions/UpdateNextRun.php +++ b/app/Actions/UpdateNextRun.php @@ -28,8 +28,8 @@ public function handle(Schedule $schedule): void return; } - // Get the cron expression from the schedule options - $expression = data_get($schedule, 'options.cron_expression'); + // Get the cron expression from the schedule column + $expression = $schedule->schedule; if ($expression) { // Calculate the next run time based on the cron expression diff --git a/app/Enums/ScheduleStatus.php b/app/Enums/ScheduleStatus.php new file mode 100644 index 000000000..5e9e7b672 --- /dev/null +++ b/app/Enums/ScheduleStatus.php @@ -0,0 +1,34 @@ + 'success', + self::Unhealthy => 'warning', + self::Failed => 'danger', + self::NotTested => 'gray', + }; + } + + public function getLabel(): ?string + { + return match ($this) { + self::Healthy => 'Healthy', + self::Unhealthy => 'Unhealthy', + self::Failed => 'Failed', + self::NotTested => 'Not Tested', + }; + } +} diff --git a/app/Filament/Resources/Schedules/Pages/CreateSchedule.php b/app/Filament/Resources/Schedules/Pages/CreateSchedule.php index b19550031..904aa519b 100644 --- a/app/Filament/Resources/Schedules/Pages/CreateSchedule.php +++ b/app/Filament/Resources/Schedules/Pages/CreateSchedule.php @@ -4,8 +4,21 @@ use App\Filament\Resources\Schedules\ScheduleResource; use Filament\Resources\Pages\CreateRecord; +use Illuminate\Support\Facades\Auth; class CreateSchedule extends CreateRecord { protected static string $resource = ScheduleResource::class; + + protected function mutateFormDataBeforeCreate(array $data): array + { + $data['created_by'] = Auth::id(); + + return $data; + } + + protected function getRedirectUrl(): string + { + return $this->getResource()::getUrl('index'); + } } diff --git a/app/Filament/Resources/Schedules/ScheduleResource.php b/app/Filament/Resources/Schedules/ScheduleResource.php index b4345ed74..2ea7e8d88 100644 --- a/app/Filament/Resources/Schedules/ScheduleResource.php +++ b/app/Filament/Resources/Schedules/ScheduleResource.php @@ -18,7 +18,7 @@ class ScheduleResource extends Resource protected static ?string $model = Schedule::class; protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-calendar'; - + protected static string|\UnitEnum|null $navigationGroup = 'Settings'; public static function getNavigationLabel(): string @@ -60,8 +60,8 @@ public static function getPages(): array { return [ 'index' => ListSchedules::route('/'), - # 'create' => CreateSchedule::route('/create'), - # 'edit' => EditSchedule::route('/{record}/edit'), + 'create' => CreateSchedule::route('/create'), + 'edit' => EditSchedule::route('/{record}/edit'), ]; } } diff --git a/app/Filament/Resources/Schedules/Schemas/ScheduleForm.php b/app/Filament/Resources/Schedules/Schemas/ScheduleForm.php index c719ae37d..c08cd1067 100644 --- a/app/Filament/Resources/Schedules/Schemas/ScheduleForm.php +++ b/app/Filament/Resources/Schedules/Schemas/ScheduleForm.php @@ -3,7 +3,6 @@ namespace App\Filament\Resources\Schedules\Schemas; use App\Actions\ExplainCronExpression; -use App\Models\Schedule; use App\Rules\Cron; use Carbon\Carbon; use Cron\CronExpression; @@ -24,44 +23,47 @@ class ScheduleForm public static function schema(): array { return [ - Section::make('Details') + Section::make(__('schedules.details')) ->schema([ Toggle::make('is_active') - ->label('Active') + ->label(__('schedules.active')) ->required(), TextInput::make('name') - ->placeholder('Enter a name for the test.') + ->label(__('schedules.name')) + ->placeholder(__('schedules.name_placeholder')) ->maxLength(255) ->unique(ignoreRecord: true) ->required(), TextInput::make('description') + ->label(__('schedules.description')) ->maxLength(255), Select::make('type') - ->label('Type') + ->label(__('schedules.type')) ->hidden(true) ->options([ 'Ookla' => 'Ookla', ]) - ->default('Ookla') + ->default('ookla') ->native(false) ->required(), ]) ->columnSpan('full'), - Tabs::make('Options') + Tabs::make(__('schedules.options')) ->tabs([ - Tab::make('Schedule') + Tab::make(__('schedules.schedule')) ->schema([ - TextInput::make('options.cron_expression') - ->placeholder('Enter a cron expression.') - ->helperText(fn (Get $get) => ExplainCronExpression::run($get('options.cron_expression'))) + TextInput::make('schedule') + ->label(__('schedules.schedule')) + ->placeholder(__('schedules.schedule_placeholder')) + ->helperText(fn (Get $get) => ExplainCronExpression::run($get('schedule'))) ->required() ->rules([new Cron]) ->live(), Placeholder::make('next_run_at') - ->label('Next Run At') + ->label(__('schedules.next_run_at')) ->content(function (Get $get) { - $expression = $get('options.cron_expression'); + $expression = $get('schedule'); if (! $expression) { return '—'; @@ -79,13 +81,14 @@ public static function schema(): array }), ]), - Tab::make('Servers') + Tab::make(__('schedules.servers')) ->schema([ Radio::make('options.server_preference') + ->label(__('schedules.server_preference')) ->options([ - 'auto' => 'Automatically select a server', - 'prefer' => 'Prefer servers from the list', - 'ignore' => 'Ignore servers from the list', + 'auto' => __('schedules.server_preference_auto'), + 'prefer' => __('schedules.server_preference_prefer'), + 'ignore' => __('schedules.server_preference_ignore'), ]) ->default('auto') ->required() @@ -94,31 +97,27 @@ public static function schema(): array Repeater::make('options.servers') ->schema([ TextInput::make('server_id') - ->label('Server ID') - ->placeholder('Enter the ID of the server.') + ->label(__('schedules.server_id')) + ->placeholder(__('schedules.server_id_placeholder')) ->required(), ]) - ->itemLabel(fn (array $state): ?string => isset($state['server_id']) - ? Schedule::getServerLabel($state['server_id']) - : null - ) ->hidden(fn (Get $get) => $get('options.server_preference') === 'auto'), ]), - Tab::make('Advanced') + Tab::make(__('schedules.advanced')) ->schema([ TagsInput::make('options.skip_ips') - ->label('Skip IP addresses') - ->placeholder('8.8.8.8') + ->label(__('schedules.skip_ips')) + ->placeholder(__('schedules.skip_ips_placeholder')) ->nestedRecursiveRules([ 'ip', ]) ->live() - ->helpertext('Add external IP addresses that should be skipped.'), + ->helpertext(__('schedules.skip_ips_helper')), TextInput::make('options.interface') - ->label('Network Interface') - ->placeholder('eth0') - ->helpertext('Set the network interface to use for the test. This need to be the network interface available inside the container'), + ->label(__('schedules.network_interface')) + ->placeholder(__('schedules.network_interface_placeholder')) + ->helpertext(__('schedules.network_interface_helper')), ]), ])->columnSpan('full'), ]; diff --git a/app/Filament/Resources/Schedules/Tables/ScheduleTable.php b/app/Filament/Resources/Schedules/Tables/ScheduleTable.php index 41d1f8ec4..7a175e55b 100644 --- a/app/Filament/Resources/Schedules/Tables/ScheduleTable.php +++ b/app/Filament/Resources/Schedules/Tables/ScheduleTable.php @@ -3,7 +3,9 @@ namespace App\Filament\Resources\Schedules\Tables; use App\Actions\ExplainCronExpression; +use App\Enums\ScheduleStatus; use App\Models\Schedule; +use App\Models\User; use Filament\Actions\Action; use Filament\Actions\ActionGroup; use Filament\Actions\DeleteAction; @@ -23,52 +25,70 @@ public static function table(Table $table): Table return $table ->columns([ TextColumn::make('id') - ->label('ID') + ->label(__('schedules.id')) + ->sortable(), + TextColumn::make('name') + ->label(__('schedules.name')) ->sortable(), - TextColumn::make('token') - ->copyable() - ->toggleable(isToggledHiddenByDefault: true), - TextColumn::make('name'), TextColumn::make('type') - ->label('Type') + ->label(__('schedules.type')) ->toggleable(isToggledHiddenByDefault: true) ->sortable(), TextColumn::make('description') + ->label(__('schedules.description')) ->toggleable(isToggledHiddenByDefault: true), - TextColumn::make('options.cron_expression') - ->label('Schedule') + TextColumn::make('schedule') + ->label(__('schedules.schedule')) ->sortable() ->toggleable(isToggledHiddenByDefault: false) ->formatStateUsing(fn (?string $state) => ExplainCronExpression::run($state)), TextColumn::make('options.server_preference') - ->label('Server Preference') + ->label(__('schedules.server_preference')) ->sortable() ->toggleable(isToggledHiddenByDefault: true) ->formatStateUsing(function (?string $state) { return match ($state) { - 'auto' => 'Automatic', - 'prefer' => 'Prefer Specific Servers', - 'ignore' => 'Ignore Specific Servers', + 'auto' => __('schedules.server_preference_auto'), + 'prefer' => __('schedules.server_preference_prefer'), + 'ignore' => __('schedules.server_preference_ignore'), default => $state, }; }) ->tooltip(fn ($record) => $record->getServerTooltip()), IconColumn::make('is_active') - ->label('Active') + ->label(__('schedules.active')) ->alignCenter() ->toggleable(isToggledHiddenByDefault: false) ->boolean(), + TextColumn::make('status') + ->label(__('schedules.status')) + ->badge() + ->sortable() + ->toggleable(isToggledHiddenByDefault: false), TextColumn::make('next_run_at') + ->label(__('schedules.next_run_at')) ->alignEnd() ->dateTime() ->toggleable(isToggledHiddenByDefault: false) ->sortable(), + TextColumn::make('last_run_at') + ->label(__('schedules.last_run_at')) + ->alignEnd() + ->dateTime() + ->toggleable(isToggledHiddenByDefault: true) + ->sortable(), + TextColumn::make('createdBy.name') + ->label(__('schedules.created_by')) + ->toggleable(isToggledHiddenByDefault: true) + ->sortable(), TextColumn::make('created_at') + ->label(__('general.created_at')) ->alignEnd() ->dateTime() ->sortable() ->toggleable(isToggledHiddenByDefault: false), TextColumn::make('updated_at') + ->label(__('general.updated_at')) ->alignEnd() ->dateTime() ->sortable() @@ -78,7 +98,7 @@ public static function table(Table $table): Table ->deferColumnManager(false) ->filters([ SelectFilter::make('type') - ->label('Type') + ->label(__('schedules.type')) ->options(function () { return Schedule::distinct() ->pluck('type', 'type') @@ -86,9 +106,10 @@ public static function table(Table $table): Table }) ->native(false), TernaryFilter::make('Active') + ->label(__('schedules.active')) ->nullable() - ->trueLabel('Active schedules only') - ->falseLabel('Inactive schedules only') + ->trueLabel(__('schedules.active_schedules_only')) + ->falseLabel(__('schedules.inactive_schedules_only')) ->queries( true: fn (Builder $query) => $query->where('is_active', true), false: fn (Builder $query) => $query->where('is_active', false), @@ -96,9 +117,9 @@ public static function table(Table $table): Table ) ->native(false), SelectFilter::make('options.server_preference') - ->label('Server Preference') + ->label(__('schedules.server_preference')) ->options(function () { - return Schedule::distinct() + return Schedule::query() ->get() ->pluck('options') ->map(function ($options) { @@ -109,9 +130,9 @@ public static function table(Table $table): Table ->mapWithKeys(function ($value) { return [ $value => match ($value) { - 'auto' => 'Automatic', - 'prefer' => 'Prefer Specific Servers', - 'ignore' => 'Ignore Specific Servers', + 'auto' => __('schedules.server_preference_auto'), + 'prefer' => __('schedules.server_preference_prefer'), + 'ignore' => __('schedules.server_preference_ignore'), default => $value, }, ]; @@ -119,24 +140,29 @@ public static function table(Table $table): Table ->toArray(); }) ->native(false), + SelectFilter::make('status') + ->label(__('schedules.status')) + ->options(ScheduleStatus::class) + ->native(false), + SelectFilter::make('created_by') + ->label(__('schedules.created_by')) + ->options(function () { + return User::query() + ->orderBy('name') + ->pluck('name', 'id') + ->toArray(); + }) + ->native(false), ]) ->actions([ ActionGroup::make([ EditAction::make(), Action::make('changeScheduleStatus') - ->label('Change Schedule Status') + ->label(__('schedules.change_schedule_status')) ->action(function ($record) { $record->update(['is_active' => ! $record->is_active]); }) ->icon('heroicon-c-arrow-path'), - Action::make('viewResults') - ->label('View Results') - ->action(function ($record) { - return redirect()->route('filament.admin.resources.results.index', [ - 'tableFilters[schedule_id][values][0]' => $record->id, - ]); - }) - ->icon('heroicon-s-eye'), DeleteAction::make(), ]), ]) diff --git a/app/Models/Schedule.php b/app/Models/Schedule.php index 805ef6837..863633adf 100644 --- a/app/Models/Schedule.php +++ b/app/Models/Schedule.php @@ -2,11 +2,12 @@ namespace App\Models; -use App\Actions\GetOoklaSpeedtestServers; +use App\Enums\ScheduleStatus; use App\Observers\ScheduleObserver; use Illuminate\Database\Eloquent\Attributes\ObservedBy; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; #[ObservedBy([ScheduleObserver::class])] @@ -31,15 +32,18 @@ protected function casts(): array return [ 'options' => 'array', 'is_active' => 'boolean', + 'status' => ScheduleStatus::class, 'next_run_at' => 'datetime', + 'last_run_at' => 'datetime', ]; } - public static function getServerLabel(string|int $serverId): string + /** + * Get the user who created this schedule. + */ + public function createdBy(): BelongsTo { - $lookup = GetOoklaSpeedtestServers::run(); - - return $lookup[$serverId] ?? "Server ID: {$serverId}"; + return $this->belongsTo(User::class, 'created_by'); } public function getServerTooltip(): ?string diff --git a/app/Models/Traits/HasOwner.php b/app/Models/Traits/HasOwner.php index 786a99703..e61d3bc3d 100644 --- a/app/Models/Traits/HasOwner.php +++ b/app/Models/Traits/HasOwner.php @@ -20,7 +20,15 @@ trait HasOwner */ public function ownedBy(): BelongsTo { - return $this->belongsTo(User::class, 'owned_by_id'); + return $this->belongsTo(User::class, 'created_by'); + } + + /** + * Creator of the model. + */ + public function createdBy(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); } /** @@ -36,7 +44,7 @@ public function owner(): BelongsTo */ public function hasOwner(): bool { - return ! is_null($this->owned_by_id); + return ! is_null($this->created_by); } /** @@ -48,7 +56,7 @@ public function isOwnedBy(User $owner): bool return false; } - return $owner->id === $this->owned_by_id; + return $owner->id === $this->created_by; } /** @@ -56,7 +64,7 @@ public function isOwnedBy(User $owner): bool */ public function scopeWhereOwnedBy(Builder $query, User $owner): Builder { - return $query->where('owned_by_id', '=', $owner->id); + return $query->where('created_by', '=', $owner->id); } /** @@ -64,6 +72,6 @@ public function scopeWhereOwnedBy(Builder $query, User $owner): Builder */ public function scopeWhereNotOwnedBy(Builder $query, User $owner): Builder { - return $query->where('owned_by_id', '!=', $owner->id); + return $query->where('created_by', '!=', $owner->id); } } diff --git a/app/Rules/NoCronOverlap.php b/app/Rules/NoCronOverlap.php index 9d70a4faa..81bd8cbde 100644 --- a/app/Rules/NoCronOverlap.php +++ b/app/Rules/NoCronOverlap.php @@ -31,7 +31,7 @@ public function passes($attribute, $value): bool ->where('type', $this->schedule->type) // Dynamically use the type from the schedule ->when($this->ignoreId, fn ($q) => $q->where('id', '!=', $this->ignoreId)) ->get() - ->pluck('options.cron_expression'); // Extract cron expressions + ->pluck('schedule'); // Extract cron expressions foreach ($existingCrons as $existingCron) { \Log::info('Comparing:', ['existing' => $existingCron, 'new' => $value]); diff --git a/composer.lock b/composer.lock index 2cdd63f47..e7c120218 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "3aff9923fe99afc6088082ec8c3be834", + "content-hash": "f655f6456a0a203c8274ac9c20c85ba6", "packages": [ { "name": "anourvalar/eloquent-serialize", @@ -4715,6 +4715,80 @@ ], "time": "2025-09-03T16:03:54+00:00" }, + { + "name": "orisai/cron-expression-explainer", + "version": "1.1.1", + "source": { + "type": "git", + "url": "https://github.com/orisai/cron-expression-explainer.git", + "reference": "f36c3405b36c7ac0c9e9837e5bfc3161579e0fc3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/orisai/cron-expression-explainer/zipball/f36c3405b36c7ac0c9e9837e5bfc3161579e0fc3", + "reference": "f36c3405b36c7ac0c9e9837e5bfc3161579e0fc3", + "shasum": "" + }, + "require": { + "dragonmantank/cron-expression": "^3.3.0", + "php": "7.4 - 8.4", + "symfony/intl": "^5.4.35|^6.4.3|^7.0.3", + "symfony/polyfill-php80": "^1.29" + }, + "require-dev": { + "brianium/paratest": "^6.3.0", + "infection/infection": "^0.26.0", + "nette/utils": "^3.1.0|^4.0.0", + "orisai/coding-standard": "^3.0.0", + "phpstan/extension-installer": "^1.0.0", + "phpstan/phpstan": "^1.0.0", + "phpstan/phpstan-deprecation-rules": "^1.0.0", + "phpstan/phpstan-phpunit": "^1.0.0", + "phpstan/phpstan-strict-rules": "^1.0.0", + "phpunit/phpunit": "^9.5.0", + "staabm/annotate-pull-request-from-checkstyle": "^1.7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Orisai\\CronExpressionExplainer\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MPL-2.0" + ], + "authors": [ + { + "name": "Marek Bartoš", + "homepage": "https://github.com/mabar" + } + ], + "description": "Human-readable cron expressions", + "homepage": "https://github.com/orisai/cron-expression-explainer", + "keywords": [ + "cron", + "crontab", + "frequency", + "i18n", + "internationalization", + "interval", + "l10n", + "localization", + "orisai", + "recurring", + "schedule", + "scheduler", + "scheduling", + "task", + "translation" + ], + "support": { + "issues": "https://github.com/orisai/cron-expression-explainer/issues", + "source": "https://github.com/orisai/cron-expression-explainer/tree/1.1.1" + }, + "time": "2024-06-20T21:57:28+00:00" + }, { "name": "paragonie/constant_time_encoding", "version": "v3.1.3", @@ -6832,16 +6906,16 @@ }, { "name": "symfony/clock", - "version": "v7.3.0", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/clock.git", - "reference": "b81435fbd6648ea425d1ee96a2d8e68f4ceacd24" + "reference": "9169f24776edde469914c1e7a1442a50f7a4e110" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/clock/zipball/b81435fbd6648ea425d1ee96a2d8e68f4ceacd24", - "reference": "b81435fbd6648ea425d1ee96a2d8e68f4ceacd24", + "url": "https://api.github.com/repos/symfony/clock/zipball/9169f24776edde469914c1e7a1442a50f7a4e110", + "reference": "9169f24776edde469914c1e7a1442a50f7a4e110", "shasum": "" }, "require": { @@ -6886,7 +6960,7 @@ "time" ], "support": { - "source": "https://github.com/symfony/clock/tree/v7.3.0" + "source": "https://github.com/symfony/clock/tree/v7.4.0" }, "funding": [ { @@ -6897,25 +6971,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-25T14:21:43+00:00" + "time": "2025-11-12T15:39:26+00:00" }, { "name": "symfony/console", - "version": "v7.3.6", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "c28ad91448f86c5f6d9d2c70f0cf68bf135f252a" + "reference": "0bc0f45254b99c58d45a8fbf9fb955d46cbd1bb8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/c28ad91448f86c5f6d9d2c70f0cf68bf135f252a", - "reference": "c28ad91448f86c5f6d9d2c70f0cf68bf135f252a", + "url": "https://api.github.com/repos/symfony/console/zipball/0bc0f45254b99c58d45a8fbf9fb955d46cbd1bb8", + "reference": "0bc0f45254b99c58d45a8fbf9fb955d46cbd1bb8", "shasum": "" }, "require": { @@ -6923,7 +7001,7 @@ "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-mbstring": "~1.0", "symfony/service-contracts": "^2.5|^3", - "symfony/string": "^7.2" + "symfony/string": "^7.2|^8.0" }, "conflict": { "symfony/dependency-injection": "<6.4", @@ -6937,16 +7015,16 @@ }, "require-dev": { "psr/log": "^1|^2|^3", - "symfony/config": "^6.4|^7.0", - "symfony/dependency-injection": "^6.4|^7.0", - "symfony/event-dispatcher": "^6.4|^7.0", - "symfony/http-foundation": "^6.4|^7.0", - "symfony/http-kernel": "^6.4|^7.0", - "symfony/lock": "^6.4|^7.0", - "symfony/messenger": "^6.4|^7.0", - "symfony/process": "^6.4|^7.0", - "symfony/stopwatch": "^6.4|^7.0", - "symfony/var-dumper": "^6.4|^7.0" + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/lock": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -6980,7 +7058,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.3.6" + "source": "https://github.com/symfony/console/tree/v7.4.0" }, "funding": [ { @@ -7000,20 +7078,20 @@ "type": "tidelift" } ], - "time": "2025-11-04T01:21:42+00:00" + "time": "2025-11-27T13:27:24+00:00" }, { "name": "symfony/css-selector", - "version": "v7.3.6", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", - "reference": "84321188c4754e64273b46b406081ad9b18e8614" + "reference": "ab862f478513e7ca2fe9ec117a6f01a8da6e1135" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/84321188c4754e64273b46b406081ad9b18e8614", - "reference": "84321188c4754e64273b46b406081ad9b18e8614", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/ab862f478513e7ca2fe9ec117a6f01a8da6e1135", + "reference": "ab862f478513e7ca2fe9ec117a6f01a8da6e1135", "shasum": "" }, "require": { @@ -7049,7 +7127,7 @@ "description": "Converts CSS selectors to XPath expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/css-selector/tree/v7.3.6" + "source": "https://github.com/symfony/css-selector/tree/v7.4.0" }, "funding": [ { @@ -7069,7 +7147,7 @@ "type": "tidelift" } ], - "time": "2025-10-29T17:24:25+00:00" + "time": "2025-10-30T13:39:42+00:00" }, { "name": "symfony/deprecation-contracts", @@ -7140,32 +7218,33 @@ }, { "name": "symfony/error-handler", - "version": "v7.3.6", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "bbe40bfab84323d99dab491b716ff142410a92a8" + "reference": "48be2b0653594eea32dcef130cca1c811dcf25c2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/bbe40bfab84323d99dab491b716ff142410a92a8", - "reference": "bbe40bfab84323d99dab491b716ff142410a92a8", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/48be2b0653594eea32dcef130cca1c811dcf25c2", + "reference": "48be2b0653594eea32dcef130cca1c811dcf25c2", "shasum": "" }, "require": { "php": ">=8.2", "psr/log": "^1|^2|^3", - "symfony/var-dumper": "^6.4|^7.0" + "symfony/polyfill-php85": "^1.32", + "symfony/var-dumper": "^6.4|^7.0|^8.0" }, "conflict": { "symfony/deprecation-contracts": "<2.5", "symfony/http-kernel": "<6.4" }, "require-dev": { - "symfony/console": "^6.4|^7.0", + "symfony/console": "^6.4|^7.0|^8.0", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/http-kernel": "^6.4|^7.0", - "symfony/serializer": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0", "symfony/webpack-encore-bundle": "^1.0|^2.0" }, "bin": [ @@ -7197,7 +7276,7 @@ "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v7.3.6" + "source": "https://github.com/symfony/error-handler/tree/v7.4.0" }, "funding": [ { @@ -7217,28 +7296,28 @@ "type": "tidelift" } ], - "time": "2025-10-31T19:12:50+00:00" + "time": "2025-11-05T14:29:59+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v7.3.3", + "version": "v8.0.0", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "b7dc69e71de420ac04bc9ab830cf3ffebba48191" + "reference": "573f95783a2ec6e38752979db139f09fec033f03" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/b7dc69e71de420ac04bc9ab830cf3ffebba48191", - "reference": "b7dc69e71de420ac04bc9ab830cf3ffebba48191", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/573f95783a2ec6e38752979db139f09fec033f03", + "reference": "573f95783a2ec6e38752979db139f09fec033f03", "shasum": "" }, "require": { - "php": ">=8.2", + "php": ">=8.4", "symfony/event-dispatcher-contracts": "^2.5|^3" }, "conflict": { - "symfony/dependency-injection": "<6.4", + "symfony/security-http": "<7.4", "symfony/service-contracts": "<2.5" }, "provide": { @@ -7247,13 +7326,14 @@ }, "require-dev": { "psr/log": "^1|^2|^3", - "symfony/config": "^6.4|^7.0", - "symfony/dependency-injection": "^6.4|^7.0", - "symfony/error-handler": "^6.4|^7.0", - "symfony/expression-language": "^6.4|^7.0", - "symfony/http-foundation": "^6.4|^7.0", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/error-handler": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/framework-bundle": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", "symfony/service-contracts": "^2.5|^3", - "symfony/stopwatch": "^6.4|^7.0" + "symfony/stopwatch": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -7281,7 +7361,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v7.3.3" + "source": "https://github.com/symfony/event-dispatcher/tree/v8.0.0" }, "funding": [ { @@ -7301,7 +7381,7 @@ "type": "tidelift" } ], - "time": "2025-08-13T11:49:31+00:00" + "time": "2025-10-30T14:17:19+00:00" }, { "name": "symfony/event-dispatcher-contracts", @@ -7381,23 +7461,23 @@ }, { "name": "symfony/finder", - "version": "v7.3.5", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "9f696d2f1e340484b4683f7853b273abff94421f" + "reference": "340b9ed7320570f319028a2cbec46d40535e94bd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/9f696d2f1e340484b4683f7853b273abff94421f", - "reference": "9f696d2f1e340484b4683f7853b273abff94421f", + "url": "https://api.github.com/repos/symfony/finder/zipball/340b9ed7320570f319028a2cbec46d40535e94bd", + "reference": "340b9ed7320570f319028a2cbec46d40535e94bd", "shasum": "" }, "require": { "php": ">=8.2" }, "require-dev": { - "symfony/filesystem": "^6.4|^7.0" + "symfony/filesystem": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -7425,7 +7505,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.3.5" + "source": "https://github.com/symfony/finder/tree/v7.4.0" }, "funding": [ { @@ -7445,27 +7525,28 @@ "type": "tidelift" } ], - "time": "2025-10-15T18:45:57+00:00" + "time": "2025-11-05T05:42:40+00:00" }, { "name": "symfony/html-sanitizer", - "version": "v7.3.6", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/html-sanitizer.git", - "reference": "3855e827adb1b675adcb98ad7f92681e293f2d77" + "reference": "5b0bbcc3600030b535dd0b17a0e8c56243f96d7f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/html-sanitizer/zipball/3855e827adb1b675adcb98ad7f92681e293f2d77", - "reference": "3855e827adb1b675adcb98ad7f92681e293f2d77", + "url": "https://api.github.com/repos/symfony/html-sanitizer/zipball/5b0bbcc3600030b535dd0b17a0e8c56243f96d7f", + "reference": "5b0bbcc3600030b535dd0b17a0e8c56243f96d7f", "shasum": "" }, "require": { "ext-dom": "*", "league/uri": "^6.5|^7.0", "masterminds/html5": "^2.7.2", - "php": ">=8.2" + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3" }, "type": "library", "autoload": { @@ -7498,7 +7579,7 @@ "sanitizer" ], "support": { - "source": "https://github.com/symfony/html-sanitizer/tree/v7.3.6" + "source": "https://github.com/symfony/html-sanitizer/tree/v7.4.0" }, "funding": [ { @@ -7518,27 +7599,26 @@ "type": "tidelift" } ], - "time": "2025-10-30T13:22:58+00:00" + "time": "2025-10-30T13:39:42+00:00" }, { "name": "symfony/http-foundation", - "version": "v7.3.7", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "db488a62f98f7a81d5746f05eea63a74e55bb7c4" + "reference": "769c1720b68e964b13b58529c17d4a385c62167b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/db488a62f98f7a81d5746f05eea63a74e55bb7c4", - "reference": "db488a62f98f7a81d5746f05eea63a74e55bb7c4", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/769c1720b68e964b13b58529c17d4a385c62167b", + "reference": "769c1720b68e964b13b58529c17d4a385c62167b", "shasum": "" }, "require": { "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3.0", - "symfony/polyfill-mbstring": "~1.1", - "symfony/polyfill-php83": "^1.27" + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "^1.1" }, "conflict": { "doctrine/dbal": "<3.6", @@ -7547,13 +7627,13 @@ "require-dev": { "doctrine/dbal": "^3.6|^4", "predis/predis": "^1.1|^2.0", - "symfony/cache": "^6.4.12|^7.1.5", - "symfony/clock": "^6.4|^7.0", - "symfony/dependency-injection": "^6.4|^7.0", - "symfony/expression-language": "^6.4|^7.0", - "symfony/http-kernel": "^6.4|^7.0", - "symfony/mime": "^6.4|^7.0", - "symfony/rate-limiter": "^6.4|^7.0" + "symfony/cache": "^6.4.12|^7.1.5|^8.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/mime": "^6.4|^7.0|^8.0", + "symfony/rate-limiter": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -7581,7 +7661,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v7.3.7" + "source": "https://github.com/symfony/http-foundation/tree/v7.4.0" }, "funding": [ { @@ -7601,29 +7681,29 @@ "type": "tidelift" } ], - "time": "2025-11-08T16:41:12+00:00" + "time": "2025-11-13T08:49:24+00:00" }, { "name": "symfony/http-kernel", - "version": "v7.3.7", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "10b8e9b748ea95fa4539c208e2487c435d3c87ce" + "reference": "7348193cd384495a755554382e4526f27c456085" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/10b8e9b748ea95fa4539c208e2487c435d3c87ce", - "reference": "10b8e9b748ea95fa4539c208e2487c435d3c87ce", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/7348193cd384495a755554382e4526f27c456085", + "reference": "7348193cd384495a755554382e4526f27c456085", "shasum": "" }, "require": { "php": ">=8.2", "psr/log": "^1|^2|^3", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/error-handler": "^6.4|^7.0", - "symfony/event-dispatcher": "^7.3", - "symfony/http-foundation": "^7.3", + "symfony/error-handler": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^7.3|^8.0", + "symfony/http-foundation": "^7.4|^8.0", "symfony/polyfill-ctype": "^1.8" }, "conflict": { @@ -7633,6 +7713,7 @@ "symfony/console": "<6.4", "symfony/dependency-injection": "<6.4", "symfony/doctrine-bridge": "<6.4", + "symfony/flex": "<2.10", "symfony/form": "<6.4", "symfony/http-client": "<6.4", "symfony/http-client-contracts": "<2.5", @@ -7650,27 +7731,27 @@ }, "require-dev": { "psr/cache": "^1.0|^2.0|^3.0", - "symfony/browser-kit": "^6.4|^7.0", - "symfony/clock": "^6.4|^7.0", - "symfony/config": "^6.4|^7.0", - "symfony/console": "^6.4|^7.0", - "symfony/css-selector": "^6.4|^7.0", - "symfony/dependency-injection": "^6.4|^7.0", - "symfony/dom-crawler": "^6.4|^7.0", - "symfony/expression-language": "^6.4|^7.0", - "symfony/finder": "^6.4|^7.0", + "symfony/browser-kit": "^6.4|^7.0|^8.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/css-selector": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/dom-crawler": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", "symfony/http-client-contracts": "^2.5|^3", - "symfony/process": "^6.4|^7.0", - "symfony/property-access": "^7.1", - "symfony/routing": "^6.4|^7.0", - "symfony/serializer": "^7.1", - "symfony/stopwatch": "^6.4|^7.0", - "symfony/translation": "^6.4|^7.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/property-access": "^7.1|^8.0", + "symfony/routing": "^6.4|^7.0|^8.0", + "symfony/serializer": "^7.1|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/translation": "^6.4|^7.0|^8.0", "symfony/translation-contracts": "^2.5|^3", - "symfony/uid": "^6.4|^7.0", - "symfony/validator": "^6.4|^7.0", - "symfony/var-dumper": "^6.4|^7.0", - "symfony/var-exporter": "^6.4|^7.0", + "symfony/uid": "^6.4|^7.0|^8.0", + "symfony/validator": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0", + "symfony/var-exporter": "^6.4|^7.0|^8.0", "twig/twig": "^3.12" }, "type": "library", @@ -7699,7 +7780,97 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v7.3.7" + "source": "https://github.com/symfony/http-kernel/tree/v7.4.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-11-27T13:38:24+00:00" + }, + { + "name": "symfony/intl", + "version": "v7.4.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/intl.git", + "reference": "2fa074de6c7faa6b54f2891fc22708f42245ed5c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/intl/zipball/2fa074de6c7faa6b54f2891fc22708f42245ed5c", + "reference": "2fa074de6c7faa6b54f2891fc22708f42245ed5c", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/string": "<7.1" + }, + "require-dev": { + "symfony/filesystem": "^6.4|^7.0|^8.0", + "symfony/var-exporter": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Intl\\": "" + }, + "exclude-from-classmap": [ + "/Tests/", + "/Resources/data/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + }, + { + "name": "Eriksen Costa", + "email": "eriksen.costa@infranology.com.br" + }, + { + "name": "Igor Wiedler", + "email": "igor@wiedler.ch" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides access to the localization data of the ICU library", + "homepage": "https://symfony.com", + "keywords": [ + "i18n", + "icu", + "internationalization", + "intl", + "l10n", + "localization" + ], + "support": { + "source": "https://github.com/symfony/intl/tree/v7.4.0" }, "funding": [ { @@ -7719,20 +7890,20 @@ "type": "tidelift" } ], - "time": "2025-11-12T11:38:40+00:00" + "time": "2025-11-27T13:27:24+00:00" }, { "name": "symfony/mailer", - "version": "v7.3.5", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "fd497c45ba9c10c37864e19466b090dcb60a50ba" + "reference": "a3d9eea8cfa467ece41f0f54ba28185d74bd53fd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/fd497c45ba9c10c37864e19466b090dcb60a50ba", - "reference": "fd497c45ba9c10c37864e19466b090dcb60a50ba", + "url": "https://api.github.com/repos/symfony/mailer/zipball/a3d9eea8cfa467ece41f0f54ba28185d74bd53fd", + "reference": "a3d9eea8cfa467ece41f0f54ba28185d74bd53fd", "shasum": "" }, "require": { @@ -7740,8 +7911,8 @@ "php": ">=8.2", "psr/event-dispatcher": "^1", "psr/log": "^1|^2|^3", - "symfony/event-dispatcher": "^6.4|^7.0", - "symfony/mime": "^7.2", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/mime": "^7.2|^8.0", "symfony/service-contracts": "^2.5|^3" }, "conflict": { @@ -7752,10 +7923,10 @@ "symfony/twig-bridge": "<6.4" }, "require-dev": { - "symfony/console": "^6.4|^7.0", - "symfony/http-client": "^6.4|^7.0", - "symfony/messenger": "^6.4|^7.0", - "symfony/twig-bridge": "^6.4|^7.0" + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/twig-bridge": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -7783,7 +7954,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v7.3.5" + "source": "https://github.com/symfony/mailer/tree/v7.4.0" }, "funding": [ { @@ -7803,24 +7974,25 @@ "type": "tidelift" } ], - "time": "2025-10-24T14:27:20+00:00" + "time": "2025-11-21T15:26:00+00:00" }, { "name": "symfony/mime", - "version": "v7.3.4", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "b1b828f69cbaf887fa835a091869e55df91d0e35" + "reference": "bdb02729471be5d047a3ac4a69068748f1a6be7a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/b1b828f69cbaf887fa835a091869e55df91d0e35", - "reference": "b1b828f69cbaf887fa835a091869e55df91d0e35", + "url": "https://api.github.com/repos/symfony/mime/zipball/bdb02729471be5d047a3ac4a69068748f1a6be7a", + "reference": "bdb02729471be5d047a3ac4a69068748f1a6be7a", "shasum": "" }, "require": { "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-intl-idn": "^1.10", "symfony/polyfill-mbstring": "^1.0" }, @@ -7835,11 +8007,11 @@ "egulias/email-validator": "^2.1.10|^3.1|^4", "league/html-to-markdown": "^5.0", "phpdocumentor/reflection-docblock": "^3.0|^4.0|^5.0", - "symfony/dependency-injection": "^6.4|^7.0", - "symfony/process": "^6.4|^7.0", - "symfony/property-access": "^6.4|^7.0", - "symfony/property-info": "^6.4|^7.0", - "symfony/serializer": "^6.4.3|^7.0.3" + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/property-info": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4.3|^7.0.3|^8.0" }, "type": "library", "autoload": { @@ -7871,7 +8043,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v7.3.4" + "source": "https://github.com/symfony/mime/tree/v7.4.0" }, "funding": [ { @@ -7891,20 +8063,20 @@ "type": "tidelift" } ], - "time": "2025-09-16T08:38:17+00:00" + "time": "2025-11-16T10:14:42+00:00" }, { "name": "symfony/options-resolver", - "version": "v7.3.3", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/options-resolver.git", - "reference": "0ff2f5c3df08a395232bbc3c2eb7e84912df911d" + "reference": "b38026df55197f9e39a44f3215788edf83187b80" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/options-resolver/zipball/0ff2f5c3df08a395232bbc3c2eb7e84912df911d", - "reference": "0ff2f5c3df08a395232bbc3c2eb7e84912df911d", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/b38026df55197f9e39a44f3215788edf83187b80", + "reference": "b38026df55197f9e39a44f3215788edf83187b80", "shasum": "" }, "require": { @@ -7942,7 +8114,7 @@ "options" ], "support": { - "source": "https://github.com/symfony/options-resolver/tree/v7.3.3" + "source": "https://github.com/symfony/options-resolver/tree/v7.4.0" }, "funding": [ { @@ -7962,7 +8134,7 @@ "type": "tidelift" } ], - "time": "2025-08-05T10:16:07+00:00" + "time": "2025-11-12T15:39:26+00:00" }, { "name": "symfony/polyfill-ctype", @@ -8795,16 +8967,16 @@ }, { "name": "symfony/process", - "version": "v7.3.4", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "f24f8f316367b30810810d4eb30c543d7003ff3b" + "reference": "7ca8dc2d0dcf4882658313aba8be5d9fd01026c8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/f24f8f316367b30810810d4eb30c543d7003ff3b", - "reference": "f24f8f316367b30810810d4eb30c543d7003ff3b", + "url": "https://api.github.com/repos/symfony/process/zipball/7ca8dc2d0dcf4882658313aba8be5d9fd01026c8", + "reference": "7ca8dc2d0dcf4882658313aba8be5d9fd01026c8", "shasum": "" }, "require": { @@ -8836,7 +9008,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v7.3.4" + "source": "https://github.com/symfony/process/tree/v7.4.0" }, "funding": [ { @@ -8856,20 +9028,20 @@ "type": "tidelift" } ], - "time": "2025-09-11T10:12:26+00:00" + "time": "2025-10-16T11:21:06+00:00" }, { "name": "symfony/routing", - "version": "v7.3.6", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "c97abe725f2a1a858deca629a6488c8fc20c3091" + "reference": "4720254cb2644a0b876233d258a32bf017330db7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/c97abe725f2a1a858deca629a6488c8fc20c3091", - "reference": "c97abe725f2a1a858deca629a6488c8fc20c3091", + "url": "https://api.github.com/repos/symfony/routing/zipball/4720254cb2644a0b876233d258a32bf017330db7", + "reference": "4720254cb2644a0b876233d258a32bf017330db7", "shasum": "" }, "require": { @@ -8883,11 +9055,11 @@ }, "require-dev": { "psr/log": "^1|^2|^3", - "symfony/config": "^6.4|^7.0", - "symfony/dependency-injection": "^6.4|^7.0", - "symfony/expression-language": "^6.4|^7.0", - "symfony/http-foundation": "^6.4|^7.0", - "symfony/yaml": "^6.4|^7.0" + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/yaml": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -8921,7 +9093,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v7.3.6" + "source": "https://github.com/symfony/routing/tree/v7.4.0" }, "funding": [ { @@ -8941,7 +9113,7 @@ "type": "tidelift" } ], - "time": "2025-11-05T07:57:47+00:00" + "time": "2025-11-27T13:27:24+00:00" }, { "name": "symfony/service-contracts", @@ -9032,34 +9204,34 @@ }, { "name": "symfony/string", - "version": "v7.3.4", + "version": "v8.0.0", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "f96476035142921000338bad71e5247fbc138872" + "reference": "f929eccf09531078c243df72398560e32fa4cf4f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/f96476035142921000338bad71e5247fbc138872", - "reference": "f96476035142921000338bad71e5247fbc138872", + "url": "https://api.github.com/repos/symfony/string/zipball/f929eccf09531078c243df72398560e32fa4cf4f", + "reference": "f929eccf09531078c243df72398560e32fa4cf4f", "shasum": "" }, "require": { - "php": ">=8.2", - "symfony/polyfill-ctype": "~1.8", - "symfony/polyfill-intl-grapheme": "~1.0", - "symfony/polyfill-intl-normalizer": "~1.0", - "symfony/polyfill-mbstring": "~1.0" + "php": ">=8.4", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-intl-grapheme": "^1.33", + "symfony/polyfill-intl-normalizer": "^1.0", + "symfony/polyfill-mbstring": "^1.0" }, "conflict": { "symfony/translation-contracts": "<2.5" }, "require-dev": { - "symfony/emoji": "^7.1", - "symfony/http-client": "^6.4|^7.0", - "symfony/intl": "^6.4|^7.0", + "symfony/emoji": "^7.4|^8.0", + "symfony/http-client": "^7.4|^8.0", + "symfony/intl": "^7.4|^8.0", "symfony/translation-contracts": "^2.5|^3.0", - "symfony/var-exporter": "^6.4|^7.0" + "symfony/var-exporter": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -9098,7 +9270,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v7.3.4" + "source": "https://github.com/symfony/string/tree/v8.0.0" }, "funding": [ { @@ -9118,27 +9290,27 @@ "type": "tidelift" } ], - "time": "2025-09-11T14:36:48+00:00" + "time": "2025-09-11T14:37:55+00:00" }, { "name": "symfony/translation", - "version": "v7.3.4", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "ec25870502d0c7072d086e8ffba1420c85965174" + "reference": "2d01ca0da3f092f91eeedb46f24aa30d2fca8f68" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/ec25870502d0c7072d086e8ffba1420c85965174", - "reference": "ec25870502d0c7072d086e8ffba1420c85965174", + "url": "https://api.github.com/repos/symfony/translation/zipball/2d01ca0da3f092f91eeedb46f24aa30d2fca8f68", + "reference": "2d01ca0da3f092f91eeedb46f24aa30d2fca8f68", "shasum": "" }, "require": { "php": ">=8.2", "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-mbstring": "~1.0", - "symfony/translation-contracts": "^2.5|^3.0" + "symfony/translation-contracts": "^2.5.3|^3.3" }, "conflict": { "nikic/php-parser": "<5.0", @@ -9157,17 +9329,17 @@ "require-dev": { "nikic/php-parser": "^5.0", "psr/log": "^1|^2|^3", - "symfony/config": "^6.4|^7.0", - "symfony/console": "^6.4|^7.0", - "symfony/dependency-injection": "^6.4|^7.0", - "symfony/finder": "^6.4|^7.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", "symfony/http-client-contracts": "^2.5|^3.0", - "symfony/http-kernel": "^6.4|^7.0", - "symfony/intl": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/intl": "^6.4|^7.0|^8.0", "symfony/polyfill-intl-icu": "^1.21", - "symfony/routing": "^6.4|^7.0", + "symfony/routing": "^6.4|^7.0|^8.0", "symfony/service-contracts": "^2.5|^3", - "symfony/yaml": "^6.4|^7.0" + "symfony/yaml": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -9198,7 +9370,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v7.3.4" + "source": "https://github.com/symfony/translation/tree/v7.4.0" }, "funding": [ { @@ -9218,7 +9390,7 @@ "type": "tidelift" } ], - "time": "2025-09-07T11:39:36+00:00" + "time": "2025-11-27T13:27:24+00:00" }, { "name": "symfony/translation-contracts", @@ -9304,16 +9476,16 @@ }, { "name": "symfony/uid", - "version": "v7.3.1", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/uid.git", - "reference": "a69f69f3159b852651a6bf45a9fdd149520525bb" + "reference": "2498e9f81b7baa206f44de583f2f48350b90142c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/uid/zipball/a69f69f3159b852651a6bf45a9fdd149520525bb", - "reference": "a69f69f3159b852651a6bf45a9fdd149520525bb", + "url": "https://api.github.com/repos/symfony/uid/zipball/2498e9f81b7baa206f44de583f2f48350b90142c", + "reference": "2498e9f81b7baa206f44de583f2f48350b90142c", "shasum": "" }, "require": { @@ -9321,7 +9493,7 @@ "symfony/polyfill-uuid": "^1.15" }, "require-dev": { - "symfony/console": "^6.4|^7.0" + "symfony/console": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -9358,7 +9530,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/uid/tree/v7.3.1" + "source": "https://github.com/symfony/uid/tree/v7.4.0" }, "funding": [ { @@ -9369,25 +9541,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-27T19:55:54+00:00" + "time": "2025-09-25T11:02:55+00:00" }, { "name": "symfony/var-dumper", - "version": "v7.3.5", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "476c4ae17f43a9a36650c69879dcf5b1e6ae724d" + "reference": "41fd6c4ae28c38b294b42af6db61446594a0dece" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/476c4ae17f43a9a36650c69879dcf5b1e6ae724d", - "reference": "476c4ae17f43a9a36650c69879dcf5b1e6ae724d", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/41fd6c4ae28c38b294b42af6db61446594a0dece", + "reference": "41fd6c4ae28c38b294b42af6db61446594a0dece", "shasum": "" }, "require": { @@ -9399,10 +9575,10 @@ "symfony/console": "<6.4" }, "require-dev": { - "symfony/console": "^6.4|^7.0", - "symfony/http-kernel": "^6.4|^7.0", - "symfony/process": "^6.4|^7.0", - "symfony/uid": "^6.4|^7.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/uid": "^6.4|^7.0|^8.0", "twig/twig": "^3.12" }, "bin": [ @@ -9441,7 +9617,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.3.5" + "source": "https://github.com/symfony/var-dumper/tree/v7.4.0" }, "funding": [ { @@ -9461,32 +9637,32 @@ "type": "tidelift" } ], - "time": "2025-09-27T09:00:46+00:00" + "time": "2025-10-27T20:36:44+00:00" }, { "name": "symfony/yaml", - "version": "v7.3.5", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "90208e2fc6f68f613eae7ca25a2458a931b1bacc" + "reference": "6c84a4b55aee4cd02034d1c528e83f69ddf63810" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/90208e2fc6f68f613eae7ca25a2458a931b1bacc", - "reference": "90208e2fc6f68f613eae7ca25a2458a931b1bacc", + "url": "https://api.github.com/repos/symfony/yaml/zipball/6c84a4b55aee4cd02034d1c528e83f69ddf63810", + "reference": "6c84a4b55aee4cd02034d1c528e83f69ddf63810", "shasum": "" }, "require": { "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3.0", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-ctype": "^1.8" }, "conflict": { "symfony/console": "<6.4" }, "require-dev": { - "symfony/console": "^6.4|^7.0" + "symfony/console": "^6.4|^7.0|^8.0" }, "bin": [ "Resources/bin/yaml-lint" @@ -9517,7 +9693,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v7.3.5" + "source": "https://github.com/symfony/yaml/tree/v7.4.0" }, "funding": [ { @@ -9537,7 +9713,7 @@ "type": "tidelift" } ], - "time": "2025-09-27T09:00:46+00:00" + "time": "2025-11-16T10:14:42+00:00" }, { "name": "tijsverkoyen/css-to-inline-styles", diff --git a/database/migrations/2025_11_20_205859_create_schedules_table..php b/database/migrations/2025_11_20_205859_create_schedules_table..php index 69d2ca869..c588c0543 100644 --- a/database/migrations/2025_11_20_205859_create_schedules_table..php +++ b/database/migrations/2025_11_20_205859_create_schedules_table..php @@ -13,13 +13,16 @@ public function up(): void { Schema::create('schedules', function (Blueprint $table) { $table->id(); - $table->foreignId('owned_by_id')->nullable(); - $table->string('type')->nullable(); + $table->string('type')->default('ookla'); $table->string('name')->nullable(); $table->text('description')->nullable(); + $table->string('schedule')->nullable(); $table->json('options')->nullable(); $table->boolean('is_active')->default(true); + $table->string('status')->default('not_tested'); $table->dateTime('next_run_at')->nullable(); + $table->dateTime('last_run_at')->nullable(); + $table->foreignId('created_by')->nullable(); $table->timestamps(); }); } diff --git a/lang/en/schedules.php b/lang/en/schedules.php new file mode 100644 index 000000000..90912e89e --- /dev/null +++ b/lang/en/schedules.php @@ -0,0 +1,58 @@ + 'Schedules', + + // Form labels + 'active' => 'Active', + 'name' => 'Name', + 'name_placeholder' => 'Enter a name for the test.', + 'description' => 'Description', + 'type' => 'Type', + + // Schedule tab + 'schedule' => 'Schedule', + 'schedule_placeholder' => 'Enter a cron expression.', + 'next_run_at' => 'Next Run At', + + // Servers tab + 'servers' => 'Servers', + 'server_preference' => 'Server Preference', + 'server_preference_auto' => 'Automatically select a server', + 'server_preference_prefer' => 'Prefer servers from the list', + 'server_preference_ignore' => 'Ignore servers from the list', + 'server_id' => 'Server ID', + 'server_id_placeholder' => 'Enter the ID of the server.', + + // Advanced tab + 'advanced' => 'Advanced', + 'skip_ips' => 'Skip IP addresses', + 'skip_ips_placeholder' => '8.8.8.8', + 'skip_ips_helper' => 'Add external IP addresses that should be skipped.', + 'network_interface' => 'Network Interface', + 'network_interface_placeholder' => 'eth0', + 'network_interface_helper' => 'Set the network interface to use for the test. This need to be the network interface available inside the container', + + // Table columns + 'id' => 'ID', + 'created_by' => 'Created By', + 'status' => 'Status', + 'next_run_at' => 'Next Run At', + 'last_run_at' => 'Last Run At', + + // Filters + 'active_schedules_only' => 'Active schedules only', + 'inactive_schedules_only' => 'Inactive schedules only', + + // Actions + 'change_schedule_status' => 'Change Schedule Status', + 'view_results' => 'View Results', + + // Details section + 'details' => 'Details', + 'options' => 'Options', + + // Notifications + 'overlap_detected' => 'Schedule Overlap Detected', + 'overlap_body' => 'The cron expression for this schedule overlaps with the following active schedule ids: :ids.', +]; From 96ec62ff959a53edde05f773f56f5d26c9eab167 Mon Sep 17 00:00:00 2001 From: sveng93 Date: Mon, 1 Dec 2025 17:25:04 +0100 Subject: [PATCH 07/15] add updating --- app/Observers/ScheduleObserver.php | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/app/Observers/ScheduleObserver.php b/app/Observers/ScheduleObserver.php index 5b8386ae8..08505fd75 100644 --- a/app/Observers/ScheduleObserver.php +++ b/app/Observers/ScheduleObserver.php @@ -26,13 +26,23 @@ public function created(Schedule $schedule): void CheckCronOverlap::run($schedule); } + /** + * Handle the Schedule "updating" event. + */ + public function updating(Schedule $schedule): void + { + if ($schedule->isDirty('cron_schedule')) { + UpdateNextRun::run($schedule); + CheckCronOverlap::run($schedule); + } + } + /** * Handle the Schedule "updated" event. */ public function updated(Schedule $schedule): void { - UpdateNextRun::run($schedule); - CheckCronOverlap::run($schedule); + // } /** From 776c010dba244a53ac13fd37cc13b6149e3bf68a Mon Sep 17 00:00:00 2001 From: sveng93 Date: Mon, 1 Dec 2025 17:28:37 +0100 Subject: [PATCH 08/15] add enums lang strings --- app/Enums/ScheduleStatus.php | 8 ++++---- lang/en/enums.php | 8 ++++++++ 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/app/Enums/ScheduleStatus.php b/app/Enums/ScheduleStatus.php index 5e9e7b672..ad81fb517 100644 --- a/app/Enums/ScheduleStatus.php +++ b/app/Enums/ScheduleStatus.php @@ -25,10 +25,10 @@ public function getColor(): ?string public function getLabel(): ?string { return match ($this) { - self::Healthy => 'Healthy', - self::Unhealthy => 'Unhealthy', - self::Failed => 'Failed', - self::NotTested => 'Not Tested', + self::Healthy => __('enums.schedule_status.healthy'), + self::Unhealthy => __('enums.schedule_status.unhealthy'), + self::Failed => __('enums.schedule_status.failed'), + self::NotTested => __('enums.schedule_status.not_tested'), }; } } diff --git a/lang/en/enums.php b/lang/en/enums.php index c1ff432af..95ebfe540 100644 --- a/lang/en/enums.php +++ b/lang/en/enums.php @@ -18,4 +18,12 @@ 'faker' => 'Faker', 'ookla' => 'Ookla', ], + + // Schedule status enum values + 'schedule_status' => [ + 'healthy' => 'Healthy', + 'unhealthy' => 'Unhealthy', + 'failed' => 'Failed', + 'not_tested' => 'Not Tested', + ], ]; From 935034281e7bdf59ceebffc793a7ec7770ae5388 Mon Sep 17 00:00:00 2001 From: sveng93 Date: Thu, 4 Dec 2025 20:08:46 +0100 Subject: [PATCH 09/15] use tags --- .../Resources/Schedules/Schemas/ScheduleForm.php | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/app/Filament/Resources/Schedules/Schemas/ScheduleForm.php b/app/Filament/Resources/Schedules/Schemas/ScheduleForm.php index c08cd1067..9081cd2ab 100644 --- a/app/Filament/Resources/Schedules/Schemas/ScheduleForm.php +++ b/app/Filament/Resources/Schedules/Schemas/ScheduleForm.php @@ -94,13 +94,9 @@ public static function schema(): array ->required() ->live(), - Repeater::make('options.servers') - ->schema([ - TextInput::make('server_id') - ->label(__('schedules.server_id')) - ->placeholder(__('schedules.server_id_placeholder')) - ->required(), - ]) + TagsInput::make('options.servers') + ->label(__('schedules.server_id')) + ->placeholder(__('schedules.server_id_placeholder')) ->hidden(fn (Get $get) => $get('options.server_preference') === 'auto'), ]), From 3692f107e2785167a8becf310a134db6ec7826dc Mon Sep 17 00:00:00 2001 From: sveng93 Date: Tue, 9 Dec 2025 14:42:33 +0100 Subject: [PATCH 10/15] Add schedule name to the resutls table modal --- app/Filament/Resources/Results/Schemas/ResultForm.php | 3 +++ lang/en/results.php | 1 + 2 files changed, 4 insertions(+) diff --git a/app/Filament/Resources/Results/Schemas/ResultForm.php b/app/Filament/Resources/Results/Schemas/ResultForm.php index 3e2e5b852..2c382cfd1 100644 --- a/app/Filament/Resources/Results/Schemas/ResultForm.php +++ b/app/Filament/Resources/Results/Schemas/ResultForm.php @@ -121,6 +121,9 @@ public static function schema(): array TextEntry::make('comment') ->label(__('general.comment')) ->state(fn (Result $result): ?string => $result->comments), + TextEntry::make('schedule.name') + ->label(__('results.schedule')) + ->state(fn (Result $result): ?string => $result->schedule?->name), Checkbox::make('scheduled') ->label(__('results.scheduled')), Checkbox::make('healthy') diff --git a/lang/en/results.php b/lang/en/results.php index 8b625d37a..7ce33844e 100644 --- a/lang/en/results.php +++ b/lang/en/results.php @@ -37,6 +37,7 @@ 'isp' => 'ISP', 'ip_address' => 'IP address', 'scheduled' => 'Scheduled', + 'schedule' => 'Schedule', // Filters 'only_healthy_speedtests' => 'Only healthy speedtests', From cee44d833043eb17661e55faa9e5c050bec44111 Mon Sep 17 00:00:00 2001 From: sveng93 Date: Tue, 9 Dec 2025 14:48:11 +0100 Subject: [PATCH 11/15] update table --- .../Schedules/Tables/ScheduleTable.php | 8 - composer.lock | 166 +++++++++++++++++- 2 files changed, 165 insertions(+), 9 deletions(-) diff --git a/app/Filament/Resources/Schedules/Tables/ScheduleTable.php b/app/Filament/Resources/Schedules/Tables/ScheduleTable.php index 7a175e55b..186b1d002 100644 --- a/app/Filament/Resources/Schedules/Tables/ScheduleTable.php +++ b/app/Filament/Resources/Schedules/Tables/ScheduleTable.php @@ -87,15 +87,7 @@ public static function table(Table $table): Table ->dateTime() ->sortable() ->toggleable(isToggledHiddenByDefault: false), - TextColumn::make('updated_at') - ->label(__('general.updated_at')) - ->alignEnd() - ->dateTime() - ->sortable() - ->toggleable(isToggledHiddenByDefault: true), ]) - ->deferFilters(false) - ->deferColumnManager(false) ->filters([ SelectFilter::make('type') ->label(__('schedules.type')) diff --git a/composer.lock b/composer.lock index 23ff73cb5..835dbcd55 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "405d221f03e4de1894ce759a9e751448", + "content-hash": "41cc2150174e1c562ec6ad587a36b9af", "packages": [ { "name": "anourvalar/eloquent-serialize", @@ -4715,6 +4715,80 @@ ], "time": "2025-09-03T16:03:54+00:00" }, + { + "name": "orisai/cron-expression-explainer", + "version": "1.1.1", + "source": { + "type": "git", + "url": "https://github.com/orisai/cron-expression-explainer.git", + "reference": "f36c3405b36c7ac0c9e9837e5bfc3161579e0fc3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/orisai/cron-expression-explainer/zipball/f36c3405b36c7ac0c9e9837e5bfc3161579e0fc3", + "reference": "f36c3405b36c7ac0c9e9837e5bfc3161579e0fc3", + "shasum": "" + }, + "require": { + "dragonmantank/cron-expression": "^3.3.0", + "php": "7.4 - 8.4", + "symfony/intl": "^5.4.35|^6.4.3|^7.0.3", + "symfony/polyfill-php80": "^1.29" + }, + "require-dev": { + "brianium/paratest": "^6.3.0", + "infection/infection": "^0.26.0", + "nette/utils": "^3.1.0|^4.0.0", + "orisai/coding-standard": "^3.0.0", + "phpstan/extension-installer": "^1.0.0", + "phpstan/phpstan": "^1.0.0", + "phpstan/phpstan-deprecation-rules": "^1.0.0", + "phpstan/phpstan-phpunit": "^1.0.0", + "phpstan/phpstan-strict-rules": "^1.0.0", + "phpunit/phpunit": "^9.5.0", + "staabm/annotate-pull-request-from-checkstyle": "^1.7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Orisai\\CronExpressionExplainer\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MPL-2.0" + ], + "authors": [ + { + "name": "Marek Bartoš", + "homepage": "https://github.com/mabar" + } + ], + "description": "Human-readable cron expressions", + "homepage": "https://github.com/orisai/cron-expression-explainer", + "keywords": [ + "cron", + "crontab", + "frequency", + "i18n", + "internationalization", + "interval", + "l10n", + "localization", + "orisai", + "recurring", + "schedule", + "scheduler", + "scheduling", + "task", + "translation" + ], + "support": { + "issues": "https://github.com/orisai/cron-expression-explainer/issues", + "source": "https://github.com/orisai/cron-expression-explainer/tree/1.1.1" + }, + "time": "2024-06-20T21:57:28+00:00" + }, { "name": "paragonie/constant_time_encoding", "version": "v3.1.3", @@ -7942,6 +8016,96 @@ ], "time": "2025-11-27T13:38:24+00:00" }, + { + "name": "symfony/intl", + "version": "v7.4.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/intl.git", + "reference": "2fa074de6c7faa6b54f2891fc22708f42245ed5c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/intl/zipball/2fa074de6c7faa6b54f2891fc22708f42245ed5c", + "reference": "2fa074de6c7faa6b54f2891fc22708f42245ed5c", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/string": "<7.1" + }, + "require-dev": { + "symfony/filesystem": "^6.4|^7.0|^8.0", + "symfony/var-exporter": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Intl\\": "" + }, + "exclude-from-classmap": [ + "/Tests/", + "/Resources/data/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + }, + { + "name": "Eriksen Costa", + "email": "eriksen.costa@infranology.com.br" + }, + { + "name": "Igor Wiedler", + "email": "igor@wiedler.ch" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides access to the localization data of the ICU library", + "homepage": "https://symfony.com", + "keywords": [ + "i18n", + "icu", + "internationalization", + "intl", + "l10n", + "localization" + ], + "support": { + "source": "https://github.com/symfony/intl/tree/v7.4.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-11-27T13:27:24+00:00" + }, { "name": "symfony/mailer", "version": "v7.4.0", From 4da2232ec83a0866c761a6fce5785004ca1135f6 Mon Sep 17 00:00:00 2001 From: sveng93 Date: Thu, 11 Dec 2025 19:45:48 +0100 Subject: [PATCH 12/15] Fix UpdateNextRun at update --- app/Observers/ScheduleObserver.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/Observers/ScheduleObserver.php b/app/Observers/ScheduleObserver.php index 08505fd75..534303fc3 100644 --- a/app/Observers/ScheduleObserver.php +++ b/app/Observers/ScheduleObserver.php @@ -42,7 +42,8 @@ public function updating(Schedule $schedule): void */ public function updated(Schedule $schedule): void { - // + UpdateNextRun::run($schedule); + CheckCronOverlap::run($schedule); } /** From fd0bba9120c6f25ec8cf50fcbfa1bf9835447e0e Mon Sep 17 00:00:00 2001 From: sveng93 Date: Thu, 11 Dec 2025 19:53:39 +0100 Subject: [PATCH 13/15] Add lang strings --- app/Filament/Resources/Schedules/ScheduleResource.php | 6 +++--- lang/en/schedules.php | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/app/Filament/Resources/Schedules/ScheduleResource.php b/app/Filament/Resources/Schedules/ScheduleResource.php index 2ea7e8d88..1dfad5c7a 100644 --- a/app/Filament/Resources/Schedules/ScheduleResource.php +++ b/app/Filament/Resources/Schedules/ScheduleResource.php @@ -23,17 +23,17 @@ class ScheduleResource extends Resource public static function getNavigationLabel(): string { - return 'Schedules'; + return __('schedules.title'); } public static function getModelLabel(): string { - return 'Schedule'; + return __('schedules.singular'); } public static function getPluralModelLabel(): string { - return 'Schedules'; + return __('schedules.title'); } public static function canAccess(): bool diff --git a/lang/en/schedules.php b/lang/en/schedules.php index 90912e89e..92ef4f3b4 100644 --- a/lang/en/schedules.php +++ b/lang/en/schedules.php @@ -2,6 +2,7 @@ return [ 'title' => 'Schedules', + 'singular' => 'Schedule', // Form labels 'active' => 'Active', From a282d6694149f52bdbe80b3078a10adcc7734bcc Mon Sep 17 00:00:00 2001 From: sveng93 Date: Thu, 11 Dec 2025 20:10:25 +0100 Subject: [PATCH 14/15] update text and remove unneeded colunm --- .../Schedules/Pages/EditSchedule.php | 5 +++ .../Schedules/Tables/ScheduleTable.php | 37 ------------------- lang/en/schedules.php | 6 +-- 3 files changed, 8 insertions(+), 40 deletions(-) diff --git a/app/Filament/Resources/Schedules/Pages/EditSchedule.php b/app/Filament/Resources/Schedules/Pages/EditSchedule.php index 7fbd47816..68c42c33a 100644 --- a/app/Filament/Resources/Schedules/Pages/EditSchedule.php +++ b/app/Filament/Resources/Schedules/Pages/EditSchedule.php @@ -16,4 +16,9 @@ protected function getHeaderActions(): array DeleteAction::make(), ]; } + + protected function getRedirectUrl(): string + { + return $this->getResource()::getUrl('index'); + } } diff --git a/app/Filament/Resources/Schedules/Tables/ScheduleTable.php b/app/Filament/Resources/Schedules/Tables/ScheduleTable.php index 186b1d002..1c0a42ac1 100644 --- a/app/Filament/Resources/Schedules/Tables/ScheduleTable.php +++ b/app/Filament/Resources/Schedules/Tables/ScheduleTable.php @@ -42,19 +42,6 @@ public static function table(Table $table): Table ->sortable() ->toggleable(isToggledHiddenByDefault: false) ->formatStateUsing(fn (?string $state) => ExplainCronExpression::run($state)), - TextColumn::make('options.server_preference') - ->label(__('schedules.server_preference')) - ->sortable() - ->toggleable(isToggledHiddenByDefault: true) - ->formatStateUsing(function (?string $state) { - return match ($state) { - 'auto' => __('schedules.server_preference_auto'), - 'prefer' => __('schedules.server_preference_prefer'), - 'ignore' => __('schedules.server_preference_ignore'), - default => $state, - }; - }) - ->tooltip(fn ($record) => $record->getServerTooltip()), IconColumn::make('is_active') ->label(__('schedules.active')) ->alignCenter() @@ -108,30 +95,6 @@ public static function table(Table $table): Table blank: fn (Builder $query) => $query, ) ->native(false), - SelectFilter::make('options.server_preference') - ->label(__('schedules.server_preference')) - ->options(function () { - return Schedule::query() - ->get() - ->pluck('options') - ->map(function ($options) { - return $options['server_preference'] ?? null; - }) - ->filter() - ->unique() - ->mapWithKeys(function ($value) { - return [ - $value => match ($value) { - 'auto' => __('schedules.server_preference_auto'), - 'prefer' => __('schedules.server_preference_prefer'), - 'ignore' => __('schedules.server_preference_ignore'), - default => $value, - }, - ]; - }) - ->toArray(); - }) - ->native(false), SelectFilter::make('status') ->label(__('schedules.status')) ->options(ScheduleStatus::class) diff --git a/lang/en/schedules.php b/lang/en/schedules.php index 92ef4f3b4..d15cdda37 100644 --- a/lang/en/schedules.php +++ b/lang/en/schedules.php @@ -19,9 +19,9 @@ // Servers tab 'servers' => 'Servers', 'server_preference' => 'Server Preference', - 'server_preference_auto' => 'Automatically select a server', - 'server_preference_prefer' => 'Prefer servers from the list', - 'server_preference_ignore' => 'Ignore servers from the list', + 'server_preference_auto' => 'Automatic selection', + 'server_preference_prefer' => 'Prefered servers', + 'server_preference_ignore' => 'Ignore servers', 'server_id' => 'Server ID', 'server_id_placeholder' => 'Enter the ID of the server.', From afccc69732dfa025aafa0c8b1fa38b2af7ec4d79 Mon Sep 17 00:00:00 2001 From: sveng93 Date: Thu, 11 Dec 2025 20:33:48 +0100 Subject: [PATCH 15/15] pint --- app/Filament/Resources/Schedules/Schemas/ScheduleForm.php | 1 - app/Observers/ScheduleObserver.php | 1 - 2 files changed, 2 deletions(-) diff --git a/app/Filament/Resources/Schedules/Schemas/ScheduleForm.php b/app/Filament/Resources/Schedules/Schemas/ScheduleForm.php index 9081cd2ab..cf708ec47 100644 --- a/app/Filament/Resources/Schedules/Schemas/ScheduleForm.php +++ b/app/Filament/Resources/Schedules/Schemas/ScheduleForm.php @@ -8,7 +8,6 @@ use Cron\CronExpression; use Filament\Forms\Components\Placeholder; use Filament\Forms\Components\Radio; -use Filament\Forms\Components\Repeater; use Filament\Forms\Components\Select; use Filament\Forms\Components\TagsInput; use Filament\Forms\Components\TextInput; diff --git a/app/Observers/ScheduleObserver.php b/app/Observers/ScheduleObserver.php index 534303fc3..99b0b9b96 100644 --- a/app/Observers/ScheduleObserver.php +++ b/app/Observers/ScheduleObserver.php @@ -5,7 +5,6 @@ use App\Actions\CheckCronOverlap; use App\Actions\UpdateNextRun; use App\Models\Schedule; -use Illuminate\Support\Str; class ScheduleObserver {