Merge branch 'develop' into feature/react-admin

This commit is contained in:
Matthew Penner 2021-05-18 17:07:25 -06:00
commit a3b59f24af
95 changed files with 2671 additions and 1777 deletions

View file

@ -1,6 +1,5 @@
import React, { lazy, memo } from 'react';
import { ServerContext } from '@/state/server';
import SuspenseSpinner from '@/components/elements/SuspenseSpinner';
import Can from '@/components/elements/Can';
import ContentContainer from '@/components/elements/ContentContainer';
import tw from 'twin.macro';
@ -10,6 +9,7 @@ import isEqual from 'react-fast-compare';
import PowerControls from '@/components/server/PowerControls';
import { EulaModalFeature } from '@feature/index';
import ErrorBoundary from '@/components/elements/ErrorBoundary';
import Spinner from '@/components/elements/Spinner';
export type PowerAction = 'start' | 'stop' | 'restart' | 'kill';
@ -51,12 +51,12 @@ const ServerConsole = () => {
}
</div>
<div css={tw`w-full lg:w-3/4 mt-4 lg:mt-0 lg:pl-4`}>
<SuspenseSpinner>
<Spinner.Suspense>
<ErrorBoundary>
<ChunkedConsole/>
</ErrorBoundary>
<ChunkedStatGraphs/>
</SuspenseSpinner>
</Spinner.Suspense>
{eggFeatures.includes('eula') &&
<React.Suspense fallback={null}>
<EulaModalFeature/>

View file

@ -60,7 +60,7 @@ const BackupContainer = () => {
</Pagination>
{backupLimit === 0 &&
<p css={tw`text-center text-sm text-neutral-300`}>
Backups cannot be created for this server.
Backups cannot be created for this server because the backup limit is set to 0.
</p>
}
<Can action={'backup.create'}>

View file

@ -1,10 +1,16 @@
import React, { useState } from 'react';
import { faBoxOpen, faCloudDownloadAlt, faEllipsisH, faLock, faTrashAlt } from '@fortawesome/free-solid-svg-icons';
import {
faBoxOpen,
faCloudDownloadAlt,
faEllipsisH,
faLock,
faTrashAlt,
faUnlock,
} from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import DropdownMenu, { DropdownButtonRow } from '@/components/elements/DropdownMenu';
import getBackupDownloadUrl from '@/api/server/backups/getBackupDownloadUrl';
import useFlash from '@/plugins/useFlash';
import ChecksumModal from '@/components/server/backups/ChecksumModal';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import deleteBackup from '@/api/server/backups/deleteBackup';
import ConfirmationModal from '@/components/elements/ConfirmationModal';
@ -15,6 +21,7 @@ import { ServerBackup } from '@/api/server/types';
import { ServerContext } from '@/state/server';
import Input from '@/components/elements/Input';
import { restoreServerBackup } from '@/api/server/backups';
import http, { httpErrorToHuman } from '@/api/http';
interface Props {
backup: ServerBackup;
@ -76,14 +83,35 @@ export default ({ backup }: Props) => {
.then(() => setModal(''));
};
const onLockToggle = () => {
if (backup.isLocked && modal !== 'unlock') {
return setModal('unlock');
}
http.post(`/api/client/servers/${uuid}/backups/${backup.uuid}/lock`)
.then(() => mutate(data => ({
...data,
items: data.items.map(b => b.uuid !== backup.uuid ? b : {
...b,
isLocked: !b.isLocked,
}),
}), false))
.catch(error => alert(httpErrorToHuman(error)))
.then(() => setModal(''));
};
return (
<>
<ChecksumModal
appear
visible={modal === 'checksum'}
onDismissed={() => setModal('')}
checksum={backup.checksum}
/>
<ConfirmationModal
visible={modal === 'unlock'}
title={'Unlock this backup?'}
onConfirmed={onLockToggle}
onModalDismissed={() => setModal('')}
buttonText={'Yes, unlock'}
>
Are you sure you want to unlock this backup? It will no longer be protected from automated or
accidental deletions.
</ConfirmationModal>
<ConfirmationModal
visible={modal === 'restore'}
title={'Restore this backup?'}
@ -151,15 +179,23 @@ export default ({ backup }: Props) => {
<span css={tw`ml-2`}>Restore</span>
</DropdownButtonRow>
</Can>
<DropdownButtonRow onClick={() => setModal('checksum')}>
<FontAwesomeIcon fixedWidth icon={faLock} css={tw`text-xs`}/>
<span css={tw`ml-2`}>Checksum</span>
</DropdownButtonRow>
<Can action={'backup.delete'}>
<DropdownButtonRow danger onClick={() => setModal('delete')}>
<FontAwesomeIcon fixedWidth icon={faTrashAlt} css={tw`text-xs`}/>
<span css={tw`ml-2`}>Delete</span>
</DropdownButtonRow>
<>
<DropdownButtonRow onClick={onLockToggle}>
<FontAwesomeIcon
fixedWidth
icon={backup.isLocked ? faUnlock : faLock}
css={tw`text-xs mr-2`}
/>
{backup.isLocked ? 'Unlock' : 'Lock'}
</DropdownButtonRow>
{!backup.isLocked &&
<DropdownButtonRow danger onClick={() => setModal('delete')}>
<FontAwesomeIcon fixedWidth icon={faTrashAlt} css={tw`text-xs`}/>
<span css={tw`ml-2`}>Delete</span>
</DropdownButtonRow>
}
</>
</Can>
</div>
</DropdownMenu>

View file

@ -1,6 +1,6 @@
import React from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faArchive, faEllipsisH } from '@fortawesome/free-solid-svg-icons';
import { faArchive, faEllipsisH, faLock } from '@fortawesome/free-solid-svg-icons';
import { format, formatDistanceToNow } from 'date-fns';
import Spinner from '@/components/elements/Spinner';
import { bytesToHuman } from '@/helpers';
@ -45,7 +45,10 @@ export default ({ backup, className }: Props) => {
<div css={tw`flex items-center truncate w-full md:flex-1`}>
<div css={tw`mr-4`}>
{backup.completedAt ?
<FontAwesomeIcon icon={faArchive} css={tw`text-neutral-300`}/>
backup.isLocked ?
<FontAwesomeIcon icon={faLock} css={tw`text-yellow-500`}/>
:
<FontAwesomeIcon icon={faArchive} css={tw`text-neutral-300`}/>
:
<Spinner size={'small'}/>
}
@ -65,7 +68,7 @@ export default ({ backup, className }: Props) => {
}
</div>
<p css={tw`mt-1 md:mt-0 text-xs text-neutral-400 font-mono truncate`}>
{backup.uuid}
{backup.checksum}
</p>
</div>
</div>

View file

@ -1,17 +0,0 @@
import React from 'react';
import Modal, { RequiredModalProps } from '@/components/elements/Modal';
import tw from 'twin.macro';
const ChecksumModal = ({ checksum, ...props }: RequiredModalProps & { checksum: string }) => (
<Modal {...props}>
<h3 css={tw`mb-6 text-2xl`}>Verify file checksum</h3>
<p css={tw`text-sm`}>
The checksum of this file is:
</p>
<pre css={tw`mt-2 text-sm p-2 bg-neutral-900 rounded`}>
<code css={tw`block font-mono overflow-auto`}>{checksum}</code>
</pre>
</Modal>
);
export default ChecksumModal;

View file

@ -1,7 +1,7 @@
import React, { useEffect, useState } from 'react';
import Modal, { RequiredModalProps } from '@/components/elements/Modal';
import { Field as FormikField, Form, Formik, FormikHelpers, useFormikContext } from 'formik';
import { object, string } from 'yup';
import { boolean, object, string } from 'yup';
import Field from '@/components/elements/Field';
import FormikFieldWrapper from '@/components/elements/FormikFieldWrapper';
import useFlash from '@/plugins/useFlash';
@ -12,10 +12,13 @@ import tw from 'twin.macro';
import { Textarea } from '@/components/elements/Input';
import getServerBackups from '@/api/swr/getServerBackups';
import { ServerContext } from '@/state/server';
import FormikSwitch from '@/components/elements/FormikSwitch';
import Can from '@/components/elements/Can';
interface Values {
name: string;
ignored: string;
isLocked: boolean;
}
const ModalContent = ({ ...props }: RequiredModalProps) => {
@ -26,14 +29,12 @@ const ModalContent = ({ ...props }: RequiredModalProps) => {
<Form>
<FlashMessageRender byKey={'backups:create'} css={tw`mb-4`}/>
<h2 css={tw`text-2xl mb-6`}>Create server backup</h2>
<div css={tw`mb-6`}>
<Field
name={'name'}
label={'Backup name'}
description={'If provided, the name that should be used to reference this backup.'}
/>
</div>
<div css={tw`mb-6`}>
<Field
name={'name'}
label={'Backup name'}
description={'If provided, the name that should be used to reference this backup.'}
/>
<div css={tw`mt-6`}>
<FormikFieldWrapper
name={'ignored'}
label={'Ignored Files & Directories'}
@ -47,7 +48,16 @@ const ModalContent = ({ ...props }: RequiredModalProps) => {
<FormikField as={Textarea} name={'ignored'} rows={6}/>
</FormikFieldWrapper>
</div>
<div css={tw`flex justify-end`}>
<Can action={'backup.delete'}>
<div css={tw`mt-6 bg-neutral-700 border border-neutral-800 shadow-inner p-4 rounded`}>
<FormikSwitch
name={'isLocked'}
label={'Locked'}
description={'Prevents this backup from being deleted until explicitly unlocked.'}
/>
</div>
</Can>
<div css={tw`flex justify-end mt-6`}>
<Button type={'submit'} disabled={isSubmitting}>
Start backup
</Button>
@ -67,9 +77,9 @@ export default () => {
clearFlashes('backups:create');
}, [ visible ]);
const submit = ({ name, ignored }: Values, { setSubmitting }: FormikHelpers<Values>) => {
const submit = (values: Values, { setSubmitting }: FormikHelpers<Values>) => {
clearFlashes('backups:create');
createServerBackup(uuid, name, ignored)
createServerBackup(uuid, values)
.then(backup => {
mutate(data => ({ ...data, items: data.items.concat(backup) }), false);
setVisible(false);
@ -85,10 +95,11 @@ export default () => {
{visible &&
<Formik
onSubmit={submit}
initialValues={{ name: '', ignored: '' }}
initialValues={{ name: '', ignored: '', isLocked: false }}
validationSchema={object().shape({
name: string().max(191),
ignored: string(),
isLocked: boolean(),
})}
>
<ModalContent appear visible={visible} onDismissed={() => setVisible(false)}/>

View file

@ -53,7 +53,7 @@ export default () => {
/>
))
:
<p css={tw`text-center text-sm text-neutral-400`}>
<p css={tw`text-center text-sm text-neutral-300`}>
{databaseLimit > 0 ?
'It looks like you have no databases.'
:

View file

@ -24,7 +24,7 @@ const LazyCodemirrorEditor = lazy(() => import(/* webpackChunkName: "editor" */'
export default () => {
const [ error, setError ] = useState('');
const { action } = useParams<{ action: string }>();
const { action } = useParams<{ action: 'new' | string }>();
const [ loading, setLoading ] = useState(action === 'edit');
const [ content, setContent ] = useState('');
const [ modalVisible, setModalVisible ] = useState(false);

View file

@ -63,7 +63,7 @@ const MassActionsBar = () => {
return (
<Fade timeout={75} in={selectedFiles.length > 0} unmountOnExit>
<div css={tw`fixed bottom-0 z-50 left-0 right-0 flex justify-center`}>
<div css={tw`pointer-events-none fixed bottom-0 z-50 left-0 right-0 flex justify-center`}>
<SpinnerOverlay visible={loading} size={'large'} fixed>
{loadingMessage}
</SpinnerOverlay>
@ -74,7 +74,18 @@ const MassActionsBar = () => {
onConfirmed={onClickConfirmDeletion}
onModalDismissed={() => setShowConfirm(false)}
>
Deleting files is a permanent operation, you cannot undo this action.
Are you sure you want to delete {selectedFiles.length} file(s)?
<br/>
Deleting the file(s) listed below is a permanent operation, you cannot undo this action.
<br/>
<code>
{ selectedFiles.slice(0, 15).map(file => (
<li key={file}>{file}<br/></li>))
}
{ selectedFiles.length > 15 &&
<li> + {selectedFiles.length - 15} other(s) </li>
}
</code>
</ConfirmationModal>
{showMove &&
<RenameFileModal
@ -85,7 +96,7 @@ const MassActionsBar = () => {
onDismissed={() => setShowMove(false)}
/>
}
<div css={tw`rounded p-4 mb-6`} style={{ background: 'rgba(0, 0, 0, 0.35)' }}>
<div css={tw`pointer-events-auto rounded p-4 mb-6`} style={{ background: 'rgba(0, 0, 0, 0.35)' }}>
<Button size={'xsmall'} css={tw`mr-4`} onClick={() => setShowMove(true)}>
<FontAwesomeIcon icon={faLevelUpAlt} css={tw`mr-2`}/> Move
</Button>

View file

@ -11,6 +11,7 @@ import Can from '@/components/elements/Can';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import getServerAllocations from '@/api/swr/getServerAllocations';
import isEqual from 'react-fast-compare';
import { useDeepCompareEffect } from '@/plugins/useDeepCompareEffect';
const NetworkContainer = () => {
const [ loading, setLoading ] = useState(false);
@ -23,7 +24,7 @@ const NetworkContainer = () => {
const { data, error, mutate } = getServerAllocations();
useEffect(() => {
mutate(allocations, false);
mutate(allocations);
}, []);
useEffect(() => {
@ -32,6 +33,12 @@ const NetworkContainer = () => {
}
}, [ error ]);
useDeepCompareEffect(() => {
if (!data) return;
setServerFromState(state => ({ ...state, allocations: data }));
}, [ data ]);
const onCreateAllocation = () => {
clearFlashes('server:network');

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);
@ -126,15 +79,53 @@ export default ({ schedule, visible, ...props }: Props) => {
dayOfMonth: schedule?.cron.dayOfMonth || '*',
month: schedule?.cron.month || '*',
dayOfWeek: schedule?.cron.dayOfWeek || '*',
enabled: schedule ? schedule.isActive : true,
enabled: schedule?.isActive ?? true,
onlyWhenOnline: schedule?.onlyWhenOnline ?? 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={'onlyWhenOnline'}
description={'Only execute this schedule when the server is in a running 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={'This schedule will be executed automatically if enabled.'}
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);

View file

@ -13,12 +13,7 @@ export default ({ schedule }: Props) => {
return (
<>
{visible &&
<TaskDetailsModal
schedule={schedule}
onDismissed={() => setVisible(false)}
/>
}
<TaskDetailsModal schedule={schedule} visible={visible} onModalDismissed={() => setVisible(false)}/>
<Button onClick={() => setVisible(true)} css={tw`flex-1`}>
New Task
</Button>

View file

@ -67,7 +67,7 @@ export default () => {
}
<Can action={'schedule.create'}>
<div css={tw`mt-8 flex justify-end`}>
{visible && <EditScheduleModal appear visible onDismissed={() => setVisible(false)}/>}
<EditScheduleModal visible={visible} onModalDismissed={() => setVisible(false)}/>
<Button type={'button'} onClick={() => setVisible(true)}>
Create schedule
</Button>

View file

@ -46,9 +46,9 @@ const ActivePill = ({ active }: { active: boolean }) => (
);
export default () => {
const params = useParams() as Params;
const history = useHistory();
const state = useLocation<State>().state;
const { state } = useLocation<State>();
const { id: scheduleId } = useParams<Params>();
const id = ServerContext.useStoreState(state => state.server.data!.id);
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
@ -61,27 +61,27 @@ export default () => {
const appendSchedule = ServerContext.useStoreActions(actions => actions.schedules.appendSchedule);
useEffect(() => {
if (schedule?.id === Number(params.id)) {
if (schedule?.id === Number(scheduleId)) {
setIsLoading(false);
return;
}
clearFlashes('schedules');
getServerSchedule(uuid, Number(params.id))
getServerSchedule(uuid, Number(scheduleId))
.then(schedule => appendSchedule(schedule))
.catch(error => {
console.error(error);
clearAndAddHttpError({ error, key: 'schedules' });
})
.then(() => setIsLoading(false));
}, [ params ]);
}, [ scheduleId ]);
const toggleEditModal = useCallback(() => {
setShowEditModal(s => !s);
}, []);
return (
<PageContentBlock>
<PageContentBlock title={'Schedules'}>
<FlashMessageRender byKey={'schedules'} css={tw`mb-4`}/>
{!schedule || isLoading ?
<Spinner size={'large'} centered/>
@ -153,7 +153,7 @@ export default () => {
}
</div>
</div>
<EditScheduleModal visible={showEditModal} schedule={schedule} onDismissed={toggleEditModal}/>
<EditScheduleModal visible={showEditModal} schedule={schedule} onModalDismissed={toggleEditModal}/>
<div css={tw`mt-6 flex sm:justify-end`}>
<Can action={'schedule.delete'}>
<DeleteScheduleButton
@ -161,7 +161,7 @@ export default () => {
onDeleted={() => history.push(`/server/${id}/schedules`)}
/>
</Can>
{schedule.isActive && schedule.tasks.length > 0 &&
{schedule.tasks.length > 0 &&
<Can action={'schedule.update'}>
<RunScheduleButton schedule={schedule}/>
</Can>

View file

@ -1,7 +1,15 @@
import React, { useState } from 'react';
import { Schedule, Task } from '@/api/server/schedules/getServerSchedules';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faClock, faCode, faFileArchive, faPencilAlt, faToggleOn, faTrashAlt } from '@fortawesome/free-solid-svg-icons';
import {
faArrowCircleDown,
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';
@ -59,11 +67,12 @@ export default ({ schedule, task }: Props) => {
return (
<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
<TaskDetailsModal
schedule={schedule}
task={task}
onDismissed={() => setIsEditing(false)}
/>}
visible={isEditing}
onModalDismissed={() => setIsEditing(false)}
/>
<ConfirmationModal
title={'Confirm task deletion'}
buttonText={'Delete Task'}
@ -89,6 +98,14 @@ export default ({ schedule, task }: Props) => {
}
</div>
<div css={tw`mt-3 sm:mt-0 flex items-center w-full sm:w-auto`}>
{task.continueOnFailure &&
<div css={tw`mr-6`}>
<div css={tw`flex items-center px-2 py-1 bg-yellow-500 text-yellow-800 text-sm rounded-full`}>
<Icon icon={faArrowCircleDown} css={tw`w-3 h-3 mr-2`}/>
Continues on Failure
</div>
</div>
}
{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`}>

View file

@ -1,13 +1,12 @@
import React, { useEffect } from 'react';
import Modal from '@/components/elements/Modal';
import React, { useContext, useEffect } from 'react';
import { Schedule, Task } from '@/api/server/schedules/getServerSchedules';
import { Field as FormikField, Form, Formik, FormikHelpers, useFormikContext } from 'formik';
import { Field as FormikField, Form, Formik, FormikHelpers, useField } from 'formik';
import { ServerContext } from '@/state/server';
import createOrUpdateScheduleTask from '@/api/server/schedules/createOrUpdateScheduleTask';
import { httpErrorToHuman } from '@/api/http';
import Field from '@/components/elements/Field';
import FlashMessageRender from '@/components/FlashMessageRender';
import { number, object, string } from 'yup';
import { boolean, number, object, string } from 'yup';
import useFlash from '@/plugins/useFlash';
import FormikFieldWrapper from '@/components/elements/FormikFieldWrapper';
import tw from 'twin.macro';
@ -15,158 +14,177 @@ import Label from '@/components/elements/Label';
import { Textarea } from '@/components/elements/Input';
import Button from '@/components/elements/Button';
import Select from '@/components/elements/Select';
import ModalContext from '@/context/ModalContext';
import asModal from '@/hoc/asModal';
import FormikSwitch from '@/components/elements/FormikSwitch';
interface Props {
schedule: Schedule;
// If a task is provided we can assume we're editing it. If not provided,
// we are creating a new one.
task?: Task;
onDismissed: () => void;
}
interface Values {
action: string;
payload: string;
timeOffset: string;
continueOnFailure: boolean;
}
const TaskDetailsForm = ({ isEditingTask }: { isEditingTask: boolean }) => {
const { values: { action }, initialValues, setFieldValue, setFieldTouched, isSubmitting } = useFormikContext<Values>();
const schema = object().shape({
action: string().required().oneOf([ 'command', 'power', 'backup' ]),
payload: string().when('action', {
is: v => v !== 'backup',
then: string().required('A task payload must be provided.'),
otherwise: string(),
}),
continueOnFailure: boolean(),
timeOffset: number().typeError('The time offset must be a valid number between 0 and 900.')
.required('A time offset value must be provided.')
.min(0, 'The time offset must be at least 0 seconds.')
.max(900, 'The time offset must be less than 900 seconds.'),
});
const ActionListener = () => {
const [ { value }, { initialValue: initialAction } ] = useField<string>('action');
const [ , { initialValue: initialPayload }, { setValue, setTouched } ] = useField<string>('payload');
useEffect(() => {
if (action !== initialValues.action) {
setFieldValue('payload', action === 'power' ? 'start' : '');
setFieldTouched('payload', false);
if (value !== initialAction) {
setValue(value === 'power' ? 'start' : '');
setTouched(false);
} else {
setFieldValue('payload', initialValues.payload);
setFieldTouched('payload', false);
setValue(initialPayload || '');
setTouched(false);
}
}, [ action ]);
}, [ value ]);
return (
<Form css={tw`m-0`}>
<h2 css={tw`text-2xl mb-6`}>{isEditingTask ? 'Edit Task' : 'Create Task'}</h2>
<div css={tw`flex`}>
<div css={tw`mr-2 w-1/3`}>
<Label>Action</Label>
<FormikFieldWrapper name={'action'}>
<FormikField as={Select} name={'action'}>
<option value={'command'}>Send command</option>
<option value={'power'}>Send power action</option>
<option value={'backup'}>Create backup</option>
</FormikField>
</FormikFieldWrapper>
</div>
<div css={tw`flex-1 ml-6`}>
<Field
name={'timeOffset'}
label={'Time offset (in seconds)'}
description={'The amount of time to wait after the previous task executes before running this one. If this is the first task on a schedule this will not be applied.'}
/>
</div>
</div>
<div css={tw`mt-6`}>
{action === 'command' ?
<div>
<Label>Payload</Label>
<FormikFieldWrapper name={'payload'}>
<FormikField as={Textarea} name={'payload'} rows={6} />
</FormikFieldWrapper>
</div>
:
action === 'power' ?
<div>
<Label>Payload</Label>
<FormikFieldWrapper name={'payload'}>
<FormikField as={Select} name={'payload'}>
<option value={'start'}>Start the server</option>
<option value={'restart'}>Restart the server</option>
<option value={'stop'}>Stop the server</option>
<option value={'kill'}>Terminate the server</option>
</FormikField>
</FormikFieldWrapper>
</div>
:
<div>
<Label>Ignored Files</Label>
<FormikFieldWrapper
name={'payload'}
description={'Optional. Include the files and folders to be excluded in this backup. By default, the contents of your .pteroignore file will be used. If you have reached your backup limit, the oldest backup will be rotated.'}
>
<FormikField as={Textarea} name={'payload'} rows={6} />
</FormikFieldWrapper>
</div>
}
</div>
<div css={tw`flex justify-end mt-6`}>
<Button type={'submit'} disabled={isSubmitting}>
{isEditingTask ? 'Save Changes' : 'Create Task'}
</Button>
</div>
</Form>
);
return null;
};
export default ({ task, schedule, onDismissed }: Props) => {
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
const TaskDetailsModal = ({ schedule, task }: Props) => {
const { dismiss } = useContext(ModalContext);
const { clearFlashes, addError } = useFlash();
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
const appendSchedule = ServerContext.useStoreActions(actions => actions.schedules.appendSchedule);
const backupLimit = ServerContext.useStoreState(state => state.server.data!.featureLimits.backups);
useEffect(() => {
clearFlashes('schedule:task');
return () => {
clearFlashes('schedule:task');
};
}, []);
const submit = (values: Values, { setSubmitting }: FormikHelpers<Values>) => {
clearFlashes('schedule:task');
createOrUpdateScheduleTask(uuid, schedule.id, task?.id, values)
.then(task => {
let tasks = schedule.tasks.map(t => t.id === task.id ? task : t);
if (!schedule.tasks.find(t => t.id === task.id)) {
tasks = [ ...tasks, task ];
}
if (backupLimit === 0 && values.action === 'backup') {
setSubmitting(false);
addError({ message: 'A backup task cannot be created when the server\'s backup limit is set to 0.', key: 'schedule:task' });
} else {
createOrUpdateScheduleTask(uuid, schedule.id, task?.id, values)
.then(task => {
let tasks = schedule.tasks.map(t => t.id === task.id ? task : t);
if (!schedule.tasks.find(t => t.id === task.id)) {
tasks = [ ...tasks, task ];
}
appendSchedule({ ...schedule, tasks });
onDismissed();
})
.catch(error => {
console.error(error);
setSubmitting(false);
addError({ message: httpErrorToHuman(error), key: 'schedule:task' });
});
appendSchedule({ ...schedule, tasks });
dismiss();
})
.catch(error => {
console.error(error);
setSubmitting(false);
addError({ message: httpErrorToHuman(error), key: 'schedule:task' });
});
}
};
return (
<Formik
onSubmit={submit}
validationSchema={schema}
initialValues={{
action: task?.action || 'command',
payload: task?.payload || '',
timeOffset: task?.timeOffset.toString() || '0',
continueOnFailure: task?.continueOnFailure || false,
}}
validationSchema={object().shape({
action: string().required().oneOf([ 'command', 'power', 'backup' ]),
payload: string().when('action', {
is: v => v !== 'backup',
then: string().required('A task payload must be provided.'),
otherwise: string(),
}),
timeOffset: number().typeError('The time offset must be a valid number between 0 and 900.')
.required('A time offset value must be provided.')
.min(0, 'The time offset must be at least 0 seconds.')
.max(900, 'The time offset must be less than 900 seconds.'),
})}
>
{({ isSubmitting }) => (
<Modal
visible
appear
onDismissed={() => onDismissed()}
showSpinnerOverlay={isSubmitting}
>
<FlashMessageRender byKey={'schedule:task'} css={tw`mb-4`} />
<TaskDetailsForm isEditingTask={typeof task !== 'undefined'} />
</Modal>
{({ isSubmitting, values }) => (
<Form css={tw`m-0`}>
<FlashMessageRender byKey={'schedule:task'} css={tw`mb-4`}/>
<h2 css={tw`text-2xl mb-6`}>{task ? 'Edit Task' : 'Create Task'}</h2>
<div css={tw`flex`}>
<div css={tw`mr-2 w-1/3`}>
<Label>Action</Label>
<ActionListener/>
<FormikFieldWrapper name={'action'}>
<FormikField as={Select} name={'action'}>
<option value={'command'}>Send command</option>
<option value={'power'}>Send power action</option>
<option value={'backup'}>Create backup</option>
</FormikField>
</FormikFieldWrapper>
</div>
<div css={tw`flex-1 ml-6`}>
<Field
name={'timeOffset'}
label={'Time offset (in seconds)'}
description={'The amount of time to wait after the previous task executes before running this one. If this is the first task on a schedule this will not be applied.'}
/>
</div>
</div>
<div css={tw`mt-6`}>
{values.action === 'command' ?
<div>
<Label>Payload</Label>
<FormikFieldWrapper name={'payload'}>
<FormikField as={Textarea} name={'payload'} rows={6}/>
</FormikFieldWrapper>
</div>
:
values.action === 'power' ?
<div>
<Label>Payload</Label>
<FormikFieldWrapper name={'payload'}>
<FormikField as={Select} name={'payload'}>
<option value={'start'}>Start the server</option>
<option value={'restart'}>Restart the server</option>
<option value={'stop'}>Stop the server</option>
<option value={'kill'}>Terminate the server</option>
</FormikField>
</FormikFieldWrapper>
</div>
:
<div>
<Label>Ignored Files</Label>
<FormikFieldWrapper
name={'payload'}
description={'Optional. Include the files and folders to be excluded in this backup. By default, the contents of your .pteroignore file will be used. If you have reached your backup limit, the oldest backup will be rotated.'}
>
<FormikField as={Textarea} name={'payload'} rows={6}/>
</FormikFieldWrapper>
</div>
}
</div>
<div css={tw`mt-6 bg-neutral-700 border border-neutral-800 shadow-inner p-4 rounded`}>
<FormikSwitch
name={'continueOnFailure'}
description={'Future tasks will be run when this task fails.'}
label={'Continue on Failure'}
/>
</div>
<div css={tw`flex justify-end mt-6`}>
<Button type={'submit'} disabled={isSubmitting}>
{task ? 'Save Changes' : 'Create Task'}
</Button>
</div>
</Form>
)}
</Formik>
);
};
export default asModal<Props>()(TaskDetailsModal);

View file

@ -32,7 +32,7 @@ const EditSubuserModal = ({ subuser }: Props) => {
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
const appendSubuser = ServerContext.useStoreActions(actions => actions.subusers.appendSubuser);
const { clearFlashes, clearAndAddHttpError } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
const { dismiss, toggleSpinner } = useContext(ModalContext);
const { dismiss, setPropOverrides } = useContext(ModalContext);
const isRootAdmin = useStoreState(state => state.user.data!.rootAdmin);
const permissions = useStoreState(state => state.permissions.data);
@ -56,7 +56,7 @@ const EditSubuserModal = ({ subuser }: Props) => {
}, [ isRootAdmin, permissions, loggedInPermissions ]);
const submit = (values: Values) => {
toggleSpinner(true);
setPropOverrides({ showSpinnerOverlay: true });
clearFlashes('user:edit');
createOrUpdateSubuser(uuid, values, subuser)
@ -66,7 +66,7 @@ const EditSubuserModal = ({ subuser }: Props) => {
})
.catch(error => {
console.error(error);
toggleSpinner(false);
setPropOverrides(null);
clearAndAddHttpError({ key: 'user:edit', error });
if (ref.current) {