Merge branch 'develop' into feature/server-transfers-actually

This commit is contained in:
Matthew Penner 2020-04-04 16:28:02 -06:00 committed by GitHub
commit fd4de9168a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
49 changed files with 1000 additions and 37 deletions

View file

@ -1,9 +1,9 @@
import { rawDataToServerObject, Server } from '@/api/server/getServer';
import http, { getPaginationSet, PaginatedResult } from '@/api/http';
export default (): Promise<PaginatedResult<Server>> => {
export default (query?: string): Promise<PaginatedResult<Server>> => {
return new Promise((resolve, reject) => {
http.get(`/api/client`, { params: { include: [ 'allocation' ] } })
http.get(`/api/client`, { params: { include: [ 'allocation' ], query } })
.then(({ data }) => resolve({
items: (data.data || []).map((datum: any) => rawDataToServerObject(datum.attributes)),
pagination: getPaginationSet(data.meta.pagination),

View file

@ -0,0 +1,12 @@
import { rawDataToServerBackup, ServerBackup } from '@/api/server/backups/getServerBackups';
import http from '@/api/http';
export default (uuid: string, name?: string, ignore?: string): Promise<ServerBackup> => {
return new Promise((resolve, reject) => {
http.post(`/api/client/servers/${uuid}/backups`, {
name, ignore,
})
.then(({ data }) => resolve(rawDataToServerBackup(data)))
.catch(reject);
});
};

View file

@ -0,0 +1,32 @@
import http, { FractalResponseData, getPaginationSet, PaginatedResult } from '@/api/http';
export interface ServerBackup {
uuid: string;
name: string;
ignoredFiles: string;
sha256Hash: string;
bytes: number;
createdAt: Date;
completedAt: Date | null;
}
export const rawDataToServerBackup = ({ attributes }: FractalResponseData): ServerBackup => ({
uuid: attributes.uuid,
name: attributes.name,
ignoredFiles: attributes.ignored_files,
sha256Hash: attributes.sha256_hash,
bytes: attributes.bytes,
createdAt: new Date(attributes.created_at),
completedAt: attributes.completed_at ? new Date(attributes.completed_at) : null,
});
export default (uuid: string, page?: number | string): Promise<PaginatedResult<ServerBackup>> => {
return new Promise((resolve, reject) => {
http.get(`/api/client/servers/${uuid}/backups`, { params: { page } })
.then(({ data }) => resolve({
items: (data.data || []).map(rawDataToServerBackup),
pagination: getPaginationSet(data.meta.pagination),
}))
.catch(reject);
});
};

View file

@ -8,6 +8,8 @@ import { faSwatchbook } from '@fortawesome/free-solid-svg-icons/faSwatchbook';
import { faCogs } from '@fortawesome/free-solid-svg-icons/faCogs';
import { useStoreState } from 'easy-peasy';
import { ApplicationStore } from '@/state';
import { faSearch } from '@fortawesome/free-solid-svg-icons/faSearch';
import SearchContainer from '@/components/dashboard/search/SearchContainer';
export default () => {
const user = useStoreState((state: ApplicationStore) => state.user.data!);
@ -22,6 +24,7 @@ export default () => {
</Link>
</div>
<div className={'right-navigation'}>
<SearchContainer/>
<NavLink to={'/'} exact={true}>
<FontAwesomeIcon icon={faLayerGroup}/>
</NavLink>

View file

@ -0,0 +1,32 @@
import React, { useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faSearch } from '@fortawesome/free-solid-svg-icons/faSearch';
import useEventListener from '@/plugins/useEventListener';
import SearchModal from '@/components/dashboard/search/SearchModal';
export default () => {
const [ visible, setVisible ] = useState(false);
useEventListener('keydown', (e: KeyboardEvent) => {
if ([ 'input', 'textarea' ].indexOf(((e.target as HTMLElement).tagName || 'input').toLowerCase()) < 0) {
if (!visible && e.key.toLowerCase() === 'k') {
setVisible(true);
}
}
});
return (
<>
{visible &&
<SearchModal
appear={true}
visible={visible}
onDismissed={() => setVisible(false)}
/>
}
<div className={'navigation-link'} onClick={() => setVisible(true)}>
<FontAwesomeIcon icon={faSearch}/>
</div>
</>
);
};

View file

@ -0,0 +1,123 @@
import React, { useEffect, useRef, useState } from 'react';
import Modal, { RequiredModalProps } from '@/components/elements/Modal';
import { Field, Form, Formik, FormikHelpers, useFormikContext } from 'formik';
import { Actions, useStoreActions, useStoreState } from 'easy-peasy';
import { object, string } from 'yup';
import { debounce } from 'lodash-es';
import FormikFieldWrapper from '@/components/elements/FormikFieldWrapper';
import InputSpinner from '@/components/elements/InputSpinner';
import getServers from '@/api/getServers';
import { Server } from '@/api/server/getServer';
import { ApplicationStore } from '@/state';
import { httpErrorToHuman } from '@/api/http';
import { Link } from 'react-router-dom';
type Props = RequiredModalProps;
interface Values {
term: string;
}
const SearchWatcher = () => {
const { values, submitForm } = useFormikContext<Values>();
useEffect(() => {
if (values.term.length >= 3) {
submitForm();
}
}, [ values.term ]);
return null;
};
export default ({ ...props }: Props) => {
const ref = useRef<HTMLInputElement>(null);
const [ loading, setLoading ] = useState(false);
const [ servers, setServers ] = useState<Server[]>([]);
const isAdmin = useStoreState(state => state.user.data!.rootAdmin);
const { addError, clearFlashes } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
const search = debounce(({ term }: Values, { setSubmitting }: FormikHelpers<Values>) => {
setLoading(true);
setSubmitting(false);
clearFlashes('search');
getServers(term)
.then(servers => setServers(servers.items.filter((_, index) => index < 5)))
.catch(error => {
console.error(error);
addError({ key: 'search', message: httpErrorToHuman(error) });
})
.then(() => setLoading(false));
}, 500);
useEffect(() => {
if (props.visible) {
setTimeout(() => ref.current?.focus(), 250);
}
}, [ props.visible ]);
return (
<Formik
onSubmit={search}
validationSchema={object().shape({
term: string()
.min(3, 'Please enter at least three characters to begin searching.')
.required('A search term must be provided.'),
})}
initialValues={{ term: '' } as Values}
>
<Modal {...props}>
<Form>
<FormikFieldWrapper
name={'term'}
label={'Search term'}
description={
isAdmin
? 'Enter a server name, user email, or uuid to begin searching.'
: 'Enter a server name to begin searching.'
}
>
<SearchWatcher/>
<InputSpinner visible={loading}>
<Field
innerRef={ref}
name={'term'}
className={'input-dark'}
/>
</InputSpinner>
</FormikFieldWrapper>
</Form>
{servers.length > 0 &&
<div className={'mt-6'}>
{
servers.map(server => (
<Link
key={server.uuid}
to={`/server/${server.id}`}
className={'flex items-center block bg-neutral-900 p-4 rounded border-l-4 border-neutral-900 no-underline hover:shadow hover:border-cyan-500 transition-colors duration-250'}
onClick={() => props.onDismissed()}
>
<div>
<p className={'text-sm'}>{server.name}</p>
<p className={'mt-1 text-xs text-neutral-400'}>
{
server.allocations.filter(alloc => alloc.default).map(allocation => (
<span key={allocation.ip + allocation.port.toString()}>{allocation.alias || allocation.ip}:{allocation.port}</span>
))
}
</p>
</div>
<div className={'flex-1 text-right'}>
<span className={'text-xs py-1 px-2 bg-cyan-800 text-cyan-100 rounded'}>
{server.node}
</span>
</div>
</Link>
))
}
</div>
}
</Modal>
</Formik>
);
};

View file

@ -0,0 +1,22 @@
import React from 'react';
import Spinner from '@/components/elements/Spinner';
import { CSSTransition } from 'react-transition-group';
const InputSpinner = ({ visible, children }: { visible: boolean, children: React.ReactNode }) => (
<div className={'relative'}>
<CSSTransition
timeout={250}
in={visible}
unmountOnExit={true}
appear={true}
classNames={'fade'}
>
<div className={'absolute pin-r h-full flex items-center justify-end pr-3'}>
<Spinner size={'tiny'}/>
</div>
</CSSTransition>
{children}
</div>
);
export default InputSpinner;

View file

@ -3,11 +3,13 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faTimes } from '@fortawesome/free-solid-svg-icons/faTimes';
import { CSSTransition } from 'react-transition-group';
import Spinner from '@/components/elements/Spinner';
import classNames from 'classnames';
export interface RequiredModalProps {
visible: boolean;
onDismissed: () => void;
appear?: boolean;
top?: boolean;
}
type Props = RequiredModalProps & {
@ -18,7 +20,7 @@ type Props = RequiredModalProps & {
children: React.ReactNode;
}
export default ({ visible, appear, dismissable, showSpinnerOverlay, closeOnBackground = true, closeOnEscape = true, onDismissed, children }: Props) => {
export default ({ visible, appear, dismissable, showSpinnerOverlay, top = true, closeOnBackground = true, closeOnEscape = true, onDismissed, children }: Props) => {
const [render, setRender] = useState(visible);
const isDismissable = useMemo(() => {
@ -58,7 +60,7 @@ export default ({ visible, appear, dismissable, showSpinnerOverlay, closeOnBackg
}
}
}}>
<div className={'modal-container top'}>
<div className={classNames('modal-container', { top })}>
{isDismissable &&
<div className={'modal-close-icon'} onClick={() => setRender(false)}>
<FontAwesomeIcon icon={faTimes}/>

View file

@ -0,0 +1,60 @@
import React, { useEffect, useState } from 'react';
import Spinner from '@/components/elements/Spinner';
import getServerBackups, { ServerBackup } from '@/api/server/backups/getServerBackups';
import useServer from '@/plugins/useServer';
import useFlash from '@/plugins/useFlash';
import { httpErrorToHuman } from '@/api/http';
import Can from '@/components/elements/Can';
import CreateBackupButton from '@/components/server/backups/CreateBackupButton';
import FlashMessageRender from '@/components/FlashMessageRender';
import BackupRow from '@/components/server/backups/BackupRow';
export default () => {
const { uuid } = useServer();
const { addError, clearFlashes } = useFlash();
const [ loading, setLoading ] = useState(true);
const [ backups, setBackups ] = useState<ServerBackup[]>([]);
useEffect(() => {
clearFlashes('backups');
getServerBackups(uuid)
.then(data => {
setBackups(data.items);
})
.catch(error => {
console.error(error);
addError({ key: 'backups', message: httpErrorToHuman(error) });
})
.then(() => setLoading(false));
}, []);
if (loading) {
return <Spinner size={'large'} centered={true}/>;
}
return (
<div className={'mt-10 mb-6'}>
<FlashMessageRender byKey={'backups'} className={'mb-4'}/>
{!backups.length ?
<p className="text-center text-sm text-neutral-400">
There are no backups stored for this server.
</p>
:
<div>
{backups.map((backup, index) => <BackupRow
key={backup.uuid}
backup={backup}
className={index !== (backups.length - 1) ? 'mb-2' : undefined}
/>)}
</div>
}
<Can action={'backup.create'}>
<div className={'mt-6 flex justify-end'}>
<CreateBackupButton
onBackupGenerated={backup => setBackups(s => [...s, backup])}
/>
</div>
</Can>
</div>
);
};

View file

@ -0,0 +1,47 @@
import React from 'react';
import { ServerBackup } from '@/api/server/backups/getServerBackups';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faArchive } from '@fortawesome/free-solid-svg-icons/faArchive';
import format from 'date-fns/format';
import distanceInWordsToNow from 'date-fns/distance_in_words_to_now'
import Spinner from '@/components/elements/Spinner';
import { faCloudDownloadAlt } from '@fortawesome/free-solid-svg-icons/faCloudDownloadAlt';
interface Props {
backup: ServerBackup;
className?: string;
}
export default ({ backup, className }: Props) => {
return (
<div className={`grey-row-box flex items-center ${className}`}>
<div className={'mr-4'}>
<FontAwesomeIcon icon={faArchive} className={'text-neutral-300'}/>
</div>
<div className={'flex-1'}>
<p className={'text-sm mb-1'}>{backup.name}</p>
<p className={'text-xs text-neutral-400 font-mono'}>{backup.uuid}</p>
</div>
<div className={'ml-4 text-center'}>
<p
title={format(backup.createdAt, 'ddd, MMMM Do, YYYY HH:mm:ss Z')}
className={'text-sm'}
>
{distanceInWordsToNow(backup.createdAt, { includeSeconds: true, addSuffix: true })}
</p>
<p className={'text-2xs text-neutral-500 uppercase mt-1'}>Created</p>
</div>
<div className={'ml-6'} style={{ marginRight: '-0.5rem' }}>
{!backup.completedAt ?
<div title={'Backup is in progress'} className={'p-2'}>
<Spinner size={'tiny'}/>
</div>
:
<a href={'#'} className={'text-sm text-neutral-300 p-2 transition-colors duration-250 hover:text-cyan-400'}>
<FontAwesomeIcon icon={faCloudDownloadAlt}/>
</a>
}
</div>
</div>
);
};

View file

@ -0,0 +1,118 @@
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 Field from '@/components/elements/Field';
import FormikFieldWrapper from '@/components/elements/FormikFieldWrapper';
import useFlash from '@/plugins/useFlash';
import useServer from '@/plugins/useServer';
import createServerBackup from '@/api/server/backups/createServerBackup';
import { httpErrorToHuman } from '@/api/http';
import FlashMessageRender from '@/components/FlashMessageRender';
import { ServerBackup } from '@/api/server/backups/getServerBackups';
interface Values {
name: string;
ignored: string;
}
interface Props {
onBackupGenerated: (backup: ServerBackup) => void;
}
const ModalContent = ({ ...props }: RequiredModalProps) => {
const { isSubmitting } = useFormikContext<Values>();
return (
<Modal {...props} showSpinnerOverlay={isSubmitting}>
<Form className={'pb-6'}>
<FlashMessageRender byKey={'backups:create'} className={'mb-4'}/>
<h3 className={'mb-6'}>Create server backup</h3>
<div className={'mb-6'}>
<Field
name={'name'}
label={'Backup name'}
description={'If provided, the name that should be used to reference this backup.'}
/>
</div>
<div className={'mb-6'}>
<FormikFieldWrapper
name={'ignore'}
label={'Ignored Files & Directories'}
description={`
Enter the files or folders to ignore while generating this backup. Leave blank to use
the contents of the .pteroignore file in the root of the server directory if present.
Wildcard matching of files and folders is supported in addition to negating a rule by
prefixing the path with an exclamation point.
`}
>
<FormikField
name={'contents'}
component={'textarea'}
className={'input-dark h-32'}
/>
</FormikFieldWrapper>
</div>
<div className={'flex justify-end'}>
<button
type={'submit'}
className={'btn btn-primary btn-sm'}
>
Start backup
</button>
</div>
</Form>
</Modal>
);
};
export default ({ onBackupGenerated }: Props) => {
const { uuid } = useServer();
const { addError, clearFlashes } = useFlash();
const [ visible, setVisible ] = useState(false);
useEffect(() => {
clearFlashes('backups:create');
}, [visible]);
const submit = ({ name, ignored }: Values, { setSubmitting }: FormikHelpers<Values>) => {
clearFlashes('backups:create')
createServerBackup(uuid, name, ignored)
.then(backup => {
onBackupGenerated(backup);
setVisible(false);
})
.catch(error => {
console.error(error);
addError({ key: 'backups:create', message: httpErrorToHuman(error) });
setSubmitting(false);
});
};
return (
<>
{visible &&
<Formik
onSubmit={submit}
initialValues={{ name: '', ignored: '' }}
validationSchema={object().shape({
name: string().max(255),
ignored: string(),
})}
>
<ModalContent
appear={true}
visible={visible}
onDismissed={() => setVisible(false)}
/>
</Formik>
}
<button
className={'btn btn-primary btn-sm'}
onClick={() => setVisible(true)}
>
Create backup
</button>
</>
);
};

View file

@ -45,7 +45,7 @@ const EditSubuserModal = forwardRef<HTMLHeadingElement, Props>(({ subuser, ...pr
const permissions = useStoreState((state: ApplicationStore) => state.permissions.data);
return (
<Modal {...props} showSpinnerOverlay={isSubmitting}>
<Modal {...props} top={false} showSpinnerOverlay={isSubmitting}>
<h3 ref={ref}>
{subuser ?
`${canEditUser ? 'Modify' : 'View'} permissions for ${subuser.email}`

9
resources/scripts/easy-peasy.d.ts vendored Normal file
View file

@ -0,0 +1,9 @@
// noinspection ES6UnusedImports
import EasyPeasy from 'easy-peasy';
import { ApplicationStore } from '@/state';
declare module 'easy-peasy' {
export function useStoreState<Result>(
mapState: (state: ApplicationStore) => Result,
): Result;
}

View file

@ -0,0 +1,23 @@
import { useEffect, useRef } from 'react';
export default (eventName: string, handler: any, element: any = window) => {
const savedHandler = useRef<any>(null);
useEffect(() => {
savedHandler.current = handler;
}, [handler]);
useEffect(
() => {
const isSupported = element && element.addEventListener;
if (!isSupported) return;
const eventListener = (event: any) => savedHandler.current(event);
element.addEventListener(eventName, eventListener);
return () => {
element.removeEventListener(eventName, eventListener);
};
},
[eventName, element],
);
};

View file

@ -0,0 +1,9 @@
import { Actions, useStoreActions } from 'easy-peasy';
import { FlashStore } from '@/state/flashes';
import { ApplicationStore } from '@/state';
const useFlash = (): Actions<FlashStore> => {
return useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
};
export default useFlash;

View file

@ -0,0 +1,9 @@
import { DependencyList } from 'react';
import { ServerContext } from '@/state/server';
import { Server } from '@/api/server/getServer';
const useServer = (dependencies?: DependencyList): Server => {
return ServerContext.useStoreState(state => state.server.data!, [ dependencies ]);
};
export default useServer;

View file

@ -16,6 +16,7 @@ import ScheduleContainer from '@/components/server/schedules/ScheduleContainer';
import ScheduleEditContainer from '@/components/server/schedules/ScheduleEditContainer';
import UsersContainer from '@/components/server/users/UsersContainer';
import Can from '@/components/elements/Can';
import BackupContainer from '@/components/server/backups/BackupContainer';
const ServerRouter = ({ match, location }: RouteComponentProps<{ id: string }>) => {
const server = ServerContext.useStoreState(state => state.server.data);
@ -47,6 +48,9 @@ const ServerRouter = ({ match, location }: RouteComponentProps<{ id: string }>)
<Can action={'user.*'}>
<NavLink to={`${match.url}/users`}>Users</NavLink>
</Can>
<Can action={'backup.*'}>
<NavLink to={`${match.url}/backups`}>Backups</NavLink>
</Can>
<Can action={['settings.*', 'file.sftp']} matchAny={true}>
<NavLink to={`${match.url}/settings`}>Settings</NavLink>
</Can>
@ -77,6 +81,7 @@ const ServerRouter = ({ match, location }: RouteComponentProps<{ id: string }>)
<Route path={`${match.path}/schedules`} component={ScheduleContainer} exact/>
<Route path={`${match.path}/schedules/:id`} component={ScheduleEditContainer} exact/>
<Route path={`${match.path}/users`} component={UsersContainer} exact/>
<Route path={`${match.path}/backups`} component={BackupContainer} exact/>
<Route path={`${match.path}/settings`} component={SettingsContainer} exact/>
</Switch>
</React.Fragment>

View file

@ -6,9 +6,9 @@
& > .modal-container {
@apply .relative .w-full .max-w-1/2 .m-auto .flex-col .flex;
/*&.top {
&.top {
margin-top: 10%;
}*/
}
& > .modal-close-icon {
@apply .absolute .pin-r .p-2 .text-white .cursor-pointer .opacity-50;

View file

@ -21,8 +21,8 @@
& .right-navigation {
@apply .flex .h-full .items-center .justify-center;
& > a {
@apply .flex .items-center .h-full .no-underline .text-neutral-300 .px-6;
& > a, & > .navigation-link {
@apply .flex .items-center .h-full .no-underline .text-neutral-300 .px-6 .cursor-pointer;
transition: background-color 150ms linear, color 150ms linear, box-shadow 150ms ease-in;
/*! purgecss start ignore */

View file

@ -79,7 +79,7 @@
@if($server->threads != null)
<code>{{ $server->threads }}</code>
@else
<code>Not Set</code>
<code>n/a</code>
@endif
</td>
</tr>