Add underlying data changes necessary for new task & schedule features

This commit is contained in:
Dane Everitt 2021-05-01 10:44:40 -07:00
parent cf1ac04e39
commit 92cd659db3
No known key found for this signature in database
GPG key ID: EEA66103B3D71F53
13 changed files with 201 additions and 107 deletions

View file

@ -16,7 +16,6 @@ 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;
@ -81,6 +80,7 @@ class ScheduleController extends ClientApiController
'cron_hour' => $request->input('hour'),
'cron_minute' => $request->input('minute'),
'is_active' => (bool) $request->input('is_active'),
'only_when_online' => (bool) $request->input('only_when_online'),
'next_run_at' => $this->getNextRunAt($request),
]);
@ -128,6 +128,7 @@ class ScheduleController extends ClientApiController
'cron_hour' => $request->input('hour'),
'cron_minute' => $request->input('minute'),
'is_active' => $active,
'only_when_online' => (bool) $request->input('only_when_online'),
'next_run_at' => $this->getNextRunAt($request),
];

View file

@ -59,6 +59,7 @@ class ScheduleTaskController extends ClientApiController
'action' => $request->input('action'),
'payload' => $request->input('payload') ?? '',
'time_offset' => $request->input('time_offset'),
'continue_on_failure' => (bool) $request->input('continue_on_failure'),
]);
return $this->fractal->item($task)
@ -84,6 +85,7 @@ class ScheduleTaskController extends ClientApiController
'action' => $request->input('action'),
'payload' => $request->input('payload') ?? '',
'time_offset' => $request->input('time_offset'),
'continue_on_failure' => (bool) $request->input('continue_on_failure'),
]);
return $this->fractal->item($task->refresh())

View file

@ -7,6 +7,7 @@ use Pterodactyl\Jobs\Job;
use Carbon\CarbonImmutable;
use Pterodactyl\Models\Task;
use InvalidArgumentException;
use Illuminate\Http\Response;
use Pterodactyl\Models\Schedule;
use Illuminate\Queue\SerializesModels;
use Illuminate\Queue\InteractsWithQueue;
@ -15,6 +16,7 @@ use Illuminate\Foundation\Bus\DispatchesJobs;
use Pterodactyl\Services\Backups\InitiateBackupService;
use Pterodactyl\Repositories\Wings\DaemonPowerRepository;
use Pterodactyl\Repositories\Wings\DaemonCommandRepository;
use Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException;
class RunTaskJob extends Job implements ShouldQueue
{
@ -62,18 +64,32 @@ class RunTaskJob extends Job implements ShouldQueue
$server = $this->task->server;
// Perform the provided task against the daemon.
switch ($this->task->action) {
case 'power':
$powerRepository->setServer($server)->send($this->task->payload);
break;
case 'command':
$commandRepository->setServer($server)->send($this->task->payload);
break;
case 'backup':
$backupService->setIgnoredFiles(explode(PHP_EOL, $this->task->payload))->handle($server, null, true);
break;
default:
throw new InvalidArgumentException('Cannot run a task that points to a non-existent action.');
try {
switch ($this->task->action) {
case Task::ACTION_POWER:
$powerRepository->setServer($server)->send($this->task->payload);
break;
case Task::ACTION_COMMAND:
$commandRepository->setServer($server)->send($this->task->payload);
break;
case Task::ACTION_BACKUP:
$backupService->setIgnoredFiles(explode(PHP_EOL, $this->task->payload))->handle($server, null, true);
break;
default:
throw new InvalidArgumentException('Cannot run a task that points to a non-existent action.');
}
} catch (Exception $exception) {
if ($exception instanceof DaemonConnectionException) {
// If the task "failed" because the server is offline and it was sending a command or
// executing a power action (which shouldn't happen?) then just stop trying to process
// the schedule, but don't actually log the failure.
if ($this->task->action === Task::ACTION_POWER || $this->task->action === Task::ACTION_COMMAND) {
// Do the thing
if ($exception->getStatusCode() === Response::HTTP_CONFLICT) {
}
}
}
throw $exception;
}
$this->markTaskNotQueued();

View file

@ -18,6 +18,7 @@ use Pterodactyl\Contracts\Extensions\HashidsInterface;
* @property string $cron_minute
* @property bool $is_active
* @property bool $is_processing
* @property bool $only_when_online
* @property \Carbon\Carbon|null $last_run_at
* @property \Carbon\Carbon|null $next_run_at
* @property \Carbon\Carbon $created_at
@ -63,6 +64,7 @@ class Schedule extends Model
'cron_minute',
'is_active',
'is_processing',
'only_when_online',
'last_run_at',
'next_run_at',
];
@ -75,6 +77,7 @@ class Schedule extends Model
'server_id' => 'integer',
'is_active' => 'boolean',
'is_processing' => 'boolean',
'only_when_online' => 'boolean',
];
/**
@ -99,6 +102,7 @@ class Schedule extends Model
'cron_minute' => '*',
'is_active' => true,
'is_processing' => false,
'only_when_online' => false,
];
/**
@ -114,6 +118,7 @@ class Schedule extends Model
'cron_minute' => 'required|string',
'is_active' => 'boolean',
'is_processing' => 'boolean',
'only_when_online' => 'boolean',
'last_run_at' => 'nullable|date',
'next_run_at' => 'nullable|date',
];
@ -122,6 +127,7 @@ class Schedule extends Model
* Returns the schedule's execution crontab entry as a string.
*
* @return \Carbon\CarbonImmutable
*
* @throws \Exception
*/
public function getNextRunDate()

View file

@ -14,6 +14,7 @@ use Pterodactyl\Contracts\Extensions\HashidsInterface;
* @property string $payload
* @property int $time_offset
* @property bool $is_queued
* @property bool $continue_on_failure
* @property \Carbon\Carbon $created_at
* @property \Carbon\Carbon $updated_at
* @property string $hashid
@ -30,6 +31,13 @@ class Task extends Model
*/
public const RESOURCE_NAME = 'schedule_task';
/**
* The default actions that can exist for a task in Pterodactyl.
*/
public const ACTION_POWER = 'power';
public const ACTION_COMMAND = 'command';
public const ACTION_BACKUP = 'backup';
/**
* The table associated with the model.
*
@ -56,6 +64,7 @@ class Task extends Model
'payload',
'time_offset',
'is_queued',
'continue_on_failure',
];
/**
@ -69,6 +78,7 @@ class Task extends Model
'sequence_id' => 'integer',
'time_offset' => 'integer',
'is_queued' => 'boolean',
'continue_on_failure' => 'boolean',
];
/**
@ -79,6 +89,7 @@ class Task extends Model
protected $attributes = [
'time_offset' => 0,
'is_queued' => false,
'continue_on_failure' => false,
];
/**
@ -91,6 +102,7 @@ class Task extends Model
'payload' => 'required_unless:action,backup|string',
'time_offset' => 'required|numeric|between:0,900',
'is_queued' => 'boolean',
'continue_on_failure' => 'boolean',
];
/**

View file

@ -45,6 +45,7 @@ class ScheduleTransformer extends BaseClientTransformer
],
'is_active' => $model->is_active,
'is_processing' => $model->is_processing,
'only_when_online' => $model->only_when_online,
'last_run_at' => $model->last_run_at ? $model->last_run_at->toIso8601String() : null,
'next_run_at' => $model->next_run_at ? $model->next_run_at->toIso8601String() : null,
'created_at' => $model->created_at->toIso8601String(),

View file

@ -28,6 +28,7 @@ class TaskTransformer extends BaseClientTransformer
'payload' => $model->payload,
'time_offset' => $model->time_offset,
'is_queued' => $model->is_queued,
'continue_on_failure' => $model->continue_on_failure,
'created_at' => $model->created_at->toIso8601String(),
'updated_at' => $model->updated_at->toIso8601String(),
];

View file

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class AddContinueOnFailureOptionToTasks extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('tasks', function (Blueprint $table) {
$table->unsignedTinyInteger('continue_on_failure')->after('is_queued')->default(0);
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('tasks', function (Blueprint $table) {
$table->dropColumn('continue_on_failure');
});
}
}

View file

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class AddOnlyRunWhenServerOnlineOptionToSchedules extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('schedules', function (Blueprint $table) {
$table->unsignedTinyInteger('only_when_online')->after('is_processing')->default(0);
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('schedules', function (Blueprint $table) {
$table->dropColumn('only_when_online');
});
}
}

View file

@ -1,20 +1,19 @@
import { rawDataToServerSchedule, Schedule } from '@/api/server/schedules/getServerSchedules';
import http from '@/api/http';
type Data = Pick<Schedule, 'cron' | 'name' | 'isActive'> & { id?: number }
type Data = Pick<Schedule, 'cron' | 'name' | 'onlyWhenOnline' | 'isActive'> & { id?: number }
export default (uuid: string, schedule: Data): Promise<Schedule> => {
return new Promise((resolve, reject) => {
http.post(`/api/client/servers/${uuid}/schedules${schedule.id ? `/${schedule.id}` : ''}`, {
is_active: schedule.isActive,
name: schedule.name,
minute: schedule.cron.minute,
hour: schedule.cron.hour,
day_of_month: schedule.cron.dayOfMonth,
month: schedule.cron.month,
day_of_week: schedule.cron.dayOfWeek,
})
.then(({ data }) => resolve(rawDataToServerSchedule(data.attributes)))
.catch(reject);
export default async (uuid: string, schedule: Data): Promise<Schedule> => {
const { data } = await http.post(`/api/client/servers/${uuid}/schedules${schedule.id ? `/${schedule.id}` : ''}`, {
is_active: schedule.isActive,
only_when_online: schedule.onlyWhenOnline,
name: schedule.name,
minute: schedule.cron.minute,
hour: schedule.cron.hour,
day_of_month: schedule.cron.dayOfMonth,
month: schedule.cron.month,
day_of_week: schedule.cron.dayOfWeek,
});
return rawDataToServerSchedule(data.attributes);
};

View file

@ -12,6 +12,7 @@ export interface Schedule {
};
isActive: boolean;
isProcessing: boolean;
onlyWhenOnline: boolean;
lastRunAt: Date | null;
nextRunAt: Date | null;
createdAt: Date;
@ -27,6 +28,7 @@ export interface Task {
payload: string;
timeOffset: number;
isQueued: boolean;
continueOnFailure: boolean;
createdAt: Date;
updatedAt: Date;
}
@ -38,6 +40,7 @@ export const rawDataToServerTask = (data: any): Task => ({
payload: data.payload,
timeOffset: data.time_offset,
isQueued: data.is_queued,
continueOnFailure: data.continue_on_failure,
createdAt: new Date(data.created_at),
updatedAt: new Date(data.updated_at),
});
@ -54,6 +57,7 @@ export const rawDataToServerSchedule = (data: any): Schedule => ({
},
isActive: data.is_active,
isProcessing: data.is_processing,
onlyWhenOnline: data.only_when_online,
lastRunAt: data.last_run_at ? new Date(data.last_run_at) : null,
nextRunAt: data.next_run_at ? new Date(data.next_run_at) : null,
createdAt: new Date(data.created_at),
@ -62,14 +66,12 @@ export const rawDataToServerSchedule = (data: any): Schedule => ({
tasks: (data.relationships?.tasks?.data || []).map((row: any) => rawDataToServerTask(row.attributes)),
});
export default (uuid: string): Promise<Schedule[]> => {
return new Promise((resolve, reject) => {
http.get(`/api/client/servers/${uuid}/schedules`, {
params: {
include: [ 'tasks' ],
},
})
.then(({ data }) => resolve((data.data || []).map((row: any) => rawDataToServerSchedule(row.attributes))))
.catch(reject);
export default async (uuid: string): Promise<Schedule[]> => {
const { data } = await http.get(`/api/client/servers/${uuid}/schedules`, {
params: {
include: [ 'tasks' ],
},
});
return (data.data || []).map((row: any) => rawDataToServerSchedule(row.attributes));
};

View file

@ -17,7 +17,7 @@ const Field = forwardRef<HTMLInputElement, Props>(({ id, name, light = false, la
<FormikField innerRef={ref} name={name} validate={validate}>
{
({ field, form: { errors, touched } }: FieldProps) => (
<>
<div>
{label &&
<Label htmlFor={id} isLight={light}>{label}</Label>
}
@ -35,7 +35,7 @@ const Field = forwardRef<HTMLInputElement, Props>(({ id, name, light = false, la
:
description ? <p className={'input-help'}>{description}</p> : null
}
</>
</div>
)
}
</FormikField>

View file

@ -1,8 +1,7 @@
import React, { useEffect, useState } from 'react';
import React, { useContext, useEffect } from 'react';
import { Schedule } from '@/api/server/schedules/getServerSchedules';
import Modal, { RequiredModalProps } from '@/components/elements/Modal';
import Field from '@/components/elements/Field';
import { Form, Formik, FormikHelpers, useFormikContext } from 'formik';
import { Form, Formik, FormikHelpers } from 'formik';
import FormikSwitch from '@/components/elements/FormikSwitch';
import createOrUpdateSchedule from '@/api/server/schedules/createOrUpdateSchedule';
import { ServerContext } from '@/state/server';
@ -11,10 +10,12 @@ import FlashMessageRender from '@/components/FlashMessageRender';
import useFlash from '@/plugins/useFlash';
import tw from 'twin.macro';
import Button from '@/components/elements/Button';
import ModalContext from '@/context/ModalContext';
import asModal from '@/hoc/asModal';
type Props = {
interface Props {
schedule?: Schedule;
} & RequiredModalProps;
}
interface Values {
name: string;
@ -24,70 +25,21 @@ interface Values {
hour: string;
minute: string;
enabled: boolean;
onlyWhenOnline: boolean;
}
const EditScheduleModal = ({ schedule, ...props }: Omit<Props, 'onScheduleUpdated'>) => {
const { isSubmitting } = useFormikContext();
return (
<Modal {...props} showSpinnerOverlay={isSubmitting}>
<h3 css={tw`text-2xl mb-6`}>{schedule ? 'Edit schedule' : 'Create new schedule'}</h3>
<FlashMessageRender byKey={'schedule:edit'} css={tw`mb-6`}/>
<Form>
<Field
name={'name'}
label={'Schedule name'}
description={'A human readable identifer for this schedule.'}
/>
<div css={tw`grid grid-cols-2 sm:grid-cols-5 gap-4 mt-6`}>
<div>
<Field name={'minute'} label={'Minute'}/>
</div>
<div>
<Field name={'hour'} label={'Hour'}/>
</div>
<div>
<Field name={'dayOfMonth'} label={'Day of month'}/>
</div>
<div>
<Field name={'month'} label={'Month'}/>
</div>
<div>
<Field name={'dayOfWeek'} label={'Day of week'}/>
</div>
</div>
<p css={tw`text-neutral-400 text-xs mt-2`}>
The schedule system supports the use of Cronjob syntax when defining when tasks should begin
running. Use the fields above to specify when these tasks should begin running.
</p>
<div css={tw`mt-6 bg-neutral-700 border border-neutral-800 shadow-inner p-4 rounded`}>
<FormikSwitch
name={'enabled'}
description={'If disabled, this schedule and it\'s associated tasks will not run.'}
label={'Enabled'}
/>
</div>
<div css={tw`mt-6 text-right`}>
<Button css={tw`w-full sm:w-auto`} type={'submit'} disabled={isSubmitting}>
{schedule ? 'Save changes' : 'Create schedule'}
</Button>
</div>
</Form>
</Modal>
);
};
export default ({ schedule, visible, ...props }: Props) => {
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
const EditScheduleModal = ({ schedule }: Props) => {
const { addError, clearFlashes } = useFlash();
const [ modalVisible, setModalVisible ] = useState(visible);
const { dismiss } = useContext(ModalContext);
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
const appendSchedule = ServerContext.useStoreActions(actions => actions.schedules.appendSchedule);
useEffect(() => {
setModalVisible(visible);
clearFlashes('schedule:edit');
}, [ visible ]);
return () => {
clearFlashes('schedule:edit');
};
}, []);
const submit = (values: Values, { setSubmitting }: FormikHelpers<Values>) => {
clearFlashes('schedule:edit');
@ -101,12 +53,13 @@ export default ({ schedule, visible, ...props }: Props) => {
month: values.month,
dayOfMonth: values.dayOfMonth,
},
onlyWhenOnline: values.onlyWhenOnline,
isActive: values.enabled,
})
.then(schedule => {
setSubmitting(false);
appendSchedule(schedule);
setModalVisible(false);
dismiss();
})
.catch(error => {
console.error(error);
@ -128,13 +81,50 @@ export default ({ schedule, visible, ...props }: Props) => {
dayOfWeek: schedule?.cron.dayOfWeek || '*',
enabled: schedule ? schedule.isActive : true,
} as Values}
validationSchema={null}
>
<EditScheduleModal
visible={modalVisible}
schedule={schedule}
{...props}
/>
{({ isSubmitting }) => (
<Form>
<h3 css={tw`text-2xl mb-6`}>{schedule ? 'Edit schedule' : 'Create new schedule'}</h3>
<FlashMessageRender byKey={'schedule:edit'} css={tw`mb-6`}/>
<Field
name={'name'}
label={'Schedule name'}
description={'A human readable identifer for this schedule.'}
/>
<div css={tw`grid grid-cols-2 sm:grid-cols-5 gap-4 mt-6`}>
<Field name={'minute'} label={'Minute'}/>
<Field name={'hour'} label={'Hour'}/>
<Field name={'dayOfMonth'} label={'Day of month'}/>
<Field name={'month'} label={'Month'}/>
<Field name={'dayOfWeek'} label={'Day of week'}/>
</div>
<p css={tw`text-neutral-400 text-xs mt-2`}>
The schedule system supports the use of Cronjob syntax when defining when tasks should begin
running. Use the fields above to specify when these tasks should begin running.
</p>
<div css={tw`mt-6 bg-neutral-700 border border-neutral-800 shadow-inner p-4 rounded`}>
<FormikSwitch
name={'only_when_online'}
description={'If disabled this schedule will always run, regardless of the server\'s current power state.'}
label={'Only When Server Is Online'}
/>
</div>
<div css={tw`mt-6 bg-neutral-700 border border-neutral-800 shadow-inner p-4 rounded`}>
<FormikSwitch
name={'enabled'}
description={'If disabled this schedule and it\'s associated tasks will not run.'}
label={'Schedule Enabled'}
/>
</div>
<div css={tw`mt-6 text-right`}>
<Button css={tw`w-full sm:w-auto`} type={'submit'} disabled={isSubmitting}>
{schedule ? 'Save changes' : 'Create schedule'}
</Button>
</div>
</Form>
)}
</Formik>
);
};
export default asModal<Props>()(EditScheduleModal);