diff --git a/resources/scripts/components/server/schedules/DeleteScheduleButton.tsx b/resources/scripts/components/server/schedules/DeleteScheduleButton.tsx new file mode 100644 index 0000000..5ea8c37 --- /dev/null +++ b/resources/scripts/components/server/schedules/DeleteScheduleButton.tsx @@ -0,0 +1,57 @@ +import React, { useState } from 'react'; +import deleteSchedule from '@/api/server/schedules/deleteSchedule'; +import { ServerContext } from '@/state/server'; +import { Actions, useStoreActions } from 'easy-peasy'; +import { ApplicationStore } from '@/state'; +import { httpErrorToHuman } from '@/api/http'; +import tw from 'twin.macro'; +import Button from '@/components/elements/Button'; +import ConfirmationModal from '@/components/elements/ConfirmationModal'; +import lang from '../../../../../lang.json'; + +interface Props { + scheduleId: number; + onDeleted: () => void; +} + +export default ({ scheduleId, onDeleted }: Props) => { + const [ visible, setVisible ] = useState(false); + const [ isLoading, setIsLoading ] = useState(false); + const uuid = ServerContext.useStoreState(state => state.server.data!.uuid); + const { addError, clearFlashes } = useStoreActions((actions: Actions) => actions.flashes); + + const onDelete = () => { + setIsLoading(true); + clearFlashes('schedules'); + deleteSchedule(uuid, scheduleId) + .then(() => { + setIsLoading(false); + onDeleted(); + }) + .catch(error => { + console.error(error); + + addError({ key: 'schedules', message: httpErrorToHuman(error) }); + setIsLoading(false); + setVisible(false); + }); + }; + + return ( + <> + setVisible(false)} + > + {lang.schedule_are_you_sure_delete} + + + + ); +}; diff --git a/resources/scripts/components/server/schedules/EditScheduleModal.tsx b/resources/scripts/components/server/schedules/EditScheduleModal.tsx new file mode 100644 index 0000000..047b846 --- /dev/null +++ b/resources/scripts/components/server/schedules/EditScheduleModal.tsx @@ -0,0 +1,131 @@ +import React, { useContext, useEffect } from 'react'; +import { Schedule } from '@/api/server/schedules/getServerSchedules'; +import Field from '@/components/elements/Field'; +import { Form, Formik, FormikHelpers } from 'formik'; +import FormikSwitch from '@/components/elements/FormikSwitch'; +import createOrUpdateSchedule from '@/api/server/schedules/createOrUpdateSchedule'; +import { ServerContext } from '@/state/server'; +import { httpErrorToHuman } from '@/api/http'; +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'; +import lang from '../../../../../lang.json'; + +interface Props { + schedule?: Schedule; +} + +interface Values { + name: string; + dayOfWeek: string; + month: string; + dayOfMonth: string; + hour: string; + minute: string; + enabled: boolean; + onlyWhenOnline: boolean; +} + +const EditScheduleModal = ({ schedule }: Props) => { + const { addError, clearFlashes } = useFlash(); + const { dismiss } = useContext(ModalContext); + + const uuid = ServerContext.useStoreState(state => state.server.data!.uuid); + const appendSchedule = ServerContext.useStoreActions(actions => actions.schedules.appendSchedule); + + useEffect(() => { + return () => { + clearFlashes('schedule:edit'); + }; + }, []); + + const submit = (values: Values, { setSubmitting }: FormikHelpers) => { + clearFlashes('schedule:edit'); + createOrUpdateSchedule(uuid, { + id: schedule?.id, + name: values.name, + cron: { + minute: values.minute, + hour: values.hour, + dayOfWeek: values.dayOfWeek, + month: values.month, + dayOfMonth: values.dayOfMonth, + }, + onlyWhenOnline: values.onlyWhenOnline, + isActive: values.enabled, + }) + .then(schedule => { + setSubmitting(false); + appendSchedule(schedule); + dismiss(); + }) + .catch(error => { + console.error(error); + + setSubmitting(false); + addError({ key: 'schedule:edit', message: httpErrorToHuman(error) }); + }); + }; + + return ( + + {({ isSubmitting }) => ( +
+

{schedule ? lang.schedule_edit : lang.schedule_create_new}

+ + +
+ + + + + +
+

+ {lang.the_schedule_system_stuff_blabla_no_one_fucking_cares} +

+
+ +
+
+ +
+
+ +
+ + )} +
+ ); +}; + +export default asModal()(EditScheduleModal); diff --git a/resources/scripts/components/server/schedules/NewTaskButton.tsx b/resources/scripts/components/server/schedules/NewTaskButton.tsx new file mode 100644 index 0000000..65fdd6c --- /dev/null +++ b/resources/scripts/components/server/schedules/NewTaskButton.tsx @@ -0,0 +1,23 @@ +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'; +import lang from '../../../../../lang.json'; + +interface Props { + schedule: Schedule; +} + +export default ({ schedule }: Props) => { + const [ visible, setVisible ] = useState(false); + + return ( + <> + setVisible(false)}/> + + + ); +}; diff --git a/resources/scripts/components/server/schedules/RunScheduleButton.tsx b/resources/scripts/components/server/schedules/RunScheduleButton.tsx new file mode 100644 index 0000000..40d5d68 --- /dev/null +++ b/resources/scripts/components/server/schedules/RunScheduleButton.tsx @@ -0,0 +1,49 @@ +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'; +import lang from '../../../../../lang.json'; + +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 ( + <> + + + + ); +}; + +export default RunScheduleButton; diff --git a/resources/scripts/components/server/schedules/ScheduleContainer.tsx b/resources/scripts/components/server/schedules/ScheduleContainer.tsx new file mode 100644 index 0000000..62e2af3 --- /dev/null +++ b/resources/scripts/components/server/schedules/ScheduleContainer.tsx @@ -0,0 +1,81 @@ +import React, { useEffect, useState } from 'react'; +import getServerSchedules from '@/api/server/schedules/getServerSchedules'; +import { ServerContext } from '@/state/server'; +import Spinner from '@/components/elements/Spinner'; +import { useHistory, useRouteMatch } from 'react-router-dom'; +import FlashMessageRender from '@/components/FlashMessageRender'; +import ScheduleRow from '@/components/server/schedules/ScheduleRow'; +import { httpErrorToHuman } from '@/api/http'; +import EditScheduleModal from '@/components/server/schedules/EditScheduleModal'; +import Can from '@/components/elements/Can'; +import useFlash from '@/plugins/useFlash'; +import tw from 'twin.macro'; +import GreyRowBox from '@/components/elements/GreyRowBox'; +import Button from '@/components/elements/Button'; +import ServerContentBlock from '@/components/elements/ServerContentBlock'; +import lang from '../../../../../lang.json'; + +export default () => { + const match = useRouteMatch(); + const history = useHistory(); + + const uuid = ServerContext.useStoreState(state => state.server.data!.uuid); + const { clearFlashes, addError } = useFlash(); + const [ loading, setLoading ] = useState(true); + const [ visible, setVisible ] = useState(false); + + const schedules = ServerContext.useStoreState(state => state.schedules.data); + const setSchedules = ServerContext.useStoreActions(actions => actions.schedules.setSchedules); + + useEffect(() => { + clearFlashes('schedules'); + getServerSchedules(uuid) + .then(schedules => setSchedules(schedules)) + .catch(error => { + addError({ message: httpErrorToHuman(error), key: 'schedules' }); + console.error(error); + }) + .then(() => setLoading(false)); + }, []); + + return ( + + + {(!schedules.length && loading) ? + + : + <> + { + schedules.length === 0 ? +

+ {lang.there_isnt_arent_schedules_for_server_smile} +

+ : + schedules.map(schedule => ( + { + e.preventDefault(); + history.push(`${match.url}/${schedule.id}`); + }} + > + + + )) + } + +
+ setVisible(false)}/> + +
+
+ + } +
+ ); +}; diff --git a/resources/scripts/components/server/schedules/ScheduleCronRow.tsx b/resources/scripts/components/server/schedules/ScheduleCronRow.tsx new file mode 100644 index 0000000..716efcf --- /dev/null +++ b/resources/scripts/components/server/schedules/ScheduleCronRow.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import tw from 'twin.macro'; +import { Schedule } from '@/api/server/schedules/getServerSchedules'; +import lang from '../../../../../lang.json'; + +interface Props { + cron: Schedule['cron']; + className?: string; +} + +const ScheduleCronRow = ({ cron, className }: Props) => ( +
+
+

{cron.minute}

+

{lang.minute}

+
+
+

{cron.hour}

+

{lang.hour}

+
+
+

{cron.dayOfMonth}

+

{lang.day_month}

+
+
+

{cron.month}

+

{lang.month}

+
+
+

{cron.dayOfWeek}

+

{lang.day_week}

+
+
+); + +export default ScheduleCronRow; diff --git a/resources/scripts/components/server/schedules/ScheduleEditContainer.tsx b/resources/scripts/components/server/schedules/ScheduleEditContainer.tsx new file mode 100644 index 0000000..a59e40c --- /dev/null +++ b/resources/scripts/components/server/schedules/ScheduleEditContainer.tsx @@ -0,0 +1,169 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { useHistory, useParams } from 'react-router-dom'; +import getServerSchedule from '@/api/server/schedules/getServerSchedule'; +import Spinner from '@/components/elements/Spinner'; +import FlashMessageRender from '@/components/FlashMessageRender'; +import EditScheduleModal from '@/components/server/schedules/EditScheduleModal'; +import NewTaskButton from '@/components/server/schedules/NewTaskButton'; +import DeleteScheduleButton from '@/components/server/schedules/DeleteScheduleButton'; +import Can from '@/components/elements/Can'; +import useFlash from '@/plugins/useFlash'; +import { ServerContext } from '@/state/server'; +import PageContentBlock from '@/components/elements/PageContentBlock'; +import tw from 'twin.macro'; +import Button from '@/components/elements/Button'; +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'; +import lang from '../../../../../lang.json'; + +interface Params { + id: string; +} + +const CronBox = ({ title, value }: { title: string; value: string }) => ( +
+

{title}

+

{value}

+
+); + +const ActivePill = ({ active }: { active: boolean }) => ( + + {active ? lang.active : lang.inactive} + +); + +export default () => { + const history = useHistory(); + const { id: scheduleId } = useParams(); + + const id = ServerContext.useStoreState(state => state.server.data!.id); + const uuid = ServerContext.useStoreState(state => state.server.data!.uuid); + + 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 === Number(scheduleId)), isEqual); + const appendSchedule = ServerContext.useStoreActions(actions => actions.schedules.appendSchedule); + + useEffect(() => { + if (schedule?.id === Number(scheduleId)) { + setIsLoading(false); + return; + } + + clearFlashes('schedules'); + getServerSchedule(uuid, Number(scheduleId)) + .then(schedule => appendSchedule(schedule)) + .catch(error => { + console.error(error); + clearAndAddHttpError({ error, key: 'schedules' }); + }) + .then(() => setIsLoading(false)); + }, [ scheduleId ]); + + const toggleEditModal = useCallback(() => { + setShowEditModal(s => !s); + }, []); + + return ( + + + {!schedule || isLoading ? + + : + <> + +
+
+
+

+ {schedule.name} + {schedule.isProcessing ? + + + {lang.processing} + + : + + } +

+

+ {lang.last_run_at}:  + {schedule.lastRunAt ? + format(schedule.lastRunAt, 'MMM do \'at\' h:mma') + : + n/a + } + + {lang.next_run_at}:  + {schedule.nextRunAt ? + format(schedule.nextRunAt, 'MMM do \'at\' h:mma') + : + n/a + } + +

+
+
+ + + + +
+
+
+ + + + + +
+
+ {schedule.tasks.length > 0 ? + schedule.tasks.map(task => ( + + )) + : + null + } +
+
+ +
+ + history.push(`/server/${id}/schedules`)} + /> + + {schedule.tasks.length > 0 && + + + + } +
+ + } +
+ ); +}; diff --git a/resources/scripts/components/server/schedules/ScheduleRow.tsx b/resources/scripts/components/server/schedules/ScheduleRow.tsx new file mode 100644 index 0000000..4deea98 --- /dev/null +++ b/resources/scripts/components/server/schedules/ScheduleRow.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { Schedule } from '@/api/server/schedules/getServerSchedules'; +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'; +import lang from '../../../../../lang.json'; + +export default ({ schedule }: { schedule: Schedule }) => ( + <> +
+ +
+
+

{schedule.name}

+

+ {lang.last_run_at}: {schedule.lastRunAt ? format(schedule.lastRunAt, 'MMM do \'at\' h:mma') : 'never'} +

+
+
+

+ {schedule.isActive ? lang.active : lang.inactive} +

+
+ +
+ +
+ +); diff --git a/resources/scripts/components/server/schedules/ScheduleTaskRow.tsx b/resources/scripts/components/server/schedules/ScheduleTaskRow.tsx new file mode 100644 index 0000000..fc57db6 --- /dev/null +++ b/resources/scripts/components/server/schedules/ScheduleTaskRow.tsx @@ -0,0 +1,141 @@ +import React, { useState } from 'react'; +import { Schedule, Task } from '@/api/server/schedules/getServerSchedules'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +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'; +import TaskDetailsModal from '@/components/server/schedules/TaskDetailsModal'; +import Can from '@/components/elements/Can'; +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'; +import lang from '../../../../../lang.json'; + +interface Props { + schedule: Schedule; + task: Task; +} + +const getActionDetails = (action: string): [ string, any ] => { + switch (action) { + case 'command': + return [ lang.send_command, faCode ]; + case 'power': + return [ lang.send_power_action, faToggleOn ]; + case 'backup': + return [ lang.create_backup, faFileArchive ]; + default: + return [ lang.unknown_action, faCode ]; + } +}; + +export default ({ schedule, task }: Props) => { + const uuid = ServerContext.useStoreState(state => state.server.data!.uuid); + const { clearFlashes, addError } = useFlash(); + const [ visible, setVisible ] = useState(false); + const [ isLoading, setIsLoading ] = useState(false); + const [ isEditing, setIsEditing ] = useState(false); + const appendSchedule = ServerContext.useStoreActions(actions => actions.schedules.appendSchedule); + + const onConfirmDeletion = () => { + setIsLoading(true); + clearFlashes('schedules'); + deleteScheduleTask(uuid, schedule.id, task.id) + .then(() => appendSchedule({ + ...schedule, + tasks: schedule.tasks.filter(t => t.id !== task.id), + })) + .catch(error => { + console.error(error); + setIsLoading(false); + addError({ message: httpErrorToHuman(error), key: 'schedules' }); + }); + }; + + const [ title, icon ] = getActionDetails(task.action); + + return ( +
+ + setIsEditing(false)} + /> + setVisible(false)} + > + {lang.u_sure_u_wanna_delet_this_task} + +
+ ); +}; diff --git a/resources/scripts/components/server/schedules/TaskDetailsModal.tsx b/resources/scripts/components/server/schedules/TaskDetailsModal.tsx new file mode 100644 index 0000000..66538ff --- /dev/null +++ b/resources/scripts/components/server/schedules/TaskDetailsModal.tsx @@ -0,0 +1,191 @@ +import React, { useContext, useEffect } from 'react'; +import { Schedule, Task } from '@/api/server/schedules/getServerSchedules'; +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 { boolean, number, object, string } from 'yup'; +import useFlash from '@/plugins/useFlash'; +import FormikFieldWrapper from '@/components/elements/FormikFieldWrapper'; +import tw from 'twin.macro'; +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'; +import lang from '../../../../../lang.json'; + +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; +} + +interface Values { + action: string; + payload: string; + timeOffset: string; + continueOnFailure: boolean; +} + +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('action'); + const [ , { initialValue: initialPayload }, { setValue, setTouched } ] = useField('payload'); + + useEffect(() => { + if (value !== initialAction) { + setValue(value === 'power' ? 'start' : ''); + setTouched(false); + } else { + setValue(initialPayload || ''); + setTouched(false); + } + }, [ value ]); + + return null; +}; + +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(() => { + return () => { + clearFlashes('schedule:task'); + }; + }, []); + + const submit = (values: Values, { setSubmitting }: FormikHelpers) => { + clearFlashes('schedule: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 }); + dismiss(); + }) + .catch(error => { + console.error(error); + setSubmitting(false); + addError({ message: httpErrorToHuman(error), key: 'schedule:task' }); + }); + } + }; + + return ( + + {({ isSubmitting, values }) => ( +
+ +

{task ? lang.edit_task : lang.create_task}

+
+
+ + + + + + + + + +
+
+ +
+
+
+ {values.action === 'command' ? +
+ + + + +
+ : + values.action === 'power' ? +
+ + + + + + + + + +
+ : +
+ + + + +
+ } +
+
+ +
+
+ +
+ + )} +
+ ); +}; + +export default asModal()(TaskDetailsModal);