diff --git a/app/Actions/CheckCronOverlap.php b/app/Actions/CheckCronOverlap.php new file mode 100644 index 000000000..ad27f1bb5 --- /dev/null +++ b/app/Actions/CheckCronOverlap.php @@ -0,0 +1,55 @@ +is_active) { + + return; + } + + $cronExpression = $schedule->schedule; + $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', 'schedule']) + ->pluck('schedule', '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(__('schedules.overlap_detected')) + ->warning() + ->body(__('schedules.overlap_body', ['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..d655a8f18 --- /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 column + $expression = $schedule->schedule; + + 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/Enums/ScheduleStatus.php b/app/Enums/ScheduleStatus.php new file mode 100644 index 000000000..ad81fb517 --- /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 => __('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/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/app/Filament/Resources/Results/Tables/ResultTable.php b/app/Filament/Resources/Results/Tables/ResultTable.php index 6808e9f7d..8c19baeb3 100644 --- a/app/Filament/Resources/Results/Tables/ResultTable.php +++ b/app/Filament/Resources/Results/Tables/ResultTable.php @@ -7,6 +7,7 @@ use App\Filament\Tables\Columns\ResultServerColumn; use App\Helpers\Number; use App\Models\Result; +use App\Models\Schedule; use Filament\Actions\Action; use Filament\Actions\ActionGroup; use Filament\Actions\DeleteAction; @@ -116,6 +117,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), TextColumn::make('created_at') @@ -204,6 +206,16 @@ public static function table(Table $table): Table }) ->attribute('data->server->id'), + 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/Pages/CreateSchedule.php b/app/Filament/Resources/Schedules/Pages/CreateSchedule.php new file mode 100644 index 000000000..904aa519b --- /dev/null +++ b/app/Filament/Resources/Schedules/Pages/CreateSchedule.php @@ -0,0 +1,24 @@ +getResource()::getUrl('index'); + } +} diff --git a/app/Filament/Resources/Schedules/Pages/EditSchedule.php b/app/Filament/Resources/Schedules/Pages/EditSchedule.php new file mode 100644 index 000000000..68c42c33a --- /dev/null +++ b/app/Filament/Resources/Schedules/Pages/EditSchedule.php @@ -0,0 +1,24 @@ +getResource()::getUrl('index'); + } +} diff --git a/app/Filament/Resources/Schedules/Pages/ListSchedules.php b/app/Filament/Resources/Schedules/Pages/ListSchedules.php new file mode 100644 index 000000000..a935a24fb --- /dev/null +++ b/app/Filament/Resources/Schedules/Pages/ListSchedules.php @@ -0,0 +1,19 @@ +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..cf708ec47 --- /dev/null +++ b/app/Filament/Resources/Schedules/Schemas/ScheduleForm.php @@ -0,0 +1,120 @@ +schema([ + Toggle::make('is_active') + ->label(__('schedules.active')) + ->required(), + TextInput::make('name') + ->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(__('schedules.type')) + ->hidden(true) + ->options([ + 'Ookla' => 'Ookla', + ]) + ->default('ookla') + ->native(false) + ->required(), + ]) + ->columnSpan('full'), + + Tabs::make(__('schedules.options')) + ->tabs([ + Tab::make(__('schedules.schedule')) + ->schema([ + 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(__('schedules.next_run_at')) + ->content(function (Get $get) { + $expression = $get('schedule'); + + 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(__('schedules.servers')) + ->schema([ + Radio::make('options.server_preference') + ->label(__('schedules.server_preference')) + ->options([ + 'auto' => __('schedules.server_preference_auto'), + 'prefer' => __('schedules.server_preference_prefer'), + 'ignore' => __('schedules.server_preference_ignore'), + ]) + ->default('auto') + ->required() + ->live(), + + TagsInput::make('options.servers') + ->label(__('schedules.server_id')) + ->placeholder(__('schedules.server_id_placeholder')) + ->hidden(fn (Get $get) => $get('options.server_preference') === 'auto'), + ]), + + Tab::make(__('schedules.advanced')) + ->schema([ + TagsInput::make('options.skip_ips') + ->label(__('schedules.skip_ips')) + ->placeholder(__('schedules.skip_ips_placeholder')) + ->nestedRecursiveRules([ + 'ip', + ]) + ->live() + ->helpertext(__('schedules.skip_ips_helper')), + TextInput::make('options.interface') + ->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 new file mode 100644 index 000000000..1c0a42ac1 --- /dev/null +++ b/app/Filament/Resources/Schedules/Tables/ScheduleTable.php @@ -0,0 +1,129 @@ +columns([ + TextColumn::make('id') + ->label(__('schedules.id')) + ->sortable(), + TextColumn::make('name') + ->label(__('schedules.name')) + ->sortable(), + TextColumn::make('type') + ->label(__('schedules.type')) + ->toggleable(isToggledHiddenByDefault: true) + ->sortable(), + TextColumn::make('description') + ->label(__('schedules.description')) + ->toggleable(isToggledHiddenByDefault: true), + TextColumn::make('schedule') + ->label(__('schedules.schedule')) + ->sortable() + ->toggleable(isToggledHiddenByDefault: false) + ->formatStateUsing(fn (?string $state) => ExplainCronExpression::run($state)), + IconColumn::make('is_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), + ]) + ->filters([ + SelectFilter::make('type') + ->label(__('schedules.type')) + ->options(function () { + return Schedule::distinct() + ->pluck('type', 'type') + ->toArray(); + }) + ->native(false), + TernaryFilter::make('Active') + ->label(__('schedules.active')) + ->nullable() + ->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), + blank: fn (Builder $query) => $query, + ) + ->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(__('schedules.change_schedule_status')) + ->action(function ($record) { + $record->update(['is_active' => ! $record->is_active]); + }) + ->icon('heroicon-c-arrow-path'), + 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 bba1a37d9..cc3cfc058 100644 --- a/app/Models/Result.php +++ b/app/Models/Result.php @@ -39,6 +39,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..863633adf --- /dev/null +++ b/app/Models/Schedule.php @@ -0,0 +1,64 @@ + + */ + protected function casts(): array + { + return [ + 'options' => 'array', + 'is_active' => 'boolean', + 'status' => ScheduleStatus::class, + 'next_run_at' => 'datetime', + 'last_run_at' => 'datetime', + ]; + } + + /** + * Get the user who created this schedule. + */ + public function createdBy(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + public function getServerTooltip(): ?string + { + $preference = $this->options['server_preference'] ?? 'auto'; + + if ($preference === 'auto') { + return null; + } + + $servers = collect($this->options['servers'] ?? []) + ->pluck('server_id'); + + return $servers + ->map(fn ($id) => self::getServerLabel($id)) + ->implode(', '); + } +} diff --git a/app/Models/Traits/HasOwner.php b/app/Models/Traits/HasOwner.php new file mode 100644 index 000000000..e61d3bc3d --- /dev/null +++ b/app/Models/Traits/HasOwner.php @@ -0,0 +1,77 @@ +belongsTo(User::class, 'created_by'); + } + + /** + * Creator of the model. + */ + public function createdBy(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + /** + * 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->created_by); + } + + /** + * 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->created_by; + } + + /** + * Scope a query to only include models by the owner. + */ + public function scopeWhereOwnedBy(Builder $query, User $owner): Builder + { + return $query->where('created_by', '=', $owner->id); + } + + /** + * Scope a query to exclude models by owner. + */ + public function scopeWhereNotOwnedBy(Builder $query, User $owner): Builder + { + return $query->where('created_by', '!=', $owner->id); + } +} diff --git a/app/Observers/ScheduleObserver.php b/app/Observers/ScheduleObserver.php new file mode 100644 index 000000000..99b0b9b96 --- /dev/null +++ b/app/Observers/ScheduleObserver.php @@ -0,0 +1,71 @@ +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); + } + + /** + * 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..81bd8cbde --- /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('schedule'); // 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/composer.json b/composer.json index 4fe2349e4..b13fa06fe 100644 --- a/composer.json +++ b/composer.json @@ -28,6 +28,7 @@ "livewire/livewire": "^3.7.1", "lorisleiva/laravel-actions": "^2.9.1", "maennchen/zipstream-php": "^2.4", + "orisai/cron-expression-explainer": "^1.1", "promphp/prometheus_client_php": "^2.14.1", "saloonphp/laravel-plugin": "^3.7", "secondnetwork/blade-tabler-icons": "^3.35", diff --git a/composer.lock b/composer.lock index 6c492d7d9..0db690c18 100644 --- a/composer.lock +++ b/composer.lock @@ -4678,6 +4678,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", @@ -7964,6 +8038,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", 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..c588c0543 --- /dev/null +++ b/database/migrations/2025_11_20_205859_create_schedules_table..php @@ -0,0 +1,37 @@ +id(); + $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(); + }); + } + + /** + * 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 + { + // + } +}; 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', + ], ]; 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', diff --git a/lang/en/schedules.php b/lang/en/schedules.php new file mode 100644 index 000000000..d15cdda37 --- /dev/null +++ b/lang/en/schedules.php @@ -0,0 +1,59 @@ + 'Schedules', + 'singular' => 'Schedule', + + // 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' => '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.', + + // 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.', +];