Skip to content

Commit ef38a51

Browse files
committed
Add base support for editing an existing task
1 parent edb9657 commit ef38a51

File tree

8 files changed

+266
-27
lines changed

8 files changed

+266
-27
lines changed

app/Http/Controllers/Api/Client/Servers/ScheduleTaskController.php

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,11 @@
1010
use Pterodactyl\Models\Permission;
1111
use Pterodactyl\Repositories\Eloquent\TaskRepository;
1212
use Pterodactyl\Exceptions\Http\HttpForbiddenException;
13+
use Pterodactyl\Transformers\Api\Client\TaskTransformer;
1314
use Pterodactyl\Http\Requests\Api\Client\ClientApiRequest;
1415
use Pterodactyl\Http\Controllers\Api\Client\ClientApiController;
1516
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
17+
use Pterodactyl\Http\Requests\Api\Client\Servers\Schedules\StoreTaskRequest;
1618

1719
class ScheduleTaskController extends ClientApiController
1820
{
@@ -33,6 +35,67 @@ public function __construct(TaskRepository $repository)
3335
$this->repository = $repository;
3436
}
3537

38+
/**
39+
* Create a new task for a given schedule and store it in the database.
40+
*
41+
* @param \Pterodactyl\Http\Requests\Api\Client\Servers\Schedules\StoreTaskRequest $request
42+
* @param \Pterodactyl\Models\Server $server
43+
* @param \Pterodactyl\Models\Schedule $schedule
44+
* @return array
45+
*
46+
* @throws \Pterodactyl\Exceptions\Model\DataValidationException
47+
*/
48+
public function store(StoreTaskRequest $request, Server $server, Schedule $schedule)
49+
{
50+
if ($schedule->server_id !== $server->id) {
51+
throw new NotFoundHttpException;
52+
}
53+
54+
$lastTask = $schedule->tasks->last();
55+
56+
/** @var \Pterodactyl\Models\Task $task */
57+
$task = $this->repository->create([
58+
'schedule_id' => $schedule->id,
59+
'sequence_id' => ($lastTask->sequence_id ?? 0) + 1,
60+
'action' => $request->input('action'),
61+
'payload' => $request->input('payload'),
62+
'time_offset' => $request->input('time_offset'),
63+
]);
64+
65+
return $this->fractal->item($task)
66+
->transformWith($this->getTransformer(TaskTransformer::class))
67+
->toArray();
68+
}
69+
70+
/**
71+
* Updates a given task for a server.
72+
*
73+
* @param \Pterodactyl\Http\Requests\Api\Client\Servers\Schedules\StoreTaskRequest $request
74+
* @param \Pterodactyl\Models\Server $server
75+
* @param \Pterodactyl\Models\Schedule $schedule
76+
* @param \Pterodactyl\Models\Task $task
77+
* @return array
78+
*
79+
* @throws \Pterodactyl\Exceptions\Model\DataValidationException
80+
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
81+
*/
82+
public function update(StoreTaskRequest $request, Server $server, Schedule $schedule, Task $task)
83+
{
84+
if ($schedule->id !== $task->schedule_id || $server->id !== $schedule->server_id) {
85+
throw new NotFoundHttpException;
86+
}
87+
88+
$this->repository->update($task->id, [
89+
'action' => $request->input('action'),
90+
'payload' => $request->input('payload'),
91+
'time_offset' => $request->input('time_offset'),
92+
]);
93+
94+
return $this->fractal->item($task->refresh())
95+
->transformWith($this->getTransformer(TaskTransformer::class))
96+
->toArray();
97+
}
98+
3699
/**
37100
* Determines if a user can delete the task for a given server.
38101
*
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
3+
namespace Pterodactyl\Http\Requests\Api\Client\Servers\Schedules;
4+
5+
use Pterodactyl\Models\Permission;
6+
use Pterodactyl\Http\Requests\Api\Client\ClientApiRequest;
7+
8+
class StoreTaskRequest extends ClientApiRequest
9+
{
10+
/**
11+
* Determine if the user is allowed to create a new task for this schedule. We simply
12+
* check if they can modify a schedule to determine if they're able to do this. There
13+
* are no task specific permissions.
14+
*
15+
* @return string
16+
*/
17+
public function permission()
18+
{
19+
return Permission::ACTION_SCHEDULE_UPDATE;
20+
}
21+
22+
/**
23+
* @return array
24+
*/
25+
public function rules(): array
26+
{
27+
return [
28+
'action' => 'required|in:command,power',
29+
'payload' => 'required|string',
30+
'time_offset' => 'required|numeric|min:0|max:900',
31+
'sequence_id' => 'sometimes|required|numeric|min:1',
32+
];
33+
}
34+
}

app/Models/Task.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ class Task extends Validable
7979
* @var array
8080
*/
8181
protected $attributes = [
82+
'time_offset' => 0,
8283
'is_queued' => false,
8384
];
8485

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { rawDataToServerTask, Task } from '@/api/server/schedules/getServerSchedules';
2+
import http from '@/api/http';
3+
4+
interface Data {
5+
action: string;
6+
payload: string;
7+
timeOffset: string | number;
8+
}
9+
10+
export default (uuid: string, schedule: number, task: number | undefined, { timeOffset, ...data }: Data): Promise<Task> => {
11+
return new Promise((resolve, reject) => {
12+
http.post(`/api/client/servers/${uuid}/schedules/${schedule}/tasks${task ? `/${task}` : ''}`, {
13+
...data,
14+
// eslint-disable-next-line @typescript-eslint/camelcase
15+
time_offset: timeOffset,
16+
})
17+
.then(({ data }) => resolve(rawDataToServerTask(data.attributes)))
18+
.catch(reject);
19+
});
20+
};

resources/scripts/components/server/schedules/ScheduleEditContainer.tsx

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -75,19 +75,17 @@ export default ({ match, location: { state } }: RouteComponentProps<Params, {},
7575
schedule.tasks
7676
.sort((a, b) => a.sequenceId - b.sequenceId)
7777
.map(task => (
78-
<div
78+
<ScheduleTaskRow
7979
key={task.id}
80-
className={'bg-neutral-700 border border-neutral-600 mb-2 px-6 py-4 rounded'}
81-
>
82-
<ScheduleTaskRow
83-
task={task}
84-
schedule={schedule.id}
85-
onTaskRemoved={() => setSchedule(s => ({
86-
...s!,
87-
tasks: s!.tasks.filter(t => t.id !== task.id),
88-
}))}
89-
/>
90-
</div>
80+
task={task}
81+
schedule={schedule.id}
82+
onTaskUpdated={task => setSchedule(s => ({
83+
...s!, tasks: s!.tasks.map(t => t.id === task.id ? task : t),
84+
}))}
85+
onTaskRemoved={() => setSchedule(s => ({
86+
...s!, tasks: s!.tasks.filter(t => t.id !== task.id),
87+
}))}
88+
/>
9189
))
9290
}
9391
{schedule.tasks.length > 1 &&

resources/scripts/components/server/schedules/ScheduleTaskRow.tsx

Lines changed: 33 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React, { useState } from 'react';
2-
import { Schedule, Task } from '@/api/server/schedules/getServerSchedules';
2+
import { Task } from '@/api/server/schedules/getServerSchedules';
33
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
44
import { faTrashAlt } from '@fortawesome/free-solid-svg-icons/faTrashAlt';
55
import { faCode } from '@fortawesome/free-solid-svg-icons/faCode';
@@ -11,16 +11,20 @@ import { ApplicationStore } from '@/state';
1111
import deleteScheduleTask from '@/api/server/schedules/deleteScheduleTask';
1212
import { httpErrorToHuman } from '@/api/http';
1313
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
14+
import TaskDetailsModal from '@/components/server/schedules/TaskDetailsModal';
15+
import { faPencilAlt } from '@fortawesome/free-solid-svg-icons/faPencilAlt';
1416

1517
interface Props {
1618
schedule: number;
1719
task: Task;
20+
onTaskUpdated: (task: Task) => void;
1821
onTaskRemoved: () => void;
1922
}
2023

21-
export default ({ schedule, task, onTaskRemoved }: Props) => {
22-
const [visible, setVisible] = useState(false);
23-
const [isLoading, setIsLoading] = useState(false);
24+
export default ({ schedule, task, onTaskUpdated, onTaskRemoved }: Props) => {
25+
const [ visible, setVisible ] = useState(false);
26+
const [ isLoading, setIsLoading ] = useState(false);
27+
const [ isEditing, setIsEditing ] = useState(false);
2428
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
2529
const { clearFlashes, addError } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
2630

@@ -37,8 +41,16 @@ export default ({ schedule, task, onTaskRemoved }: Props) => {
3741
};
3842

3943
return (
40-
<div className={'flex items-center'}>
44+
<div className={'flex items-center bg-neutral-700 border border-neutral-600 mb-2 px-6 py-4 rounded'}>
4145
<SpinnerOverlay visible={isLoading} fixed={true} size={'large'}/>
46+
{isEditing && <TaskDetailsModal
47+
scheduleId={schedule}
48+
task={task}
49+
onDismissed={task => {
50+
task && onTaskUpdated(task);
51+
setIsEditing(false);
52+
}}
53+
/>}
4254
<ConfirmTaskDeletionModal
4355
visible={visible}
4456
onDismissed={() => setVisible(false)}
@@ -63,16 +75,22 @@ export default ({ schedule, task, onTaskRemoved }: Props) => {
6375
</p>
6476
</div>
6577
}
66-
<div>
67-
<a
68-
href={'#'}
69-
aria-label={'Delete scheduled task'}
70-
className={'text-sm p-2 text-neutral-500 hover:text-red-600 transition-color duration-150'}
71-
onClick={() => setVisible(true)}
72-
>
73-
<FontAwesomeIcon icon={faTrashAlt}/>
74-
</a>
75-
</div>
78+
<a
79+
href={'#'}
80+
aria-label={'Edit scheduled task'}
81+
className={'block text-sm p-2 text-neutral-500 hover:text-neutral-100 transition-color duration-150 mr-4'}
82+
onClick={() => setIsEditing(true)}
83+
>
84+
<FontAwesomeIcon icon={faPencilAlt}/>
85+
</a>
86+
<a
87+
href={'#'}
88+
aria-label={'Delete scheduled task'}
89+
className={'block text-sm p-2 text-neutral-500 hover:text-red-600 transition-color duration-150'}
90+
onClick={() => setVisible(true)}
91+
>
92+
<FontAwesomeIcon icon={faTrashAlt}/>
93+
</a>
7694
</div>
7795
);
7896
};
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import React, { useEffect } from 'react';
2+
import Modal from '@/components/elements/Modal';
3+
import { Task } from '@/api/server/schedules/getServerSchedules';
4+
import { Form, Formik, Field as FormikField, FormikHelpers } from 'formik';
5+
import { ServerContext } from '@/state/server';
6+
import { Actions, useStoreActions } from 'easy-peasy';
7+
import { ApplicationStore } from '@/state';
8+
import createOrUpdateScheduleTask from '@/api/server/schedules/createOrUpdateScheduleTask';
9+
import { httpErrorToHuman } from '@/api/http';
10+
import Field from '@/components/elements/Field';
11+
import FormikFieldWrapper from '@/components/elements/FormikFieldWrapper';
12+
import FlashMessageRender from '@/components/FlashMessageRender';
13+
14+
interface Props {
15+
scheduleId: number;
16+
// If a task is provided we can assume we're editing it. If not provided,
17+
// we are creating a new one.
18+
task?: Task;
19+
onDismissed: (task: Task | undefined | void) => void;
20+
}
21+
22+
interface Values {
23+
action: string;
24+
payload: string;
25+
timeOffset: string;
26+
}
27+
28+
export default ({ task, scheduleId, onDismissed }: Props) => {
29+
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
30+
const { clearFlashes, addError } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
31+
32+
useEffect(() => {
33+
clearFlashes('schedule:task');
34+
}, []);
35+
36+
const submit = (values: Values, { setSubmitting }: FormikHelpers<Values>) => {
37+
clearFlashes('schedule:task');
38+
createOrUpdateScheduleTask(uuid, scheduleId, task?.id, values)
39+
.then(task => onDismissed(task))
40+
.catch(error => {
41+
console.error(error);
42+
setSubmitting(false);
43+
addError({ message: httpErrorToHuman(error), key: 'schedule:task' });
44+
});
45+
};
46+
47+
return (
48+
<Formik
49+
onSubmit={submit}
50+
initialValues={{
51+
action: task?.action || 'command',
52+
payload: task?.payload || '',
53+
timeOffset: task?.timeOffset.toString() || '0',
54+
}}
55+
>
56+
{({ values, isSubmitting }) => (
57+
<Modal
58+
visible={true}
59+
appear={true}
60+
onDismissed={() => onDismissed()}
61+
showSpinnerOverlay={isSubmitting}
62+
>
63+
<FlashMessageRender byKey={'schedule:task'} className={'mb-4'}/>
64+
<Form className={'m-0'}>
65+
<h3 className={'mb-6'}>Edit Task</h3>
66+
<div className={'flex'}>
67+
<div className={'mr-2'}>
68+
<label className={'input-dark-label'}>Action</label>
69+
<FormikField as={'select'} name={'action'} className={'input-dark'}>
70+
<option value={'command'}>Send command</option>
71+
<option value={'power'}>Send power action</option>
72+
</FormikField>
73+
</div>
74+
<div className={'flex-1'}>
75+
<Field
76+
name={'payload'}
77+
label={'Payload'}
78+
description={
79+
values.action === 'command'
80+
? 'The command to send to the server when this task executes.'
81+
: 'The power action to send when this task executes. Options are "start", "stop", "restart", or "kill".'
82+
}
83+
/>
84+
</div>
85+
</div>
86+
<div className={'mt-6'}>
87+
<Field
88+
name={'timeOffset'}
89+
label={'Time offset (in seconds)'}
90+
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.'}
91+
/>
92+
</div>
93+
<div className={'flex justify-end mt-6'}>
94+
<button type={'submit'} className={'btn btn-primary btn-sm'}>
95+
{task ? 'Save Changes' : 'Create Task'}
96+
</button>
97+
</div>
98+
</Form>
99+
</Modal>
100+
)}
101+
</Formik>
102+
);
103+
};

routes/api-client.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@
6262
Route::group(['prefix' => '/schedules'], function () {
6363
Route::get('/', 'Servers\ScheduleController@index');
6464
Route::get('/{schedule}', 'Servers\ScheduleController@view');
65+
Route::post('/{schedule}/tasks', 'Servers\ScheduleTaskController@store');
66+
Route::post('/{schedule}/tasks/{task}', 'Servers\ScheduleTaskController@update');
6567
Route::delete('/{schedule}/tasks/{task}', 'Servers\ScheduleTaskController@delete');
6668
});
6769

0 commit comments

Comments
 (0)