Add base support for editing an existing task

This commit is contained in:
Dane Everitt 2020-03-18 22:28:32 -07:00
parent edb9657e2b
commit ef38a51d6d
No known key found for this signature in database
GPG key ID: EEA66103B3D71F53
8 changed files with 266 additions and 27 deletions

View file

@ -10,9 +10,11 @@ use Illuminate\Http\JsonResponse;
use Pterodactyl\Models\Permission; use Pterodactyl\Models\Permission;
use Pterodactyl\Repositories\Eloquent\TaskRepository; use Pterodactyl\Repositories\Eloquent\TaskRepository;
use Pterodactyl\Exceptions\Http\HttpForbiddenException; use Pterodactyl\Exceptions\Http\HttpForbiddenException;
use Pterodactyl\Transformers\Api\Client\TaskTransformer;
use Pterodactyl\Http\Requests\Api\Client\ClientApiRequest; use Pterodactyl\Http\Requests\Api\Client\ClientApiRequest;
use Pterodactyl\Http\Controllers\Api\Client\ClientApiController; use Pterodactyl\Http\Controllers\Api\Client\ClientApiController;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Pterodactyl\Http\Requests\Api\Client\Servers\Schedules\StoreTaskRequest;
class ScheduleTaskController extends ClientApiController class ScheduleTaskController extends ClientApiController
{ {
@ -33,6 +35,67 @@ class ScheduleTaskController extends ClientApiController
$this->repository = $repository; $this->repository = $repository;
} }
/**
* Create a new task for a given schedule and store it in the database.
*
* @param \Pterodactyl\Http\Requests\Api\Client\Servers\Schedules\StoreTaskRequest $request
* @param \Pterodactyl\Models\Server $server
* @param \Pterodactyl\Models\Schedule $schedule
* @return array
*
* @throws \Pterodactyl\Exceptions\Model\DataValidationException
*/
public function store(StoreTaskRequest $request, Server $server, Schedule $schedule)
{
if ($schedule->server_id !== $server->id) {
throw new NotFoundHttpException;
}
$lastTask = $schedule->tasks->last();
/** @var \Pterodactyl\Models\Task $task */
$task = $this->repository->create([
'schedule_id' => $schedule->id,
'sequence_id' => ($lastTask->sequence_id ?? 0) + 1,
'action' => $request->input('action'),
'payload' => $request->input('payload'),
'time_offset' => $request->input('time_offset'),
]);
return $this->fractal->item($task)
->transformWith($this->getTransformer(TaskTransformer::class))
->toArray();
}
/**
* Updates a given task for a server.
*
* @param \Pterodactyl\Http\Requests\Api\Client\Servers\Schedules\StoreTaskRequest $request
* @param \Pterodactyl\Models\Server $server
* @param \Pterodactyl\Models\Schedule $schedule
* @param \Pterodactyl\Models\Task $task
* @return array
*
* @throws \Pterodactyl\Exceptions\Model\DataValidationException
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
*/
public function update(StoreTaskRequest $request, Server $server, Schedule $schedule, Task $task)
{
if ($schedule->id !== $task->schedule_id || $server->id !== $schedule->server_id) {
throw new NotFoundHttpException;
}
$this->repository->update($task->id, [
'action' => $request->input('action'),
'payload' => $request->input('payload'),
'time_offset' => $request->input('time_offset'),
]);
return $this->fractal->item($task->refresh())
->transformWith($this->getTransformer(TaskTransformer::class))
->toArray();
}
/** /**
* Determines if a user can delete the task for a given server. * Determines if a user can delete the task for a given server.
* *

View file

@ -0,0 +1,34 @@
<?php
namespace Pterodactyl\Http\Requests\Api\Client\Servers\Schedules;
use Pterodactyl\Models\Permission;
use Pterodactyl\Http\Requests\Api\Client\ClientApiRequest;
class StoreTaskRequest extends ClientApiRequest
{
/**
* Determine if the user is allowed to create a new task for this schedule. We simply
* check if they can modify a schedule to determine if they're able to do this. There
* are no task specific permissions.
*
* @return string
*/
public function permission()
{
return Permission::ACTION_SCHEDULE_UPDATE;
}
/**
* @return array
*/
public function rules(): array
{
return [
'action' => 'required|in:command,power',
'payload' => 'required|string',
'time_offset' => 'required|numeric|min:0|max:900',
'sequence_id' => 'sometimes|required|numeric|min:1',
];
}
}

View file

@ -79,6 +79,7 @@ class Task extends Validable
* @var array * @var array
*/ */
protected $attributes = [ protected $attributes = [
'time_offset' => 0,
'is_queued' => false, 'is_queued' => false,
]; ];

View file

@ -0,0 +1,20 @@
import { rawDataToServerTask, Task } from '@/api/server/schedules/getServerSchedules';
import http from '@/api/http';
interface Data {
action: string;
payload: string;
timeOffset: string | number;
}
export default (uuid: string, schedule: number, task: number | undefined, { timeOffset, ...data }: Data): Promise<Task> => {
return new Promise((resolve, reject) => {
http.post(`/api/client/servers/${uuid}/schedules/${schedule}/tasks${task ? `/${task}` : ''}`, {
...data,
// eslint-disable-next-line @typescript-eslint/camelcase
time_offset: timeOffset,
})
.then(({ data }) => resolve(rawDataToServerTask(data.attributes)))
.catch(reject);
});
};

View file

@ -75,19 +75,17 @@ export default ({ match, location: { state } }: RouteComponentProps<Params, {},
schedule.tasks schedule.tasks
.sort((a, b) => a.sequenceId - b.sequenceId) .sort((a, b) => a.sequenceId - b.sequenceId)
.map(task => ( .map(task => (
<div
key={task.id}
className={'bg-neutral-700 border border-neutral-600 mb-2 px-6 py-4 rounded'}
>
<ScheduleTaskRow <ScheduleTaskRow
key={task.id}
task={task} task={task}
schedule={schedule.id} schedule={schedule.id}
onTaskUpdated={task => setSchedule(s => ({
...s!, tasks: s!.tasks.map(t => t.id === task.id ? task : t),
}))}
onTaskRemoved={() => setSchedule(s => ({ onTaskRemoved={() => setSchedule(s => ({
...s!, ...s!, tasks: s!.tasks.filter(t => t.id !== task.id),
tasks: s!.tasks.filter(t => t.id !== task.id),
}))} }))}
/> />
</div>
)) ))
} }
{schedule.tasks.length > 1 && {schedule.tasks.length > 1 &&

View file

@ -1,5 +1,5 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Schedule, Task } from '@/api/server/schedules/getServerSchedules'; import { Task } from '@/api/server/schedules/getServerSchedules';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faTrashAlt } from '@fortawesome/free-solid-svg-icons/faTrashAlt'; import { faTrashAlt } from '@fortawesome/free-solid-svg-icons/faTrashAlt';
import { faCode } from '@fortawesome/free-solid-svg-icons/faCode'; import { faCode } from '@fortawesome/free-solid-svg-icons/faCode';
@ -11,16 +11,20 @@ import { ApplicationStore } from '@/state';
import deleteScheduleTask from '@/api/server/schedules/deleteScheduleTask'; import deleteScheduleTask from '@/api/server/schedules/deleteScheduleTask';
import { httpErrorToHuman } from '@/api/http'; import { httpErrorToHuman } from '@/api/http';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay'; import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import TaskDetailsModal from '@/components/server/schedules/TaskDetailsModal';
import { faPencilAlt } from '@fortawesome/free-solid-svg-icons/faPencilAlt';
interface Props { interface Props {
schedule: number; schedule: number;
task: Task; task: Task;
onTaskUpdated: (task: Task) => void;
onTaskRemoved: () => void; onTaskRemoved: () => void;
} }
export default ({ schedule, task, onTaskRemoved }: Props) => { export default ({ schedule, task, onTaskUpdated, onTaskRemoved }: Props) => {
const [ visible, setVisible ] = useState(false); const [ visible, setVisible ] = useState(false);
const [ isLoading, setIsLoading ] = useState(false); const [ isLoading, setIsLoading ] = useState(false);
const [ isEditing, setIsEditing ] = useState(false);
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid); const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
const { clearFlashes, addError } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes); const { clearFlashes, addError } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
@ -37,8 +41,16 @@ export default ({ schedule, task, onTaskRemoved }: Props) => {
}; };
return ( return (
<div className={'flex items-center'}> <div className={'flex items-center bg-neutral-700 border border-neutral-600 mb-2 px-6 py-4 rounded'}>
<SpinnerOverlay visible={isLoading} fixed={true} size={'large'}/> <SpinnerOverlay visible={isLoading} fixed={true} size={'large'}/>
{isEditing && <TaskDetailsModal
scheduleId={schedule}
task={task}
onDismissed={task => {
task && onTaskUpdated(task);
setIsEditing(false);
}}
/>}
<ConfirmTaskDeletionModal <ConfirmTaskDeletionModal
visible={visible} visible={visible}
onDismissed={() => setVisible(false)} onDismissed={() => setVisible(false)}
@ -63,16 +75,22 @@ export default ({ schedule, task, onTaskRemoved }: Props) => {
</p> </p>
</div> </div>
} }
<div> <a
href={'#'}
aria-label={'Edit scheduled task'}
className={'block text-sm p-2 text-neutral-500 hover:text-neutral-100 transition-color duration-150 mr-4'}
onClick={() => setIsEditing(true)}
>
<FontAwesomeIcon icon={faPencilAlt}/>
</a>
<a <a
href={'#'} href={'#'}
aria-label={'Delete scheduled task'} aria-label={'Delete scheduled task'}
className={'text-sm p-2 text-neutral-500 hover:text-red-600 transition-color duration-150'} className={'block text-sm p-2 text-neutral-500 hover:text-red-600 transition-color duration-150'}
onClick={() => setVisible(true)} onClick={() => setVisible(true)}
> >
<FontAwesomeIcon icon={faTrashAlt}/> <FontAwesomeIcon icon={faTrashAlt}/>
</a> </a>
</div> </div>
</div>
); );
}; };

View file

@ -0,0 +1,103 @@
import React, { useEffect } from 'react';
import Modal from '@/components/elements/Modal';
import { Task } from '@/api/server/schedules/getServerSchedules';
import { Form, Formik, Field as FormikField, FormikHelpers } from 'formik';
import { ServerContext } from '@/state/server';
import { Actions, useStoreActions } from 'easy-peasy';
import { ApplicationStore } from '@/state';
import createOrUpdateScheduleTask from '@/api/server/schedules/createOrUpdateScheduleTask';
import { httpErrorToHuman } from '@/api/http';
import Field from '@/components/elements/Field';
import FormikFieldWrapper from '@/components/elements/FormikFieldWrapper';
import FlashMessageRender from '@/components/FlashMessageRender';
interface Props {
scheduleId: number;
// If a task is provided we can assume we're editing it. If not provided,
// we are creating a new one.
task?: Task;
onDismissed: (task: Task | undefined | void) => void;
}
interface Values {
action: string;
payload: string;
timeOffset: string;
}
export default ({ task, scheduleId, onDismissed }: Props) => {
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
const { clearFlashes, addError } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
useEffect(() => {
clearFlashes('schedule:task');
}, []);
const submit = (values: Values, { setSubmitting }: FormikHelpers<Values>) => {
clearFlashes('schedule:task');
createOrUpdateScheduleTask(uuid, scheduleId, task?.id, values)
.then(task => onDismissed(task))
.catch(error => {
console.error(error);
setSubmitting(false);
addError({ message: httpErrorToHuman(error), key: 'schedule:task' });
});
};
return (
<Formik
onSubmit={submit}
initialValues={{
action: task?.action || 'command',
payload: task?.payload || '',
timeOffset: task?.timeOffset.toString() || '0',
}}
>
{({ values, isSubmitting }) => (
<Modal
visible={true}
appear={true}
onDismissed={() => onDismissed()}
showSpinnerOverlay={isSubmitting}
>
<FlashMessageRender byKey={'schedule:task'} className={'mb-4'}/>
<Form className={'m-0'}>
<h3 className={'mb-6'}>Edit Task</h3>
<div className={'flex'}>
<div className={'mr-2'}>
<label className={'input-dark-label'}>Action</label>
<FormikField as={'select'} name={'action'} className={'input-dark'}>
<option value={'command'}>Send command</option>
<option value={'power'}>Send power action</option>
</FormikField>
</div>
<div className={'flex-1'}>
<Field
name={'payload'}
label={'Payload'}
description={
values.action === 'command'
? 'The command to send to the server when this task executes.'
: 'The power action to send when this task executes. Options are "start", "stop", "restart", or "kill".'
}
/>
</div>
</div>
<div className={'mt-6'}>
<Field
name={'timeOffset'}
label={'Time offset (in seconds)'}
description={'The amount of time to wait after the previous task executes before running this one. If this is the first task on a schedule this will not be applied.'}
/>
</div>
<div className={'flex justify-end mt-6'}>
<button type={'submit'} className={'btn btn-primary btn-sm'}>
{task ? 'Save Changes' : 'Create Task'}
</button>
</div>
</Form>
</Modal>
)}
</Formik>
);
};

View file

@ -62,6 +62,8 @@ Route::group(['prefix' => '/servers/{server}', 'middleware' => [AuthenticateServ
Route::group(['prefix' => '/schedules'], function () { Route::group(['prefix' => '/schedules'], function () {
Route::get('/', 'Servers\ScheduleController@index'); Route::get('/', 'Servers\ScheduleController@index');
Route::get('/{schedule}', 'Servers\ScheduleController@view'); Route::get('/{schedule}', 'Servers\ScheduleController@view');
Route::post('/{schedule}/tasks', 'Servers\ScheduleTaskController@store');
Route::post('/{schedule}/tasks/{task}', 'Servers\ScheduleTaskController@update');
Route::delete('/{schedule}/tasks/{task}', 'Servers\ScheduleTaskController@delete'); Route::delete('/{schedule}/tasks/{task}', 'Servers\ScheduleTaskController@delete');
}); });