Merge branch 'develop' into xtermstuff

This commit is contained in:
Dane Everitt 2020-10-15 19:56:09 -07:00 committed by GitHub
commit 2685295110
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 585 additions and 288 deletions

View File

@ -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.
*

View File

@ -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.
*

View File

@ -0,0 +1,25 @@
<?php
namespace Pterodactyl\Http\Requests\Api\Client\Servers\Schedules;
use Pterodactyl\Models\Permission;
use Pterodactyl\Http\Requests\Api\Client\ClientApiRequest;
class TriggerScheduleRequest extends ClientApiRequest
{
/**
* @return string
*/
public function permission(): string
{
return Permission::ACTION_SCHEDULE_UPDATE;
}
/**
* @return array
*/
public function rules(): array
{
return [];
}
}

View File

@ -42,15 +42,13 @@ class RunTaskJob extends Job implements ShouldQueue
* @param \Pterodactyl\Repositories\Wings\DaemonCommandRepository $commandRepository
* @param \Pterodactyl\Services\Backups\InitiateBackupService $backupService
* @param \Pterodactyl\Repositories\Wings\DaemonPowerRepository $powerRepository
* @param \Pterodactyl\Repositories\Eloquent\TaskRepository $taskRepository
*
* @throws \Throwable
*/
public function handle(
DaemonCommandRepository $commandRepository,
InitiateBackupService $backupService,
DaemonPowerRepository $powerRepository,
TaskRepository $taskRepository
DaemonPowerRepository $powerRepository
) {
// Do not process a task that is not set to active.
if (! $this->task->schedule->is_active) {

View File

@ -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.
*

View File

@ -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.
*

View File

@ -2,12 +2,11 @@
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;
use Pterodactyl\Exceptions\DisplayException;
class ProcessScheduleService
{
@ -17,62 +16,51 @@ 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)->first();
$formattedCron = sprintf('%s %s %s * %s',
$schedule->cron_minute,
$schedule->cron_hour,
$schedule->cron_day_of_month,
$schedule->cron_day_of_week
);
if (is_null($task)) {
throw new DisplayException(
'Cannot process schedule for task execution: no tasks are registered.'
);
}
$this->scheduleRepository->update($schedule->id, [
'is_processing' => true,
'next_run_at' => CronExpression::factory($formattedCron)->getNextRunDate(),
]);
$this->connection->transaction(function () use ($schedule, $task) {
$schedule->forceFill([
'is_processing' => true,
'next_run_at' => $schedule->getNextRunDate(),
])->saveOrFail();
$this->taskRepository->update($task->id, ['is_queued' => true]);
$task->update(['is_queued' => true]);
});
$this->dispatcher->dispatch(
$this->dispatcher->{$now ? 'dispatchNow' : 'dispatch'}(
(new RunTaskJob($task))->delay($task->time_offset)
);
}

View File

@ -1,9 +1,9 @@
{
"name": "pterodactyl-panel",
"dependencies": {
"@fortawesome/fontawesome-svg-core": "1.2.19",
"@fortawesome/free-solid-svg-icons": "^5.9.0",
"@fortawesome/react-fontawesome": "0.1.4",
"@fortawesome/fontawesome-svg-core": "^1.2.32",
"@fortawesome/free-solid-svg-icons": "^5.15.1",
"@fortawesome/react-fontawesome": "^0.1.11",
"axios": "^0.19.2",
"chart.js": "^2.8.0",
"codemirror": "^5.57.0",
@ -23,9 +23,9 @@
"react": "^16.13.1",
"react-dom": "npm:@hot-loader/react-dom",
"react-fast-compare": "^3.2.0",
"react-ga": "^3.1.2",
"react-google-recaptcha": "^2.0.1",
"react-helmet": "^6.1.0",
"react-ga": "^3.1.2",
"react-hot-loader": "^4.12.21",
"react-i18next": "^11.2.1",
"react-redux": "^7.1.0",

View File

@ -0,0 +1,4 @@
import http from '@/api/http';
export default async (server: string, schedule: number): Promise<void> =>
await http.post(`/api/client/servers/${server}/schedules/${schedule}/execute`);

View File

@ -57,8 +57,8 @@ const ButtonStyle = styled.button<Omit<Props, 'isLoading'>>`
`};
`};
${props => props.size === 'xsmall' && tw`p-2 text-xs`};
${props => (!props.size || props.size === 'small') && tw`p-3`};
${props => props.size === 'xsmall' && tw`px-2 py-1 text-xs`};
${props => (!props.size || props.size === 'small') && tw`px-4 py-2`};
${props => props.size === 'large' && tw`p-4 text-sm`};
${props => props.size === 'xlarge' && tw`p-4 w-full`};

View File

@ -0,0 +1,31 @@
import React, { CSSProperties } from 'react';
import { IconDefinition } from '@fortawesome/fontawesome-svg-core';
import tw from 'twin.macro';
interface Props {
icon: IconDefinition;
className?: string;
style?: CSSProperties;
}
const Icon = ({ icon, className, style }: Props) => {
let [ width, height, , , paths ] = icon.icon;
paths = Array.isArray(paths) ? paths : [ paths ];
return (
<svg
xmlns={'http://www.w3.org/2000/svg'}
viewBox={`0 0 ${width} ${height}`}
css={tw`fill-current inline-block`}
className={className}
style={style}
>
{paths.map((path, index) => (
<path key={`svg_path_${index}`} d={path}/>
))}
</svg>
);
};
export default Icon;

View File

@ -1,9 +1,10 @@
import React, { useEffect, useMemo, useState } from 'react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import Spinner from '@/components/elements/Spinner';
import tw from 'twin.macro';
import styled, { css } from 'styled-components/macro';
import { breakpoint } from '@/theme';
import Fade from '@/components/elements/Fade';
import { createPortal } from 'react-dom';
export interface RequiredModalProps {
visible: boolean;
@ -124,4 +125,10 @@ const Modal: React.FC<ModalProps> = ({ visible, appear, dismissable, showSpinner
);
};
export default Modal;
const PortaledModal: React.FC<ModalProps> = ({ children, ...props }) => {
const element = useRef(document.getElementById('modal-portal'));
return createPortal(<Modal {...props}>{children}</Modal>, element.current!);
};
export default PortaledModal;

View File

@ -49,7 +49,7 @@ export default ({ scheduleId, onDeleted }: Props) => {
Are you sure you want to delete this schedule? All tasks will be removed and any running processes
will be terminated.
</ConfirmationModal>
<Button css={tw`mr-4`} color={'red'} isSecondary onClick={() => setVisible(true)}>
<Button css={tw`flex-1 sm:flex-none mr-4 border-transparent`} color={'red'} isSecondary onClick={() => setVisible(true)}>
Delete
</Button>
</>

View File

@ -2,6 +2,7 @@ import React, { useState } from 'react';
import { Schedule } from '@/api/server/schedules/getServerSchedules';
import TaskDetailsModal from '@/components/server/schedules/TaskDetailsModal';
import Button from '@/components/elements/Button';
import tw from 'twin.macro';
interface Props {
schedule: Schedule;
@ -18,7 +19,7 @@ export default ({ schedule }: Props) => {
onDismissed={() => setVisible(false)}
/>
}
<Button onClick={() => setVisible(true)}>
<Button onClick={() => setVisible(true)} css={tw`flex-1`}>
New Task
</Button>
</>

View File

@ -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 (
<>
<SpinnerOverlay visible={loading} size={'large'}/>
<Button
isSecondary
color={'grey'}
css={tw`flex-1 sm:flex-none border-transparent`}
disabled={schedule.isProcessing}
onClick={onTriggerExecute}
>
Run Now
</Button>
</>
);
};
export default RunScheduleButton;

View File

@ -0,0 +1,35 @@
import React from 'react';
import tw from 'twin.macro';
import { Schedule } from '@/api/server/schedules/getServerSchedules';
interface Props {
cron: Schedule['cron'];
className?: string;
}
const ScheduleCronRow = ({ cron, className }: Props) => (
<div css={tw`flex`} className={className}>
<div css={tw`w-1/5 sm:w-auto text-center`}>
<p css={tw`font-medium`}>{cron.minute}</p>
<p css={tw`text-2xs text-neutral-500 uppercase`}>Minute</p>
</div>
<div css={tw`w-1/5 sm:w-auto text-center ml-4`}>
<p css={tw`font-medium`}>{cron.hour}</p>
<p css={tw`text-2xs text-neutral-500 uppercase`}>Hour</p>
</div>
<div css={tw`w-1/5 sm:w-auto text-center ml-4`}>
<p css={tw`font-medium`}>{cron.dayOfMonth}</p>
<p css={tw`text-2xs text-neutral-500 uppercase`}>Day (Month)</p>
</div>
<div css={tw`w-1/5 sm:w-auto text-center ml-4`}>
<p css={tw`font-medium`}>*</p>
<p css={tw`text-2xs text-neutral-500 uppercase`}>Month</p>
</div>
<div css={tw`w-1/5 sm:w-auto text-center ml-4`}>
<p css={tw`font-medium`}>{cron.dayOfWeek}</p>
<p css={tw`text-2xs text-neutral-500 uppercase`}>Day (Week)</p>
</div>
</div>
);
export default ScheduleCronRow;

View File

@ -1,12 +1,9 @@
import React, { useEffect, useState } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import { RouteComponentProps } from 'react-router-dom';
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 ScheduleRow from '@/components/server/schedules/ScheduleRow';
import ScheduleTaskRow from '@/components/server/schedules/ScheduleTaskRow';
import EditScheduleModal from '@/components/server/schedules/EditScheduleModal';
import NewTaskButton from '@/components/server/schedules/NewTaskButton';
import DeleteScheduleButton from '@/components/server/schedules/DeleteScheduleButton';
@ -16,7 +13,11 @@ import { ServerContext } from '@/state/server';
import PageContentBlock from '@/components/elements/PageContentBlock';
import tw from 'twin.macro';
import Button from '@/components/elements/Button';
import GreyRowBox from '@/components/elements/GreyRowBox';
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;
@ -26,15 +27,34 @@ interface State {
schedule?: Schedule;
}
const CronBox = ({ title, value }: { title: string; value: string }) => (
<div css={tw`bg-neutral-700 rounded p-4`}>
<p css={tw`text-neutral-300 text-sm`}>{title}</p>
<p css={tw`text-2xl font-medium text-neutral-100`}>{value}</p>
</div>
);
const ActivePill = ({ active }: { active: boolean }) => (
<span
css={[
tw`rounded-full px-2 py-px text-xs ml-4 uppercase`,
active ? tw`bg-green-600 text-green-100` : tw`bg-red-600 text-red-100`,
]}
>
{active ? 'Active' : 'Inactive'}
</span>
);
export default ({ match, history, location: { state } }: RouteComponentProps<Params, Record<string, unknown>, State>) => {
const id = ServerContext.useStoreState(state => 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);
const schedule = ServerContext.useStoreState(st => st.schedules.data.find(s => s.id === state.schedule?.id), [ match ]);
// @ts-ignore
const schedule: Schedule | undefined = ServerContext.useStoreState(st => st.schedules.data.find(s => s.id === state.schedule?.id), isEqual);
const appendSchedule = ServerContext.useStoreActions(actions => actions.schedules.appendSchedule);
useEffect(() => {
@ -48,11 +68,15 @@ export default ({ match, history, location: { state } }: RouteComponentProps<Par
.then(schedule => appendSchedule(schedule))
.catch(error => {
console.error(error);
addError({ message: httpErrorToHuman(error), key: 'schedules' });
clearAndAddHttpError({ error, key: 'schedules' });
})
.then(() => setIsLoading(false));
}, [ match ]);
const toggleEditModal = useCallback(() => {
setShowEditModal(s => !s);
}, []);
return (
<PageContentBlock>
<FlashMessageRender byKey={'schedules'} css={tw`mb-4`}/>
@ -60,52 +84,73 @@ export default ({ match, history, location: { state } }: RouteComponentProps<Par
<Spinner size={'large'} centered/>
:
<>
<GreyRowBox css={tw`cursor-pointer mb-2 flex-wrap`}>
<ScheduleRow schedule={schedule}/>
</GreyRowBox>
<EditScheduleModal
visible={showEditModal}
schedule={schedule}
onDismissed={() => setShowEditModal(false)}
/>
<div css={tw`flex items-center mt-8 mb-4`}>
<div css={tw`flex-1`}>
<h2 css={tw`text-2xl`}>Configured Tasks</h2>
<ScheduleCronRow cron={schedule.cron} css={tw`sm:hidden bg-neutral-700 rounded mb-4 p-3`}/>
<div css={tw`hidden sm:grid grid-cols-5 md:grid-cols-7 gap-4 mb-6`}>
<CronBox title={'Minute'} value={schedule.cron.minute}/>
<CronBox title={'Hour'} value={schedule.cron.hour}/>
<CronBox title={'Day (Month)'} value={schedule.cron.dayOfMonth}/>
<CronBox title={'Month'} value={'*'}/>
<CronBox title={'Day (Week)'} value={schedule.cron.dayOfWeek}/>
</div>
<div css={tw`rounded shadow`}>
<div css={tw`sm:flex items-center bg-neutral-900 p-3 sm:p-6 border-b-4 border-neutral-600 rounded-t`}>
<div css={tw`flex-1`}>
<h3 css={tw`flex items-center text-neutral-100 text-2xl`}>
{schedule.name}
{schedule.isProcessing ?
<span
css={tw`flex items-center rounded-full px-2 py-px text-xs ml-4 uppercase bg-neutral-600 text-white`}
>
<Spinner css={tw`w-3! h-3! mr-2`}/>
Processing
</span>
:
<ActivePill active={schedule.isActive}/>
}
</h3>
<p css={tw`mt-1 text-sm text-neutral-300`}>
Last run at:&nbsp;
{schedule.lastRunAt ? format(schedule.lastRunAt, 'MMM do \'at\' h:mma') : 'never'}
</p>
</div>
<div css={tw`flex sm:block mt-3 sm:mt-0`}>
<Can action={'schedule.update'}>
<Button
isSecondary
color={'grey'}
size={'small'}
css={tw`flex-1 mr-4 border-transparent`}
onClick={toggleEditModal}
>
Edit
</Button>
<NewTaskButton schedule={schedule}/>
</Can>
</div>
</div>
<div css={tw`bg-neutral-700 rounded-b`}>
{schedule.tasks.length > 0 ?
schedule.tasks.map(task => (
<ScheduleTaskRow key={`${schedule.id}_${task.id}`} task={task} schedule={schedule}/>
))
:
null
}
</div>
</div>
{schedule.tasks.length > 0 ?
<>
{
schedule.tasks
.sort((a, b) => a.sequenceId - b.sequenceId)
.map(task => (
<ScheduleTaskRow key={task.id} task={task} schedule={schedule}/>
))
}
{schedule.tasks.length > 1 &&
<p css={tw`text-xs text-neutral-400`}>
Task delays are relative to the previous task in the listing.
</p>
}
</>
:
<p css={tw`text-sm text-neutral-400`}>
There are no tasks configured for this schedule.
</p>
}
<div css={tw`mt-8 flex justify-end`}>
<EditScheduleModal visible={showEditModal} schedule={schedule} onDismissed={toggleEditModal}/>
<div css={tw`mt-6 flex sm:justify-end`}>
<Can action={'schedule.delete'}>
<DeleteScheduleButton
scheduleId={schedule.id}
onDeleted={() => history.push(`/server/${id}/schedules`)}
/>
</Can>
{schedule.isActive && schedule.tasks.length > 0 &&
<Can action={'schedule.update'}>
<Button css={tw`mr-4`} onClick={() => setShowEditModal(true)}>
Edit
</Button>
<NewTaskButton schedule={schedule}/>
<RunScheduleButton schedule={schedule}/>
</Can>
}
</div>
</>
}

View File

@ -4,6 +4,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faCalendarAlt } from '@fortawesome/free-solid-svg-icons';
import { format } from 'date-fns';
import tw from 'twin.macro';
import ScheduleCronRow from '@/components/server/schedules/ScheduleCronRow';
export default ({ schedule }: { schedule: Schedule }) => (
<>
@ -27,36 +28,19 @@ export default ({ schedule }: { schedule: Schedule }) => (
{schedule.isActive ? 'Active' : 'Inactive'}
</p>
</div>
<div css={tw`flex items-center mx-auto sm:mx-8 w-full sm:w-auto mt-4 sm:mt-0`}>
<div css={tw`w-1/5 sm:w-auto text-center`}>
<p css={tw`font-medium`}>{schedule.cron.minute}</p>
<p css={tw`text-2xs text-neutral-500 uppercase`}>Minute</p>
</div>
<div css={tw`w-1/5 sm:w-auto text-center ml-4`}>
<p css={tw`font-medium`}>{schedule.cron.hour}</p>
<p css={tw`text-2xs text-neutral-500 uppercase`}>Hour</p>
</div>
<div css={tw`w-1/5 sm:w-auto text-center ml-4`}>
<p css={tw`font-medium`}>{schedule.cron.dayOfMonth}</p>
<p css={tw`text-2xs text-neutral-500 uppercase`}>Day (Month)</p>
</div>
<div css={tw`w-1/5 sm:w-auto text-center ml-4`}>
<p css={tw`font-medium`}>*</p>
<p css={tw`text-2xs text-neutral-500 uppercase`}>Month</p>
</div>
<div css={tw`w-1/5 sm:w-auto text-center ml-4`}>
<p css={tw`font-medium`}>{schedule.cron.dayOfWeek}</p>
<p css={tw`text-2xs text-neutral-500 uppercase`}>Day (Week)</p>
</div>
</div>
<ScheduleCronRow cron={schedule.cron} css={tw`mx-auto sm:mx-8 w-full sm:w-auto mt-4 sm:mt-0`}/>
<div>
<p
css={[
tw`py-1 px-3 rounded text-xs uppercase text-white hidden sm:block`,
schedule.isActive ? tw`bg-green-600` : tw`bg-neutral-400`,
schedule.isActive && !schedule.isProcessing ? tw`bg-green-600` : tw`bg-neutral-400`,
]}
>
{schedule.isActive ? 'Active' : 'Inactive'}
{schedule.isProcessing ?
'Processing'
:
schedule.isActive ? 'Active' : 'Inactive'
}
</p>
</div>
</>

View File

@ -1,7 +1,7 @@
import React, { useState } from 'react';
import { Schedule, Task } from '@/api/server/schedules/getServerSchedules';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faCode, faFileArchive, faPencilAlt, faToggleOn, faTrashAlt } from '@fortawesome/free-solid-svg-icons';
import { faClock, faCode, faFileArchive, faPencilAlt, faToggleOn, faTrashAlt } from '@fortawesome/free-solid-svg-icons';
import deleteScheduleTask from '@/api/server/schedules/deleteScheduleTask';
import { httpErrorToHuman } from '@/api/http';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
@ -11,6 +11,7 @@ import useFlash from '@/plugins/useFlash';
import { ServerContext } from '@/state/server';
import tw from 'twin.macro';
import ConfirmationModal from '@/components/elements/ConfirmationModal';
import Icon from '@/components/elements/Icon';
interface Props {
schedule: Schedule;
@ -56,7 +57,7 @@ export default ({ schedule, task }: Props) => {
const [ title, icon ] = getActionDetails(task.action);
return (
<div css={tw`flex flex-wrap items-center bg-neutral-700 border border-neutral-600 mb-2 px-6 py-4 rounded`}>
<div css={tw`sm:flex items-center p-3 sm:p-6 border-b border-neutral-800`}>
<SpinnerOverlay visible={isLoading} fixed size={'large'}/>
{isEditing && <TaskDetailsModal
schedule={schedule}
@ -73,8 +74,8 @@ export default ({ schedule, task }: Props) => {
Are you sure you want to delete this task? This action cannot be undone.
</ConfirmationModal>
<FontAwesomeIcon icon={icon} css={tw`text-lg text-white hidden md:block`}/>
<div css={tw`flex-none sm:flex-1 mb-4 sm:mb-0 w-full md:w-auto overflow-x-auto`}>
<p css={tw`md:ml-6 text-neutral-300 uppercase text-xs`}>
<div css={tw`flex-none sm:flex-1 w-full sm:w-auto overflow-x-auto`}>
<p css={tw`md:ml-6 text-neutral-200 uppercase text-sm`}>
{title}
</p>
{task.payload &&
@ -87,36 +88,36 @@ export default ({ schedule, task }: Props) => {
</div>
}
</div>
{task.sequenceId > 1 &&
<div css={tw`mr-6`}>
<p css={tw`text-center mb-1`}>
{task.timeOffset}s
</p>
<p css={tw`text-neutral-300 uppercase text-2xs`}>
Delay Run By
</p>
<div css={tw`mt-3 sm:mt-0 flex items-center w-full sm:w-auto`}>
{task.sequenceId > 1 && task.timeOffset > 0 &&
<div css={tw`mr-6`}>
<div css={tw`flex items-center px-2 py-1 bg-neutral-500 text-sm rounded-full`}>
<Icon icon={faClock} css={tw`w-3 h-3 mr-2`}/>
{task.timeOffset}s later
</div>
</div>
}
<Can action={'schedule.update'}>
<button
type={'button'}
aria-label={'Edit scheduled task'}
css={tw`block text-sm p-2 text-neutral-500 hover:text-neutral-100 transition-colors duration-150 mr-4 ml-auto sm:ml-0`}
onClick={() => setIsEditing(true)}
>
<FontAwesomeIcon icon={faPencilAlt}/>
</button>
</Can>
<Can action={'schedule.update'}>
<button
type={'button'}
aria-label={'Delete scheduled task'}
css={tw`block text-sm p-2 text-neutral-500 hover:text-red-600 transition-colors duration-150`}
onClick={() => setVisible(true)}
>
<FontAwesomeIcon icon={faTrashAlt}/>
</button>
</Can>
</div>
}
<Can action={'schedule.update'}>
<button
type={'button'}
aria-label={'Edit scheduled task'}
css={tw`block text-sm p-2 text-neutral-500 hover:text-neutral-100 transition-colors duration-150 mr-4 ml-auto sm:ml-0`}
onClick={() => setIsEditing(true)}
>
<FontAwesomeIcon icon={faPencilAlt}/>
</button>
</Can>
<Can action={'schedule.update'}>
<button
type={'button'}
aria-label={'Delete scheduled task'}
css={tw`block text-sm p-2 text-neutral-500 hover:text-red-600 transition-colors duration-150`}
onClick={() => setVisible(true)}
>
<FontAwesomeIcon icon={faTrashAlt}/>
</button>
</Can>
</div>
);
};

View File

@ -3,5 +3,6 @@
])
@section('container')
<div id="modal-portal"></div>
<div id="app"></div>
@endsection

View File

@ -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');

View File

@ -0,0 +1,94 @@
<?php
namespace Pterodactyl\Tests\Integration\Api\Client\Server\Schedule;
use Pterodactyl\Models\Task;
use Illuminate\Http\Response;
use Pterodactyl\Models\Schedule;
use Pterodactyl\Models\Permission;
use Illuminate\Support\Facades\Bus;
use Pterodactyl\Jobs\Schedule\RunTaskJob;
use Pterodactyl\Tests\Integration\Api\Client\ClientApiIntegrationTestCase;
class ExecuteScheduleTest extends ClientApiIntegrationTestCase
{
/**
* Test that a schedule can be executed and is updated in the database correctly.
*
* @param array $permissions
* @dataProvider permissionsDataProvider
*/
public function testScheduleIsExecutedRightAway(array $permissions)
{
[$user, $server] = $this->generateTestAccount($permissions);
Bus::fake();
/** @var \Pterodactyl\Models\Schedule $schedule */
$schedule = factory(Schedule::class)->create([
'server_id' => $server->id,
]);
$response = $this->actingAs($user)->postJson($this->link($schedule, '/execute'));
$response->assertStatus(Response::HTTP_BAD_REQUEST);
$response->assertJsonPath('errors.0.code', 'DisplayException');
$response->assertJsonPath('errors.0.detail', 'Cannot process schedule for task execution: no tasks are registered.');
/** @var \Pterodactyl\Models\Task $task */
$task = factory(Task::class)->create([
'schedule_id' => $schedule->id,
'sequence_id' => 1,
'time_offset' => 2,
]);
$this->actingAs($user)->postJson($this->link($schedule, '/execute'))->assertStatus(Response::HTTP_ACCEPTED);
Bus::assertDispatched(function (RunTaskJob $job) use ($task) {
$this->assertSame($task->time_offset, $job->delay);
$this->assertSame($task->id, $job->task->id);
return true;
});
}
/**
* Test that the schedule is not executed if it is not currently active.
*/
public function testScheduleIsNotExecutedIfNotActive()
{
[$user, $server] = $this->generateTestAccount();
/** @var \Pterodactyl\Models\Schedule $schedule */
$schedule = factory(Schedule::class)->create([
'server_id' => $server->id,
'is_active' => false,
]);
$response = $this->actingAs($user)->postJson($this->link($schedule, "/execute"));
$response->assertStatus(Response::HTTP_BAD_REQUEST);
$response->assertJsonPath('errors.0.code', 'BadRequestHttpException');
$response->assertJsonPath('errors.0.detail', 'Cannot trigger schedule exection for a schedule that is not currently active.');
}
/**
* Test that a user without the schedule update permission cannot execute it.
*/
public function testUserWithoutScheduleUpdatePermissionCannotExecute()
{
[$user, $server] = $this->generateTestAccount([Permission::ACTION_SCHEDULE_CREATE]);
/** @var \Pterodactyl\Models\Schedule $schedule */
$schedule = factory(Schedule::class)->create(['server_id' => $server->id]);
$this->actingAs($user)->postJson($this->link($schedule, '/execute'))->assertForbidden();
}
/**
* @return array
*/
public function permissionsDataProvider(): array
{
return [[[]], [[Permission::ACTION_SCHEDULE_UPDATE]]];
}
}

View File

@ -0,0 +1,99 @@
<?php
namespace Pterodactyl\Tests\Integration\Services\Schedules;
use Mockery;
use Pterodactyl\Models\Task;
use InvalidArgumentException;
use Pterodactyl\Models\Schedule;
use Illuminate\Support\Facades\Bus;
use Illuminate\Contracts\Bus\Dispatcher;
use Pterodactyl\Jobs\Schedule\RunTaskJob;
use Pterodactyl\Exceptions\DisplayException;
use Pterodactyl\Tests\Integration\IntegrationTestCase;
use Pterodactyl\Services\Schedules\ProcessScheduleService;
class ProcessScheduleServiceTest extends IntegrationTestCase
{
/**
* Test that a schedule with no tasks registered returns an error.
*/
public function testScheduleWithNoTasksReturnsException()
{
$server = $this->createServerModel();
$schedule = factory(Schedule::class)->create(['server_id' => $server->id]);
$this->expectException(DisplayException::class);
$this->expectExceptionMessage('Cannot process schedule for task execution: no tasks are registered.');
$this->getService()->handle($schedule);
}
/**
* Test that an error during the schedule update is not persisted to the database.
*/
public function testErrorDuringScheduleDataUpdateDoesNotPersistChanges()
{
$server = $this->createServerModel();
/** @var \Pterodactyl\Models\Schedule $schedule */
$schedule = factory(Schedule::class)->create([
'server_id' => $server->id,
'cron_minute' => 'hodor', // this will break the getNextRunDate() function.
]);
/** @var \Pterodactyl\Models\Task $task */
$task = factory(Task::class)->create(['schedule_id' => $schedule->id, 'sequence_id' => 1]);
$this->expectException(InvalidArgumentException::class);
$this->getService()->handle($schedule);
$this->assertDatabaseMissing('schedules', ['id' => $schedule->id, 'is_processing' => true]);
$this->assertDatabaseMissing('tasks', ['id' => $task->id, 'is_queued' => true]);
}
/**
* Test that a job is dispatched as expected using the initial delay.
*
* @param bool $now
* @dataProvider dispatchNowDataProvider
*/
public function testJobCanBeDispatchedWithExpectedInitialDelay($now)
{
$this->swap(Dispatcher::class, $dispatcher = Mockery::mock(Dispatcher::class));
$server = $this->createServerModel();
/** @var \Pterodactyl\Models\Schedule $schedule */
$schedule = factory(Schedule::class)->create(['server_id' => $server->id]);
/** @var \Pterodactyl\Models\Task $task */
$task = factory(Task::class)->create(['schedule_id' => $schedule->id, 'time_offset' => 10, 'sequence_id' => 1]);
$dispatcher->expects($now ? 'dispatchNow' : 'dispatch')->with(Mockery::on(function (RunTaskJob $job) use ($task) {
return $task->id === $job->task->id && $job->delay === 10;
}));
$this->getService()->handle($schedule, $now);
$this->assertDatabaseHas('schedules', ['id' => $schedule->id, 'is_processing' => true]);
$this->assertDatabaseHas('tasks', ['id' => $task->id, 'is_queued' => true]);
}
/**
* @return array
*/
public function dispatchNowDataProvider(): array
{
return [[true], [false]];
}
/**
* @return \Pterodactyl\Services\Schedules\ProcessScheduleService
*/
private function getService()
{
return $this->app->make(ProcessScheduleService::class);
}
}

View File

@ -1,85 +0,0 @@
<?php
namespace Tests\Unit\Services\Schedules;
use Mockery as m;
use Tests\TestCase;
use Cron\CronExpression;
use Pterodactyl\Models\Task;
use Pterodactyl\Models\Schedule;
use Illuminate\Contracts\Bus\Dispatcher;
use Pterodactyl\Jobs\Schedule\RunTaskJob;
use Pterodactyl\Services\Schedules\ProcessScheduleService;
use Pterodactyl\Contracts\Repository\TaskRepositoryInterface;
use Pterodactyl\Contracts\Repository\ScheduleRepositoryInterface;
class ProcessScheduleServiceTest extends TestCase
{
/**
* @var \Illuminate\Contracts\Bus\Dispatcher|\Mockery\Mock
*/
private $dispatcher;
/**
* @var \Pterodactyl\Contracts\Repository\ScheduleRepositoryInterface|\Mockery\Mock
*/
private $scheduleRepository;
/**
* @var \Pterodactyl\Contracts\Repository\TaskRepositoryInterface|\Mockery\Mock
*/
private $taskRepository;
/**
* Setup tests.
*/
public function setUp(): void
{
parent::setUp();
$this->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);
}
}

View File

@ -888,30 +888,31 @@
resolved "https://registry.yarnpkg.com/@emotion/unitless/-/unitless-0.7.5.tgz#77211291c1900a700b8a78cfafda3160d76949ed"
integrity sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==
"@fortawesome/fontawesome-common-types@^0.2.19":
version "0.2.19"
resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-0.2.19.tgz#754a0f85e1290858152e1c05700ab502b11197f1"
"@fortawesome/fontawesome-common-types@^0.2.32":
version "0.2.32"
resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-0.2.32.tgz#3436795d5684f22742989bfa08f46f50f516f259"
integrity sha512-ux2EDjKMpcdHBVLi/eWZynnPxs0BtFVXJkgHIxXRl+9ZFaHPvYamAfCzeeQFqHRjuJtX90wVnMRaMQAAlctz3w==
"@fortawesome/fontawesome-svg-core@1.2.19":
version "1.2.19"
resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-1.2.19.tgz#0eca1ce9285c3d99e6e340633ee8f615f9d1a2e0"
integrity sha512-D4ICXg9oU08eF9o7Or392gPpjmwwgJu8ecCFusthbID95CLVXOgIyd4mOKD9Nud5Ckz+Ty59pqkNtThDKR0erA==
"@fortawesome/fontawesome-svg-core@^1.2.32":
version "1.2.32"
resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-1.2.32.tgz#da092bfc7266aa274be8604de610d7115f9ba6cf"
integrity sha512-XjqyeLCsR/c/usUpdWcOdVtWFVjPbDFBTQkn2fQRrWhhUoxriQohO2RWDxLyUM8XpD+Zzg5xwJ8gqTYGDLeGaQ==
dependencies:
"@fortawesome/fontawesome-common-types" "^0.2.19"
"@fortawesome/fontawesome-common-types" "^0.2.32"
"@fortawesome/free-solid-svg-icons@^5.9.0":
version "5.9.0"
resolved "https://registry.yarnpkg.com/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-5.9.0.tgz#1c73e7bac17417d23f934d83f7fff5b100a7fda9"
"@fortawesome/free-solid-svg-icons@^5.15.1":
version "5.15.1"
resolved "https://registry.yarnpkg.com/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-5.15.1.tgz#e1432676ddd43108b41197fee9f86d910ad458ef"
integrity sha512-EFMuKtzRMNbvjab/SvJBaOOpaqJfdSap/Nl6hst7CgrJxwfORR1drdTV6q1Ib/JVzq4xObdTDcT6sqTaXMqfdg==
dependencies:
"@fortawesome/fontawesome-common-types" "^0.2.19"
"@fortawesome/fontawesome-common-types" "^0.2.32"
"@fortawesome/react-fontawesome@0.1.4":
version "0.1.4"
resolved "https://registry.yarnpkg.com/@fortawesome/react-fontawesome/-/react-fontawesome-0.1.4.tgz#18d61d9b583ca289a61aa7dccc05bd164d6bc9ad"
integrity sha512-GwmxQ+TK7PEdfSwvxtGnMCqrfEm0/HbRHArbUudsYiy9KzVCwndxa2KMcfyTQ8El0vROrq8gOOff09RF1oQe8g==
"@fortawesome/react-fontawesome@^0.1.11":
version "0.1.11"
resolved "https://registry.yarnpkg.com/@fortawesome/react-fontawesome/-/react-fontawesome-0.1.11.tgz#c1a95a2bdb6a18fa97b355a563832e248bf6ef4a"
integrity sha512-sClfojasRifQKI0OPqTy8Ln8iIhnxR/Pv/hukBhWnBz9kQRmqi6JSH3nghlhAY7SUeIIM7B5/D2G8WjX0iepVg==
dependencies:
humps "^2.0.1"
prop-types "^15.5.10"
prop-types "^15.7.2"
"@fullhuman/postcss-purgecss@^2.1.2":
version "2.3.0"
@ -3780,11 +3781,6 @@ https-browserify@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/https-browserify/-/https-browserify-1.0.0.tgz#ec06c10e0a34c0f2faf199f7fd7fc78fffd03c73"
humps@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/humps/-/humps-2.0.1.tgz#dd02ea6081bd0568dc5d073184463957ba9ef9aa"
integrity sha1-3QLqYIG9BWjcXQcxhEY5V7qe+ao=
i18next-chained-backend@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/i18next-chained-backend/-/i18next-chained-backend-2.0.0.tgz#faf2e8b5f081a01e74fbec1fe580c184bc64e25b"
@ -5429,7 +5425,7 @@ promise-inflight@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/promise-inflight/-/promise-inflight-1.0.1.tgz#98472870bf228132fcbdd868129bad12c3c029e3"
prop-types@^15.5.0, prop-types@^15.5.10, prop-types@^15.6.1, prop-types@^15.6.2, prop-types@^15.7.2:
prop-types@^15.5.0, prop-types@^15.6.1, prop-types@^15.6.2, prop-types@^15.7.2:
version "15.7.2"
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5"
integrity sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==