From c1ee0ac4f86b1911c8d0b75200dcf8213ece9699 Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Wed, 14 Oct 2020 20:38:59 -0700 Subject: [PATCH] Add support for executing a scheduled task right now --- .../ScheduleRepositoryInterface.php | 10 --- .../Api/Client/Servers/ScheduleController.php | 37 +++++++- .../Schedules/TriggerScheduleRequest.php | 25 ++++++ app/Jobs/Schedule/RunTaskJob.php | 4 +- app/Models/Schedule.php | 16 ++++ .../Eloquent/ScheduleRepository.php | 17 ---- .../Schedules/ProcessScheduleService.php | 57 +++++-------- ...verSchedules.tsx => getServerSchedules.ts} | 0 .../schedules/triggerScheduleExecution.ts | 4 + .../server/schedules/RunScheduleButton.tsx | 48 +++++++++++ .../schedules/ScheduleEditContainer.tsx | 11 ++- routes/api-client.php | 1 + .../Schedules/ProcessScheduleServiceTest.php | 85 ------------------- 13 files changed, 158 insertions(+), 157 deletions(-) create mode 100644 app/Http/Requests/Api/Client/Servers/Schedules/TriggerScheduleRequest.php rename resources/scripts/api/server/schedules/{getServerSchedules.tsx => getServerSchedules.ts} (100%) create mode 100644 resources/scripts/api/server/schedules/triggerScheduleExecution.ts create mode 100644 resources/scripts/components/server/schedules/RunScheduleButton.tsx delete mode 100644 tests/Unit/Services/Schedules/ProcessScheduleServiceTest.php diff --git a/app/Contracts/Repository/ScheduleRepositoryInterface.php b/app/Contracts/Repository/ScheduleRepositoryInterface.php index 4f340601c..32650bdcf 100644 --- a/app/Contracts/Repository/ScheduleRepositoryInterface.php +++ b/app/Contracts/Repository/ScheduleRepositoryInterface.php @@ -15,16 +15,6 @@ interface ScheduleRepositoryInterface extends RepositoryInterface */ public function findServerSchedules(int $server): Collection; - /** - * Load the tasks relationship onto the Schedule module if they are not - * already present. - * - * @param \Pterodactyl\Models\Schedule $schedule - * @param bool $refresh - * @return \Pterodactyl\Models\Schedule - */ - public function loadTasks(Schedule $schedule, bool $refresh = false): Schedule; - /** * Return a schedule model with all of the associated tasks as a relationship. * diff --git a/app/Http/Controllers/Api/Client/Servers/ScheduleController.php b/app/Http/Controllers/Api/Client/Servers/ScheduleController.php index d35b597ed..a9310abd6 100644 --- a/app/Http/Controllers/Api/Client/Servers/ScheduleController.php +++ b/app/Http/Controllers/Api/Client/Servers/ScheduleController.php @@ -10,15 +10,19 @@ use Pterodactyl\Models\Server; use Pterodactyl\Models\Schedule; use Illuminate\Http\JsonResponse; use Pterodactyl\Helpers\Utilities; +use Pterodactyl\Jobs\Schedule\RunTaskJob; use Pterodactyl\Exceptions\DisplayException; use Pterodactyl\Repositories\Eloquent\ScheduleRepository; +use Pterodactyl\Services\Schedules\ProcessScheduleService; use Pterodactyl\Transformers\Api\Client\ScheduleTransformer; use Pterodactyl\Http\Controllers\Api\Client\ClientApiController; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Pterodactyl\Http\Requests\Api\Client\Servers\Schedules\ViewScheduleRequest; use Pterodactyl\Http\Requests\Api\Client\Servers\Schedules\StoreScheduleRequest; use Pterodactyl\Http\Requests\Api\Client\Servers\Schedules\DeleteScheduleRequest; use Pterodactyl\Http\Requests\Api\Client\Servers\Schedules\UpdateScheduleRequest; +use Pterodactyl\Http\Requests\Api\Client\Servers\Schedules\TriggerScheduleRequest; class ScheduleController extends ClientApiController { @@ -27,16 +31,23 @@ class ScheduleController extends ClientApiController */ private $repository; + /** + * @var \Pterodactyl\Services\Schedules\ProcessScheduleService + */ + private $service; + /** * ScheduleController constructor. * * @param \Pterodactyl\Repositories\Eloquent\ScheduleRepository $repository + * @param \Pterodactyl\Services\Schedules\ProcessScheduleService $service */ - public function __construct(ScheduleRepository $repository) + public function __construct(ScheduleRepository $repository, ProcessScheduleService $service) { parent::__construct(); $this->repository = $repository; + $this->service = $service; } /** @@ -147,6 +158,30 @@ class ScheduleController extends ClientApiController ->toArray(); } + /** + * Executes a given schedule immediately rather than waiting on it's normally scheduled time + * to pass. This does not care about the schedule state. + * + * @param \Pterodactyl\Http\Requests\Api\Client\Servers\Schedules\TriggerScheduleRequest $request + * @param \Pterodactyl\Models\Server $server + * @param \Pterodactyl\Models\Schedule $schedule + * @return \Illuminate\Http\JsonResponse + * + * @throws \Throwable + */ + public function execute(TriggerScheduleRequest $request, Server $server, Schedule $schedule) + { + if (!$schedule->is_active) { + throw new BadRequestHttpException( + 'Cannot trigger schedule exection for a schedule that is not currently active.' + ); + } + + $this->service->handle($schedule, true); + + return new JsonResponse([], JsonResponse::HTTP_ACCEPTED); + } + /** * Deletes a schedule and it's associated tasks. * diff --git a/app/Http/Requests/Api/Client/Servers/Schedules/TriggerScheduleRequest.php b/app/Http/Requests/Api/Client/Servers/Schedules/TriggerScheduleRequest.php new file mode 100644 index 000000000..7651b7419 --- /dev/null +++ b/app/Http/Requests/Api/Client/Servers/Schedules/TriggerScheduleRequest.php @@ -0,0 +1,25 @@ +task->schedule->is_active) { diff --git a/app/Models/Schedule.php b/app/Models/Schedule.php index d737edd2c..de0475639 100644 --- a/app/Models/Schedule.php +++ b/app/Models/Schedule.php @@ -2,6 +2,8 @@ namespace Pterodactyl\Models; +use Cron\CronExpression; +use Carbon\CarbonImmutable; use Illuminate\Container\Container; use Pterodactyl\Contracts\Extensions\HashidsInterface; @@ -114,6 +116,20 @@ class Schedule extends Model 'next_run_at' => 'nullable|date', ]; + /** + * Returns the schedule's execution crontab entry as a string. + * + * @return \Carbon\CarbonImmutable + */ + public function getNextRunDate() + { + $formatted = sprintf('%s %s %s * %s', $this->cron_minute, $this->cron_hour, $this->cron_day_of_month, $this->cron_day_of_week); + + return CarbonImmutable::createFromTimestamp( + CronExpression::factory($formatted)->getNextRunDate()->getTimestamp() + ); + } + /** * Return a hashid encoded string to represent the ID of the schedule. * diff --git a/app/Repositories/Eloquent/ScheduleRepository.php b/app/Repositories/Eloquent/ScheduleRepository.php index 389d06720..030939da7 100644 --- a/app/Repositories/Eloquent/ScheduleRepository.php +++ b/app/Repositories/Eloquent/ScheduleRepository.php @@ -31,23 +31,6 @@ class ScheduleRepository extends EloquentRepository implements ScheduleRepositor return $this->getBuilder()->withCount('tasks')->where('server_id', '=', $server)->get($this->getColumns()); } - /** - * Load the tasks relationship onto the Schedule module if they are not - * already present. - * - * @param \Pterodactyl\Models\Schedule $schedule - * @param bool $refresh - * @return \Pterodactyl\Models\Schedule - */ - public function loadTasks(Schedule $schedule, bool $refresh = false): Schedule - { - if (! $schedule->relationLoaded('tasks') || $refresh) { - $schedule->load('tasks'); - } - - return $schedule; - } - /** * Return a schedule model with all of the associated tasks as a relationship. * diff --git a/app/Services/Schedules/ProcessScheduleService.php b/app/Services/Schedules/ProcessScheduleService.php index 3fa3604a4..5d4ad60cf 100644 --- a/app/Services/Schedules/ProcessScheduleService.php +++ b/app/Services/Schedules/ProcessScheduleService.php @@ -2,12 +2,10 @@ namespace Pterodactyl\Services\Schedules; -use Cron\CronExpression; use Pterodactyl\Models\Schedule; use Illuminate\Contracts\Bus\Dispatcher; use Pterodactyl\Jobs\Schedule\RunTaskJob; -use Pterodactyl\Contracts\Repository\TaskRepositoryInterface; -use Pterodactyl\Contracts\Repository\ScheduleRepositoryInterface; +use Illuminate\Database\ConnectionInterface; class ProcessScheduleService { @@ -17,62 +15,45 @@ class ProcessScheduleService private $dispatcher; /** - * @var \Pterodactyl\Contracts\Repository\ScheduleRepositoryInterface + * @var \Illuminate\Database\ConnectionInterface */ - private $scheduleRepository; - - /** - * @var \Pterodactyl\Contracts\Repository\TaskRepositoryInterface - */ - private $taskRepository; + private $connection; /** * ProcessScheduleService constructor. * + * @param \Illuminate\Database\ConnectionInterface $connection * @param \Illuminate\Contracts\Bus\Dispatcher $dispatcher - * @param \Pterodactyl\Contracts\Repository\ScheduleRepositoryInterface $scheduleRepository - * @param \Pterodactyl\Contracts\Repository\TaskRepositoryInterface $taskRepository */ - public function __construct( - Dispatcher $dispatcher, - ScheduleRepositoryInterface $scheduleRepository, - TaskRepositoryInterface $taskRepository - ) { + public function __construct(ConnectionInterface $connection, Dispatcher $dispatcher) + { $this->dispatcher = $dispatcher; - $this->scheduleRepository = $scheduleRepository; - $this->taskRepository = $taskRepository; + $this->connection = $connection; } /** * Process a schedule and push the first task onto the queue worker. * * @param \Pterodactyl\Models\Schedule $schedule + * @param bool $now * - * @throws \Pterodactyl\Exceptions\Model\DataValidationException - * @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException + * @throws \Throwable */ - public function handle(Schedule $schedule) + public function handle(Schedule $schedule, bool $now = false) { - $this->scheduleRepository->loadTasks($schedule); - /** @var \Pterodactyl\Models\Task $task */ - $task = $schedule->getRelation('tasks')->where('sequence_id', 1)->first(); + $task = $schedule->tasks()->where('sequence_id', 1)->firstOrFail(); - $formattedCron = sprintf('%s %s %s * %s', - $schedule->cron_minute, - $schedule->cron_hour, - $schedule->cron_day_of_month, - $schedule->cron_day_of_week - ); + $this->connection->transaction(function () use ($schedule, $task) { + $schedule->forceFill([ + 'is_processing' => true, + 'next_run_at' => $schedule->getNextRunDate(), + ])->saveOrFail(); - $this->scheduleRepository->update($schedule->id, [ - 'is_processing' => true, - 'next_run_at' => CronExpression::factory($formattedCron)->getNextRunDate(), - ]); + $task->update(['is_queued' => true]); + }); - $this->taskRepository->update($task->id, ['is_queued' => true]); - - $this->dispatcher->dispatch( + $this->dispatcher->{$now ? 'dispatchNow' : 'dispatch'}( (new RunTaskJob($task))->delay($task->time_offset) ); } diff --git a/resources/scripts/api/server/schedules/getServerSchedules.tsx b/resources/scripts/api/server/schedules/getServerSchedules.ts similarity index 100% rename from resources/scripts/api/server/schedules/getServerSchedules.tsx rename to resources/scripts/api/server/schedules/getServerSchedules.ts diff --git a/resources/scripts/api/server/schedules/triggerScheduleExecution.ts b/resources/scripts/api/server/schedules/triggerScheduleExecution.ts new file mode 100644 index 000000000..92f7a589f --- /dev/null +++ b/resources/scripts/api/server/schedules/triggerScheduleExecution.ts @@ -0,0 +1,4 @@ +import http from '@/api/http'; + +export default async (server: string, schedule: number): Promise => + await http.post(`/api/client/servers/${server}/schedules/${schedule}/execute`); diff --git a/resources/scripts/components/server/schedules/RunScheduleButton.tsx b/resources/scripts/components/server/schedules/RunScheduleButton.tsx new file mode 100644 index 000000000..96424b06e --- /dev/null +++ b/resources/scripts/components/server/schedules/RunScheduleButton.tsx @@ -0,0 +1,48 @@ +import React, { useCallback, useState } from 'react'; +import SpinnerOverlay from '@/components/elements/SpinnerOverlay'; +import tw from 'twin.macro'; +import Button from '@/components/elements/Button'; +import triggerScheduleExecution from '@/api/server/schedules/triggerScheduleExecution'; +import { ServerContext } from '@/state/server'; +import useFlash from '@/plugins/useFlash'; +import { Schedule } from '@/api/server/schedules/getServerSchedules'; + +const RunScheduleButton = ({ schedule }: { schedule: Schedule }) => { + const [ loading, setLoading ] = useState(false); + const { clearFlashes, clearAndAddHttpError } = useFlash(); + + const id = ServerContext.useStoreState(state => state.server.data!.id); + const appendSchedule = ServerContext.useStoreActions(actions => actions.schedules.appendSchedule); + + const onTriggerExecute = useCallback(() => { + clearFlashes('schedule'); + setLoading(true); + triggerScheduleExecution(id, schedule.id) + .then(() => { + setLoading(false); + appendSchedule({ ...schedule, isProcessing: true }); + }) + .catch(error => { + console.error(error); + clearAndAddHttpError({ error, key: 'schedules' }); + }) + .then(() => setLoading(false)); + }, []); + + return ( + <> + + + + ); +}; + +export default RunScheduleButton; diff --git a/resources/scripts/components/server/schedules/ScheduleEditContainer.tsx b/resources/scripts/components/server/schedules/ScheduleEditContainer.tsx index aea4778ae..5b2265845 100644 --- a/resources/scripts/components/server/schedules/ScheduleEditContainer.tsx +++ b/resources/scripts/components/server/schedules/ScheduleEditContainer.tsx @@ -4,7 +4,6 @@ import { Schedule } from '@/api/server/schedules/getServerSchedules'; import getServerSchedule from '@/api/server/schedules/getServerSchedule'; import Spinner from '@/components/elements/Spinner'; import FlashMessageRender from '@/components/FlashMessageRender'; -import { httpErrorToHuman } from '@/api/http'; import EditScheduleModal from '@/components/server/schedules/EditScheduleModal'; import NewTaskButton from '@/components/server/schedules/NewTaskButton'; import DeleteScheduleButton from '@/components/server/schedules/DeleteScheduleButton'; @@ -18,6 +17,7 @@ import ScheduleTaskRow from '@/components/server/schedules/ScheduleTaskRow'; import isEqual from 'react-fast-compare'; import { format } from 'date-fns'; import ScheduleCronRow from '@/components/server/schedules/ScheduleCronRow'; +import RunScheduleButton from '@/components/server/schedules/RunScheduleButton'; interface Params { id: string; @@ -49,7 +49,7 @@ export default ({ match, history, location: { state } }: RouteComponentProps state.server.data!.id); const uuid = ServerContext.useStoreState(state => state.server.data!.uuid); - const { clearFlashes, addError } = useFlash(); + const { clearFlashes, clearAndAddHttpError } = useFlash(); const [ isLoading, setIsLoading ] = useState(true); const [ showEditModal, setShowEditModal ] = useState(false); @@ -68,7 +68,7 @@ export default ({ match, history, location: { state } }: RouteComponentProps appendSchedule(schedule)) .catch(error => { console.error(error); - addError({ message: httpErrorToHuman(error), key: 'schedules' }); + clearAndAddHttpError({ error, key: 'schedules' }); }) .then(() => setIsLoading(false)); }, [ match ]); @@ -146,6 +146,11 @@ export default ({ match, history, location: { state } }: RouteComponentProps history.push(`/server/${id}/schedules`)} /> + {schedule.isActive && + + + + } } diff --git a/routes/api-client.php b/routes/api-client.php index 51a2f0dec..fa0455018 100644 --- a/routes/api-client.php +++ b/routes/api-client.php @@ -72,6 +72,7 @@ Route::group(['prefix' => '/servers/{server}', 'middleware' => [AuthenticateServ Route::post('/', 'Servers\ScheduleController@store'); Route::get('/{schedule}', 'Servers\ScheduleController@view'); Route::post('/{schedule}', 'Servers\ScheduleController@update'); + Route::post('/{schedule}/execute', 'Servers\ScheduleController@execute'); Route::delete('/{schedule}', 'Servers\ScheduleController@delete'); Route::post('/{schedule}/tasks', 'Servers\ScheduleTaskController@store'); diff --git a/tests/Unit/Services/Schedules/ProcessScheduleServiceTest.php b/tests/Unit/Services/Schedules/ProcessScheduleServiceTest.php deleted file mode 100644 index 01bbac149..000000000 --- a/tests/Unit/Services/Schedules/ProcessScheduleServiceTest.php +++ /dev/null @@ -1,85 +0,0 @@ -dispatcher = m::mock(Dispatcher::class); - $this->scheduleRepository = m::mock(ScheduleRepositoryInterface::class); - $this->taskRepository = m::mock(TaskRepositoryInterface::class); - } - - /** - * Test that a schedule can be updated and first task set to run. - */ - public function testScheduleIsUpdatedAndRun() - { - $model = factory(Schedule::class)->make(['id' => 123]); - $model->setRelation('tasks', collect([$task = factory(Task::class)->make([ - 'sequence_id' => 1, - ])])); - - $this->scheduleRepository->shouldReceive('loadTasks')->with($model)->once()->andReturn($model); - - $formatted = sprintf('%s %s %s * %s', $model->cron_minute, $model->cron_hour, $model->cron_day_of_month, $model->cron_day_of_week); - $this->scheduleRepository->shouldReceive('update')->with($model->id, [ - 'is_processing' => true, - 'next_run_at' => CronExpression::factory($formatted)->getNextRunDate(), - ]); - - $this->taskRepository->shouldReceive('update')->with($task->id, ['is_queued' => true])->once(); - - $this->dispatcher->shouldReceive('dispatch')->with(m::on(function ($class) use ($model, $task) { - $this->assertInstanceOf(RunTaskJob::class, $class); - $this->assertSame($task->time_offset, $class->delay); - $this->assertSame($task->id, $class->task->id); - - return true; - }))->once(); - - $this->getService()->handle($model); - } - - /** - * Return an instance of the service for testing purposes. - * - * @return \Pterodactyl\Services\Schedules\ProcessScheduleService - */ - private function getService(): ProcessScheduleService - { - return new ProcessScheduleService($this->dispatcher, $this->scheduleRepository, $this->taskRepository); - } -}