diff --git a/app/Extensions/Tasks/Schemas/CreateBackupSchema.php b/app/Extensions/Tasks/Schemas/CreateBackupSchema.php new file mode 100644 index 000000000..98927ab08 --- /dev/null +++ b/app/Extensions/Tasks/Schemas/CreateBackupSchema.php @@ -0,0 +1,32 @@ +backupService->setIgnoredFiles(explode(PHP_EOL, $task->payload))->handle($task->server, null, true); + } + + public function canCreate(Schedule $schedule): bool + { + return $schedule->server->backup_limit > 0; + } + + public function getPayloadLabel(): string + { + return trans('server/schedule.tasks.actions.backup.files_to_ignore'); + } +} diff --git a/app/Extensions/Tasks/Schemas/DeleteFilesSchema.php b/app/Extensions/Tasks/Schemas/DeleteFilesSchema.php new file mode 100644 index 000000000..a23e5971c --- /dev/null +++ b/app/Extensions/Tasks/Schemas/DeleteFilesSchema.php @@ -0,0 +1,26 @@ +deleteFilesService->handle($task->server, explode(PHP_EOL, $task->payload)); + } + + public function getPayloadLabel(): string + { + return trans('server/schedule.tasks.actions.delete_files.files_to_delete'); + } +} diff --git a/app/Extensions/Tasks/Schemas/PowerActionSchema.php b/app/Extensions/Tasks/Schemas/PowerActionSchema.php new file mode 100644 index 000000000..777a20bf4 --- /dev/null +++ b/app/Extensions/Tasks/Schemas/PowerActionSchema.php @@ -0,0 +1,57 @@ +serverRepository->setServer($task->server)->power($task->payload); + } + + public function getDefaultPayload(): string + { + return 'restart'; + } + + public function getPayloadLabel(): string + { + return trans('server/schedule.tasks.actions.power.action'); + } + + public function formatPayload(string $payload): string + { + return Str::ucfirst($payload); + } + + /** @return Component[] */ + public function getPayloadForm(): array + { + return [ + Select::make('payload') + ->label($this->getPayloadLabel()) + ->required() + ->options([ + 'start' => trans('server/schedule.tasks.actions.power.start'), + 'restart' => trans('server/schedule.tasks.actions.power.restart'), + 'stop' => trans('server/schedule.tasks.actions.power.stop'), + 'kill' => trans('server/schedule.tasks.actions.power.kill'), + ]) + ->selectablePlaceholder(false) + ->default($this->getDefaultPayload()), + ]; + } +} diff --git a/app/Extensions/Tasks/Schemas/SendCommandSchema.php b/app/Extensions/Tasks/Schemas/SendCommandSchema.php new file mode 100644 index 000000000..5d6c68042 --- /dev/null +++ b/app/Extensions/Tasks/Schemas/SendCommandSchema.php @@ -0,0 +1,36 @@ +server->send($task->payload); + } + + public function getPayloadLabel(): string + { + return trans('server/schedule.tasks.actions.command.command'); + } + + /** @return Component[] */ + public function getPayloadForm(): array + { + return [ + TextInput::make('payload') + ->required() + ->label($this->getPayloadLabel()) + ->default($this->getDefaultPayload()), + ]; + } +} diff --git a/app/Extensions/Tasks/Schemas/TaskSchema.php b/app/Extensions/Tasks/Schemas/TaskSchema.php new file mode 100644 index 000000000..8b8e02c09 --- /dev/null +++ b/app/Extensions/Tasks/Schemas/TaskSchema.php @@ -0,0 +1,52 @@ +getId() . '.title'); + } + + public function canCreate(Schedule $schedule): bool + { + return true; + } + + public function getDefaultPayload(): ?string + { + return null; + } + + public function getPayloadLabel(): ?string + { + return null; + } + + /** @return null|string|string[] */ + public function formatPayload(string $payload): null|string|array + { + if (empty($payload)) { + return null; + } + + return explode(PHP_EOL, $payload); + } + + /** @return Component[] */ + public function getPayloadForm(): array + { + return [ + Textarea::make('payload') + ->label($this->getPayloadLabel() ?? trans('server/schedule.tasks.payload')) + ->default($this->getDefaultPayload()) + ->autosize(), + ]; + } +} diff --git a/app/Extensions/Tasks/TaskSchemaInterface.php b/app/Extensions/Tasks/TaskSchemaInterface.php new file mode 100644 index 000000000..c832562aa --- /dev/null +++ b/app/Extensions/Tasks/TaskSchemaInterface.php @@ -0,0 +1,28 @@ + */ + private array $schemas = []; + + /** + * @return TaskSchemaInterface[] + */ + public function getAll(): array + { + return $this->schemas; + } + + public function get(string $id): ?TaskSchemaInterface + { + return array_get($this->schemas, $id); + } + + public function register(TaskSchemaInterface $schema): void + { + if (array_key_exists($schema->getId(), $this->schemas)) { + return; + } + + $this->schemas[$schema->getId()] = $schema; + } + + /** @return array */ + public function getMappings(): array + { + return collect($this->schemas)->mapWithKeys(fn ($schema) => [$schema->getId() => $schema->getName()])->all(); + } +} diff --git a/app/Filament/Server/Resources/Schedules/RelationManagers/TasksRelationManager.php b/app/Filament/Server/Resources/Schedules/RelationManagers/TasksRelationManager.php index cda3d70b2..986ba158c 100644 --- a/app/Filament/Server/Resources/Schedules/RelationManagers/TasksRelationManager.php +++ b/app/Filament/Server/Resources/Schedules/RelationManagers/TasksRelationManager.php @@ -2,6 +2,7 @@ namespace App\Filament\Server\Resources\Schedules\RelationManagers; +use App\Extensions\Tasks\TaskService; use App\Facades\Activity; use App\Models\Schedule; use App\Models\Task; @@ -9,12 +10,12 @@ use Exception; use Filament\Actions\CreateAction; use Filament\Actions\DeleteAction; use Filament\Actions\EditAction; -use Filament\Forms\Components\Field; use Filament\Forms\Components\Select; -use Filament\Forms\Components\Textarea; use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Toggle; use Filament\Resources\RelationManagers\RelationManager; +use Filament\Schemas\Components\Component; +use Filament\Schemas\Components\Group; use Filament\Schemas\Components\Utilities\Get; use Filament\Schemas\Components\Utilities\Set; use Filament\Tables\Columns\IconColumn; @@ -26,50 +27,28 @@ class TasksRelationManager extends RelationManager protected static string $relationship = 'tasks'; /** - * @return array - */ - private function getActionOptions(bool $full = true): array - { - return [ - Task::ACTION_POWER => $full ? trans('server/schedule.tasks.actions.power.title') : trans('server/schedule.tasks.actions.power.action'), - Task::ACTION_COMMAND => $full ? trans('server/schedule.tasks.actions.command.title') : trans('server/schedule.tasks.actions.command.command'), - Task::ACTION_BACKUP => $full ? trans('server/schedule.tasks.actions.backup.title') : trans('server/schedule.tasks.actions.backup.files_to_ignore'), - Task::ACTION_DELETE_FILES => $full ? trans('server/schedule.tasks.actions.delete.title') : trans('server/schedule.tasks.actions.delete.files_to_delete'), - ]; - } - - /** - * @return array + * @return Component[] * * @throws Exception */ private function getTaskForm(Schedule $schedule): array { + /** @var TaskService $taskService */ + $taskService = app(TaskService::class); // @phpstan-ignore myCustomRules.forbiddenGlobalFunctions + + $tasks = $taskService->getAll(); + return [ Select::make('action') ->label(trans('server/schedule.tasks.actions.title')) ->required() ->live() - ->disableOptionWhen(fn (string $value) => $value === Task::ACTION_BACKUP && $schedule->server->backup_limit === 0) - ->options($this->getActionOptions()) + ->disableOptionWhen(fn (string $value) => !$tasks[$value]->canCreate($schedule)) + ->options($taskService->getMappings()) ->selectablePlaceholder(false) - ->default(Task::ACTION_POWER) - ->afterStateUpdated(fn ($state, Set $set) => $set('payload', $state === Task::ACTION_POWER ? 'restart' : null)), - Textarea::make('payload') - ->hidden(fn (Get $get) => $get('action') === Task::ACTION_POWER) - ->label(fn (Get $get) => $this->getActionOptions(false)[$get('action')] ?? trans('server/schedule.tasks.payload')), - Select::make('payload') - ->visible(fn (Get $get) => $get('action') === Task::ACTION_POWER) - ->label(trans('server/schedule.tasks.actions.power.action')) - ->required() - ->options([ - 'start' => trans('server/schedule.tasks.actions.power.start'), - 'restart' => trans('server/schedule.tasks.actions.power.restart'), - 'stop' => trans('server/schedule.tasks.actions.power.stop'), - 'kill' => trans('server/schedule.tasks.actions.power.kill'), - ]) - ->selectablePlaceholder(false) - ->default('restart'), + ->default(array_key_first($tasks)) + ->afterStateUpdated(fn ($state, Set $set) => $set('payload', $tasks[$state]->getDefaultPayload())), + Group::make(fn (Get $get) => array_key_exists($get('action'), $tasks) ? $tasks[$get('action')]->getPayloadForm() : []), TextInput::make('time_offset') ->label(trans('server/schedule.tasks.time_offset')) ->hidden(fn (Get $get, ?Task $task) => config('queue.default') === 'sync' || $schedule->tasks->isEmpty() || $task?->isFirst()) @@ -97,13 +76,12 @@ class TasksRelationManager extends RelationManager ->columns([ TextColumn::make('action') ->label(trans('server/schedule.tasks.actions.title')) - ->state(fn (Task $task) => $this->getActionOptions()[$task->action] ?? $task->action), + ->state(fn (Task $task) => $task->getSchema()?->getName() ?? $task->action), TextColumn::make('payload') ->label(trans('server/schedule.tasks.payload')) - ->state(fn (Task $task) => match ($task->payload) { - 'start', 'restart', 'stop', 'kill' => mb_ucfirst($task->payload), - default => explode(PHP_EOL, $task->payload) - }) + ->state(fn (Task $task) => $task->getSchema()?->formatPayload($task->payload) ?? $task->payload) + ->tooltip(fn (Task $task) => $task->getSchema()?->getPayloadLabel()) + ->placeholder(trans('server/schedule.tasks.no_payload')) ->badge(), TextColumn::make('time_offset') ->label(trans('server/schedule.tasks.time_offset')) diff --git a/app/Jobs/Schedule/RunTaskJob.php b/app/Jobs/Schedule/RunTaskJob.php index 436da1160..476222a5f 100644 --- a/app/Jobs/Schedule/RunTaskJob.php +++ b/app/Jobs/Schedule/RunTaskJob.php @@ -2,11 +2,9 @@ namespace App\Jobs\Schedule; +use App\Extensions\Tasks\TaskService; use App\Jobs\Job; use App\Models\Task; -use App\Repositories\Daemon\DaemonServerRepository; -use App\Services\Backups\InitiateBackupService; -use App\Services\Files\DeleteFilesService; use Carbon\CarbonImmutable; use Exception; use Illuminate\Contracts\Queue\ShouldQueue; @@ -31,11 +29,8 @@ class RunTaskJob extends Job implements ShouldQueue * * @throws Throwable */ - public function handle( - InitiateBackupService $backupService, - DaemonServerRepository $serverRepository, - DeleteFilesService $deleteFilesService - ): void { + public function handle(TaskService $taskService): void + { // Do not process a task that is not set to active, unless it's been manually triggered. if (!$this->task->schedule->is_active && !$this->manualRun) { $this->markTaskNotQueued(); @@ -57,22 +52,13 @@ class RunTaskJob extends Job implements ShouldQueue // Perform the provided task against the daemon. try { - switch ($this->task->action) { - case Task::ACTION_POWER: - $serverRepository->setServer($server)->power($this->task->payload); - break; - case Task::ACTION_COMMAND: - $server->send($this->task->payload); - break; - case Task::ACTION_BACKUP: - $backupService->setIgnoredFiles(explode(PHP_EOL, $this->task->payload))->handle($server, null, true); - break; - case Task::ACTION_DELETE_FILES: - $deleteFilesService->handle($server, explode(PHP_EOL, $this->task->payload)); - break; - default: - throw new InvalidArgumentException('Invalid task action provided: ' . $this->task->action); + $taskSchema = $taskService->get($this->task->action); + + if (!$taskSchema) { + throw new InvalidArgumentException('Invalid task action provided: ' . $this->task->action); } + + $taskSchema->runTask($this->task); } catch (Exception $exception) { // If this isn't a ConnectionException on a task that allows for failures // throw the exception back up the chain so that the task is stopped. diff --git a/app/Models/Task.php b/app/Models/Task.php index cbbf00730..c2b086644 100644 --- a/app/Models/Task.php +++ b/app/Models/Task.php @@ -3,6 +3,8 @@ namespace App\Models; use App\Contracts\Validatable; +use App\Extensions\Tasks\TaskSchemaInterface; +use App\Extensions\Tasks\TaskService; use App\Traits\HasValidation; use Carbon\Carbon; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -35,17 +37,6 @@ class Task extends Model implements Validatable */ public const RESOURCE_NAME = 'schedule_task'; - /** - * The default actions that can exist for a task - */ - public const ACTION_POWER = 'power'; - - public const ACTION_COMMAND = 'command'; - - public const ACTION_BACKUP = 'backup'; - - public const ACTION_DELETE_FILES = 'delete_files'; - /** * Relationships to be updated when this model is updated. * @@ -125,4 +116,12 @@ class Task extends Model implements Validatable { return $this->schedule->firstTask()?->id === $this->id; } + + public function getSchema(): ?TaskSchemaInterface + { + /** @var TaskService $taskService */ + $taskService = app(TaskService::class); // @phpstan-ignore myCustomRules.forbiddenGlobalFunctions + + return $taskService->get($this->action); + } } diff --git a/app/Providers/Extensions/TaskServiceProvider.php b/app/Providers/Extensions/TaskServiceProvider.php new file mode 100644 index 000000000..d5cd0d114 --- /dev/null +++ b/app/Providers/Extensions/TaskServiceProvider.php @@ -0,0 +1,31 @@ +app->singleton(TaskService::class, function ($app) { + $service = new TaskService(); + + // Default Task providers + $service->register(new PowerActionSchema($app->make(DaemonServerRepository::class))); + $service->register(new SendCommandSchema()); + $service->register(new CreateBackupSchema($app->make(InitiateBackupService::class))); + $service->register(new DeleteFilesSchema($app->make(DeleteFilesService::class))); + + return $service; + }); + } +} diff --git a/bootstrap/providers.php b/bootstrap/providers.php index 2abe90899..c1f98d09d 100644 --- a/bootstrap/providers.php +++ b/bootstrap/providers.php @@ -9,6 +9,7 @@ return [ App\Providers\Extensions\CaptchaServiceProvider::class, App\Providers\Extensions\FeatureServiceProvider::class, App\Providers\Extensions\OAuthServiceProvider::class, + App\Providers\Extensions\TaskServiceProvider::class, App\Providers\Filament\FilamentServiceProvider::class, App\Providers\Filament\AdminPanelProvider::class, App\Providers\Filament\AppPanelProvider::class, diff --git a/lang/en/server/schedule.php b/lang/en/server/schedule.php index 7997f602e..be0ae8d7b 100644 --- a/lang/en/server/schedule.php +++ b/lang/en/server/schedule.php @@ -76,6 +76,7 @@ return [ 'limit' => 'Task Limit Reached', 'action' => 'Action', 'payload' => 'Payload', + 'no_payload' => 'No Payload', 'time_offset' => 'Time Offset', 'first_task' => 'First Task', 'seconds' => 'Second|Seconds', @@ -99,10 +100,9 @@ return [ 'title' => 'Create Backup', 'files_to_ignore' => 'Files to Ignore', ], - 'delete' => [ + 'delete_files' => [ 'title' => 'Delete Files', 'files_to_delete' => 'Files to Delete', - ], ], ], diff --git a/tests/Integration/Jobs/Schedule/RunTaskJobTest.php b/tests/Integration/Jobs/Schedule/RunTaskJobTest.php index c4906d872..22a679933 100644 --- a/tests/Integration/Jobs/Schedule/RunTaskJobTest.php +++ b/tests/Integration/Jobs/Schedule/RunTaskJobTest.php @@ -78,7 +78,7 @@ class RunTaskJobTest extends IntegrationTestCase /** @var \App\Models\Task $task */ $task = Task::factory()->create([ 'schedule_id' => $schedule->id, - 'action' => Task::ACTION_POWER, + 'action' => 'power', 'payload' => 'start', 'is_queued' => true, 'continue_on_failure' => false, @@ -112,7 +112,7 @@ class RunTaskJobTest extends IntegrationTestCase /** @var \App\Models\Task $task */ $task = Task::factory()->create([ 'schedule_id' => $schedule->id, - 'action' => Task::ACTION_POWER, + 'action' => 'power', 'payload' => 'start', 'continue_on_failure' => $continueOnFailure, ]); @@ -152,7 +152,7 @@ class RunTaskJobTest extends IntegrationTestCase ]); $task = Task::factory()->for($schedule)->create([ - 'action' => Task::ACTION_POWER, + 'action' => 'power', 'payload' => 'start', ]);