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; 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. * 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 Pterodactyl\Models\Schedule;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Pterodactyl\Helpers\Utilities; use Pterodactyl\Helpers\Utilities;
use Pterodactyl\Jobs\Schedule\RunTaskJob;
use Pterodactyl\Exceptions\DisplayException; use Pterodactyl\Exceptions\DisplayException;
use Pterodactyl\Repositories\Eloquent\ScheduleRepository; use Pterodactyl\Repositories\Eloquent\ScheduleRepository;
use Pterodactyl\Services\Schedules\ProcessScheduleService;
use Pterodactyl\Transformers\Api\Client\ScheduleTransformer; use Pterodactyl\Transformers\Api\Client\ScheduleTransformer;
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 Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Pterodactyl\Http\Requests\Api\Client\Servers\Schedules\ViewScheduleRequest; 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\StoreScheduleRequest;
use Pterodactyl\Http\Requests\Api\Client\Servers\Schedules\DeleteScheduleRequest; 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\UpdateScheduleRequest;
use Pterodactyl\Http\Requests\Api\Client\Servers\Schedules\TriggerScheduleRequest;
class ScheduleController extends ClientApiController class ScheduleController extends ClientApiController
{ {
@ -27,16 +31,23 @@ class ScheduleController extends ClientApiController
*/ */
private $repository; private $repository;
/**
* @var \Pterodactyl\Services\Schedules\ProcessScheduleService
*/
private $service;
/** /**
* ScheduleController constructor. * ScheduleController constructor.
* *
* @param \Pterodactyl\Repositories\Eloquent\ScheduleRepository $repository * @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(); parent::__construct();
$this->repository = $repository; $this->repository = $repository;
$this->service = $service;
} }
/** /**
@ -147,6 +158,30 @@ class ScheduleController extends ClientApiController
->toArray(); ->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. * 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\Repositories\Wings\DaemonCommandRepository $commandRepository
* @param \Pterodactyl\Services\Backups\InitiateBackupService $backupService * @param \Pterodactyl\Services\Backups\InitiateBackupService $backupService
* @param \Pterodactyl\Repositories\Wings\DaemonPowerRepository $powerRepository * @param \Pterodactyl\Repositories\Wings\DaemonPowerRepository $powerRepository
* @param \Pterodactyl\Repositories\Eloquent\TaskRepository $taskRepository
* *
* @throws \Throwable * @throws \Throwable
*/ */
public function handle( public function handle(
DaemonCommandRepository $commandRepository, DaemonCommandRepository $commandRepository,
InitiateBackupService $backupService, InitiateBackupService $backupService,
DaemonPowerRepository $powerRepository, DaemonPowerRepository $powerRepository
TaskRepository $taskRepository
) { ) {
// Do not process a task that is not set to active. // Do not process a task that is not set to active.
if (! $this->task->schedule->is_active) { if (! $this->task->schedule->is_active) {

View file

@ -2,6 +2,8 @@
namespace Pterodactyl\Models; namespace Pterodactyl\Models;
use Cron\CronExpression;
use Carbon\CarbonImmutable;
use Illuminate\Container\Container; use Illuminate\Container\Container;
use Pterodactyl\Contracts\Extensions\HashidsInterface; use Pterodactyl\Contracts\Extensions\HashidsInterface;
@ -114,6 +116,20 @@ class Schedule extends Model
'next_run_at' => 'nullable|date', '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. * 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()); 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. * Return a schedule model with all of the associated tasks as a relationship.
* *

View file

@ -2,12 +2,11 @@
namespace Pterodactyl\Services\Schedules; namespace Pterodactyl\Services\Schedules;
use Cron\CronExpression;
use Pterodactyl\Models\Schedule; use Pterodactyl\Models\Schedule;
use Illuminate\Contracts\Bus\Dispatcher; use Illuminate\Contracts\Bus\Dispatcher;
use Pterodactyl\Jobs\Schedule\RunTaskJob; use Pterodactyl\Jobs\Schedule\RunTaskJob;
use Pterodactyl\Contracts\Repository\TaskRepositoryInterface; use Illuminate\Database\ConnectionInterface;
use Pterodactyl\Contracts\Repository\ScheduleRepositoryInterface; use Pterodactyl\Exceptions\DisplayException;
class ProcessScheduleService class ProcessScheduleService
{ {
@ -17,62 +16,51 @@ class ProcessScheduleService
private $dispatcher; private $dispatcher;
/** /**
* @var \Pterodactyl\Contracts\Repository\ScheduleRepositoryInterface * @var \Illuminate\Database\ConnectionInterface
*/ */
private $scheduleRepository; private $connection;
/**
* @var \Pterodactyl\Contracts\Repository\TaskRepositoryInterface
*/
private $taskRepository;
/** /**
* ProcessScheduleService constructor. * ProcessScheduleService constructor.
* *
* @param \Illuminate\Database\ConnectionInterface $connection
* @param \Illuminate\Contracts\Bus\Dispatcher $dispatcher * @param \Illuminate\Contracts\Bus\Dispatcher $dispatcher
* @param \Pterodactyl\Contracts\Repository\ScheduleRepositoryInterface $scheduleRepository
* @param \Pterodactyl\Contracts\Repository\TaskRepositoryInterface $taskRepository
*/ */
public function __construct( public function __construct(ConnectionInterface $connection, Dispatcher $dispatcher)
Dispatcher $dispatcher, {
ScheduleRepositoryInterface $scheduleRepository,
TaskRepositoryInterface $taskRepository
) {
$this->dispatcher = $dispatcher; $this->dispatcher = $dispatcher;
$this->scheduleRepository = $scheduleRepository; $this->connection = $connection;
$this->taskRepository = $taskRepository;
} }
/** /**
* Process a schedule and push the first task onto the queue worker. * Process a schedule and push the first task onto the queue worker.
* *
* @param \Pterodactyl\Models\Schedule $schedule * @param \Pterodactyl\Models\Schedule $schedule
* @param bool $now
* *
* @throws \Pterodactyl\Exceptions\Model\DataValidationException * @throws \Throwable
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
*/ */
public function handle(Schedule $schedule) public function handle(Schedule $schedule, bool $now = false)
{ {
$this->scheduleRepository->loadTasks($schedule);
/** @var \Pterodactyl\Models\Task $task */ /** @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', if (is_null($task)) {
$schedule->cron_minute, throw new DisplayException(
$schedule->cron_hour, 'Cannot process schedule for task execution: no tasks are registered.'
$schedule->cron_day_of_month, );
$schedule->cron_day_of_week }
);
$this->scheduleRepository->update($schedule->id, [ $this->connection->transaction(function () use ($schedule, $task) {
'is_processing' => true, $schedule->forceFill([
'next_run_at' => CronExpression::factory($formattedCron)->getNextRunDate(), '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) (new RunTaskJob($task))->delay($task->time_offset)
); );
} }

View file

@ -1,9 +1,9 @@
{ {
"name": "pterodactyl-panel", "name": "pterodactyl-panel",
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-svg-core": "1.2.19", "@fortawesome/fontawesome-svg-core": "^1.2.32",
"@fortawesome/free-solid-svg-icons": "^5.9.0", "@fortawesome/free-solid-svg-icons": "^5.15.1",
"@fortawesome/react-fontawesome": "0.1.4", "@fortawesome/react-fontawesome": "^0.1.11",
"axios": "^0.19.2", "axios": "^0.19.2",
"chart.js": "^2.8.0", "chart.js": "^2.8.0",
"codemirror": "^5.57.0", "codemirror": "^5.57.0",
@ -23,9 +23,9 @@
"react": "^16.13.1", "react": "^16.13.1",
"react-dom": "npm:@hot-loader/react-dom", "react-dom": "npm:@hot-loader/react-dom",
"react-fast-compare": "^3.2.0", "react-fast-compare": "^3.2.0",
"react-ga": "^3.1.2",
"react-google-recaptcha": "^2.0.1", "react-google-recaptcha": "^2.0.1",
"react-helmet": "^6.1.0", "react-helmet": "^6.1.0",
"react-ga": "^3.1.2",
"react-hot-loader": "^4.12.21", "react-hot-loader": "^4.12.21",
"react-i18next": "^11.2.1", "react-i18next": "^11.2.1",
"react-redux": "^7.1.0", "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 === 'xsmall' && tw`px-2 py-1 text-xs`};
${props => (!props.size || props.size === 'small') && tw`p-3`}; ${props => (!props.size || props.size === 'small') && tw`px-4 py-2`};
${props => props.size === 'large' && tw`p-4 text-sm`}; ${props => props.size === 'large' && tw`p-4 text-sm`};
${props => props.size === 'xlarge' && tw`p-4 w-full`}; ${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 Spinner from '@/components/elements/Spinner';
import tw from 'twin.macro'; import tw from 'twin.macro';
import styled, { css } from 'styled-components/macro'; import styled, { css } from 'styled-components/macro';
import { breakpoint } from '@/theme'; import { breakpoint } from '@/theme';
import Fade from '@/components/elements/Fade'; import Fade from '@/components/elements/Fade';
import { createPortal } from 'react-dom';
export interface RequiredModalProps { export interface RequiredModalProps {
visible: boolean; 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 Are you sure you want to delete this schedule? All tasks will be removed and any running processes
will be terminated. will be terminated.
</ConfirmationModal> </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 Delete
</Button> </Button>
</> </>

View file

@ -2,6 +2,7 @@ import React, { useState } from 'react';
import { Schedule } from '@/api/server/schedules/getServerSchedules'; import { Schedule } from '@/api/server/schedules/getServerSchedules';
import TaskDetailsModal from '@/components/server/schedules/TaskDetailsModal'; import TaskDetailsModal from '@/components/server/schedules/TaskDetailsModal';
import Button from '@/components/elements/Button'; import Button from '@/components/elements/Button';
import tw from 'twin.macro';
interface Props { interface Props {
schedule: Schedule; schedule: Schedule;
@ -18,7 +19,7 @@ export default ({ schedule }: Props) => {
onDismissed={() => setVisible(false)} onDismissed={() => setVisible(false)}
/> />
} }
<Button onClick={() => setVisible(true)}> <Button onClick={() => setVisible(true)} css={tw`flex-1`}>
New Task New Task
</Button> </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 { RouteComponentProps } from 'react-router-dom';
import { Schedule } from '@/api/server/schedules/getServerSchedules'; import { Schedule } from '@/api/server/schedules/getServerSchedules';
import getServerSchedule from '@/api/server/schedules/getServerSchedule'; import getServerSchedule from '@/api/server/schedules/getServerSchedule';
import Spinner from '@/components/elements/Spinner'; import Spinner from '@/components/elements/Spinner';
import FlashMessageRender from '@/components/FlashMessageRender'; 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 EditScheduleModal from '@/components/server/schedules/EditScheduleModal';
import NewTaskButton from '@/components/server/schedules/NewTaskButton'; import NewTaskButton from '@/components/server/schedules/NewTaskButton';
import DeleteScheduleButton from '@/components/server/schedules/DeleteScheduleButton'; import DeleteScheduleButton from '@/components/server/schedules/DeleteScheduleButton';
@ -16,7 +13,11 @@ import { ServerContext } from '@/state/server';
import PageContentBlock from '@/components/elements/PageContentBlock'; import PageContentBlock from '@/components/elements/PageContentBlock';
import tw from 'twin.macro'; import tw from 'twin.macro';
import Button from '@/components/elements/Button'; 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 { interface Params {
id: string; id: string;
@ -26,15 +27,34 @@ interface State {
schedule?: Schedule; 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>) => { export default ({ match, history, location: { state } }: RouteComponentProps<Params, Record<string, unknown>, State>) => {
const id = ServerContext.useStoreState(state => state.server.data!.id); const id = ServerContext.useStoreState(state => state.server.data!.id);
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid); const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
const { clearFlashes, addError } = useFlash(); const { clearFlashes, clearAndAddHttpError } = useFlash();
const [ isLoading, setIsLoading ] = useState(true); const [ isLoading, setIsLoading ] = useState(true);
const [ showEditModal, setShowEditModal ] = useState(false); 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); const appendSchedule = ServerContext.useStoreActions(actions => actions.schedules.appendSchedule);
useEffect(() => { useEffect(() => {
@ -48,11 +68,15 @@ export default ({ match, history, location: { state } }: RouteComponentProps<Par
.then(schedule => appendSchedule(schedule)) .then(schedule => appendSchedule(schedule))
.catch(error => { .catch(error => {
console.error(error); console.error(error);
addError({ message: httpErrorToHuman(error), key: 'schedules' }); clearAndAddHttpError({ error, key: 'schedules' });
}) })
.then(() => setIsLoading(false)); .then(() => setIsLoading(false));
}, [ match ]); }, [ match ]);
const toggleEditModal = useCallback(() => {
setShowEditModal(s => !s);
}, []);
return ( return (
<PageContentBlock> <PageContentBlock>
<FlashMessageRender byKey={'schedules'} css={tw`mb-4`}/> <FlashMessageRender byKey={'schedules'} css={tw`mb-4`}/>
@ -60,52 +84,73 @@ export default ({ match, history, location: { state } }: RouteComponentProps<Par
<Spinner size={'large'} centered/> <Spinner size={'large'} centered/>
: :
<> <>
<GreyRowBox css={tw`cursor-pointer mb-2 flex-wrap`}> <ScheduleCronRow cron={schedule.cron} css={tw`sm:hidden bg-neutral-700 rounded mb-4 p-3`}/>
<ScheduleRow schedule={schedule}/> <div css={tw`hidden sm:grid grid-cols-5 md:grid-cols-7 gap-4 mb-6`}>
</GreyRowBox> <CronBox title={'Minute'} value={schedule.cron.minute}/>
<EditScheduleModal <CronBox title={'Hour'} value={schedule.cron.hour}/>
visible={showEditModal} <CronBox title={'Day (Month)'} value={schedule.cron.dayOfMonth}/>
schedule={schedule} <CronBox title={'Month'} value={'*'}/>
onDismissed={() => setShowEditModal(false)} <CronBox title={'Day (Week)'} value={schedule.cron.dayOfWeek}/>
/> </div>
<div css={tw`flex items-center mt-8 mb-4`}> <div css={tw`rounded shadow`}>
<div css={tw`flex-1`}> <div css={tw`sm:flex items-center bg-neutral-900 p-3 sm:p-6 border-b-4 border-neutral-600 rounded-t`}>
<h2 css={tw`text-2xl`}>Configured Tasks</h2> <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>
</div> </div>
{schedule.tasks.length > 0 ? <EditScheduleModal visible={showEditModal} schedule={schedule} onDismissed={toggleEditModal}/>
<> <div css={tw`mt-6 flex sm:justify-end`}>
{
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`}>
<Can action={'schedule.delete'}> <Can action={'schedule.delete'}>
<DeleteScheduleButton <DeleteScheduleButton
scheduleId={schedule.id} scheduleId={schedule.id}
onDeleted={() => history.push(`/server/${id}/schedules`)} onDeleted={() => history.push(`/server/${id}/schedules`)}
/> />
</Can> </Can>
{schedule.isActive && schedule.tasks.length > 0 &&
<Can action={'schedule.update'}> <Can action={'schedule.update'}>
<Button css={tw`mr-4`} onClick={() => setShowEditModal(true)}> <RunScheduleButton schedule={schedule}/>
Edit
</Button>
<NewTaskButton schedule={schedule}/>
</Can> </Can>
}
</div> </div>
</> </>
} }

View file

@ -4,6 +4,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faCalendarAlt } from '@fortawesome/free-solid-svg-icons'; import { faCalendarAlt } from '@fortawesome/free-solid-svg-icons';
import { format } from 'date-fns'; import { format } from 'date-fns';
import tw from 'twin.macro'; import tw from 'twin.macro';
import ScheduleCronRow from '@/components/server/schedules/ScheduleCronRow';
export default ({ schedule }: { schedule: Schedule }) => ( export default ({ schedule }: { schedule: Schedule }) => (
<> <>
@ -27,36 +28,19 @@ export default ({ schedule }: { schedule: Schedule }) => (
{schedule.isActive ? 'Active' : 'Inactive'} {schedule.isActive ? 'Active' : 'Inactive'}
</p> </p>
</div> </div>
<div css={tw`flex items-center mx-auto sm:mx-8 w-full sm:w-auto mt-4 sm:mt-0`}> <ScheduleCronRow cron={schedule.cron} css={tw`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>
<div> <div>
<p <p
css={[ css={[
tw`py-1 px-3 rounded text-xs uppercase text-white hidden sm:block`, 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> </p>
</div> </div>
</> </>

View file

@ -1,7 +1,7 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Schedule, Task } from '@/api/server/schedules/getServerSchedules'; import { Schedule, Task } from '@/api/server/schedules/getServerSchedules';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 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 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';
@ -11,6 +11,7 @@ import useFlash from '@/plugins/useFlash';
import { ServerContext } from '@/state/server'; import { ServerContext } from '@/state/server';
import tw from 'twin.macro'; import tw from 'twin.macro';
import ConfirmationModal from '@/components/elements/ConfirmationModal'; import ConfirmationModal from '@/components/elements/ConfirmationModal';
import Icon from '@/components/elements/Icon';
interface Props { interface Props {
schedule: Schedule; schedule: Schedule;
@ -56,7 +57,7 @@ export default ({ schedule, task }: Props) => {
const [ title, icon ] = getActionDetails(task.action); const [ title, icon ] = getActionDetails(task.action);
return ( 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'}/> <SpinnerOverlay visible={isLoading} fixed size={'large'}/>
{isEditing && <TaskDetailsModal {isEditing && <TaskDetailsModal
schedule={schedule} 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. Are you sure you want to delete this task? This action cannot be undone.
</ConfirmationModal> </ConfirmationModal>
<FontAwesomeIcon icon={icon} css={tw`text-lg text-white hidden md:block`}/> <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`}> <div css={tw`flex-none sm:flex-1 w-full sm:w-auto overflow-x-auto`}>
<p css={tw`md:ml-6 text-neutral-300 uppercase text-xs`}> <p css={tw`md:ml-6 text-neutral-200 uppercase text-sm`}>
{title} {title}
</p> </p>
{task.payload && {task.payload &&
@ -87,36 +88,36 @@ export default ({ schedule, task }: Props) => {
</div> </div>
} }
</div> </div>
{task.sequenceId > 1 && <div css={tw`mt-3 sm:mt-0 flex items-center w-full sm:w-auto`}>
<div css={tw`mr-6`}> {task.sequenceId > 1 && task.timeOffset > 0 &&
<p css={tw`text-center mb-1`}> <div css={tw`mr-6`}>
{task.timeOffset}s <div css={tw`flex items-center px-2 py-1 bg-neutral-500 text-sm rounded-full`}>
</p> <Icon icon={faClock} css={tw`w-3 h-3 mr-2`}/>
<p css={tw`text-neutral-300 uppercase text-2xs`}> {task.timeOffset}s later
Delay Run By </div>
</p> </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> </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> </div>
); );
}; };

View file

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

View file

@ -72,6 +72,7 @@ Route::group(['prefix' => '/servers/{server}', 'middleware' => [AuthenticateServ
Route::post('/', 'Servers\ScheduleController@store'); Route::post('/', 'Servers\ScheduleController@store');
Route::get('/{schedule}', 'Servers\ScheduleController@view'); Route::get('/{schedule}', 'Servers\ScheduleController@view');
Route::post('/{schedule}', 'Servers\ScheduleController@update'); Route::post('/{schedule}', 'Servers\ScheduleController@update');
Route::post('/{schedule}/execute', 'Servers\ScheduleController@execute');
Route::delete('/{schedule}', 'Servers\ScheduleController@delete'); Route::delete('/{schedule}', 'Servers\ScheduleController@delete');
Route::post('/{schedule}/tasks', 'Servers\ScheduleTaskController@store'); 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" resolved "https://registry.yarnpkg.com/@emotion/unitless/-/unitless-0.7.5.tgz#77211291c1900a700b8a78cfafda3160d76949ed"
integrity sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg== integrity sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==
"@fortawesome/fontawesome-common-types@^0.2.19": "@fortawesome/fontawesome-common-types@^0.2.32":
version "0.2.19" version "0.2.32"
resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-0.2.19.tgz#754a0f85e1290858152e1c05700ab502b11197f1" 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": "@fortawesome/fontawesome-svg-core@^1.2.32":
version "1.2.19" version "1.2.32"
resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-1.2.19.tgz#0eca1ce9285c3d99e6e340633ee8f615f9d1a2e0" resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-1.2.32.tgz#da092bfc7266aa274be8604de610d7115f9ba6cf"
integrity sha512-D4ICXg9oU08eF9o7Or392gPpjmwwgJu8ecCFusthbID95CLVXOgIyd4mOKD9Nud5Ckz+Ty59pqkNtThDKR0erA== integrity sha512-XjqyeLCsR/c/usUpdWcOdVtWFVjPbDFBTQkn2fQRrWhhUoxriQohO2RWDxLyUM8XpD+Zzg5xwJ8gqTYGDLeGaQ==
dependencies: dependencies:
"@fortawesome/fontawesome-common-types" "^0.2.19" "@fortawesome/fontawesome-common-types" "^0.2.32"
"@fortawesome/free-solid-svg-icons@^5.9.0": "@fortawesome/free-solid-svg-icons@^5.15.1":
version "5.9.0" version "5.15.1"
resolved "https://registry.yarnpkg.com/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-5.9.0.tgz#1c73e7bac17417d23f934d83f7fff5b100a7fda9" 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: dependencies:
"@fortawesome/fontawesome-common-types" "^0.2.19" "@fortawesome/fontawesome-common-types" "^0.2.32"
"@fortawesome/react-fontawesome@0.1.4": "@fortawesome/react-fontawesome@^0.1.11":
version "0.1.4" version "0.1.11"
resolved "https://registry.yarnpkg.com/@fortawesome/react-fontawesome/-/react-fontawesome-0.1.4.tgz#18d61d9b583ca289a61aa7dccc05bd164d6bc9ad" resolved "https://registry.yarnpkg.com/@fortawesome/react-fontawesome/-/react-fontawesome-0.1.11.tgz#c1a95a2bdb6a18fa97b355a563832e248bf6ef4a"
integrity sha512-GwmxQ+TK7PEdfSwvxtGnMCqrfEm0/HbRHArbUudsYiy9KzVCwndxa2KMcfyTQ8El0vROrq8gOOff09RF1oQe8g== integrity sha512-sClfojasRifQKI0OPqTy8Ln8iIhnxR/Pv/hukBhWnBz9kQRmqi6JSH3nghlhAY7SUeIIM7B5/D2G8WjX0iepVg==
dependencies: dependencies:
humps "^2.0.1" prop-types "^15.7.2"
prop-types "^15.5.10"
"@fullhuman/postcss-purgecss@^2.1.2": "@fullhuman/postcss-purgecss@^2.1.2":
version "2.3.0" version "2.3.0"
@ -3780,11 +3781,6 @@ https-browserify@^1.0.0:
version "1.0.0" version "1.0.0"
resolved "https://registry.yarnpkg.com/https-browserify/-/https-browserify-1.0.0.tgz#ec06c10e0a34c0f2faf199f7fd7fc78fffd03c73" 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: i18next-chained-backend@^2.0.0:
version "2.0.0" version "2.0.0"
resolved "https://registry.yarnpkg.com/i18next-chained-backend/-/i18next-chained-backend-2.0.0.tgz#faf2e8b5f081a01e74fbec1fe580c184bc64e25b" 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" version "1.0.1"
resolved "https://registry.yarnpkg.com/promise-inflight/-/promise-inflight-1.0.1.tgz#98472870bf228132fcbdd868129bad12c3c029e3" 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" version "15.7.2"
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5"
integrity sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ== integrity sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==