Merge branch 'develop' into feature/file-uploads

This commit is contained in:
Dane Everitt 2020-08-22 18:33:09 -07:00
commit 54f9c5f187
No known key found for this signature in database
GPG key ID: EEA66103B3D71F53
136 changed files with 2178 additions and 971 deletions

View file

@ -0,0 +1,26 @@
import useWebsocketEvent from '@/plugins/useWebsocketEvent';
import { ServerContext } from '@/state/server';
import useServer from '@/plugins/useServer';
const InstallListener = () => {
const server = useServer();
const getServer = ServerContext.useStoreActions(actions => actions.server.getServer);
const setServer = ServerContext.useStoreActions(actions => actions.server.setServer);
// Listen for the installation completion event and then fire off a request to fetch the updated
// server information. This allows the server to automatically become available to the user if they
// just sit on the page.
useWebsocketEvent('install completed', () => {
getServer(server.uuid).catch(error => console.error(error));
});
// When we see the install started event immediately update the state to indicate such so that the
// screens automatically update.
useWebsocketEvent('install started', () => {
setServer({ ...server, isInstalling: true });
});
return null;
};
export default InstallListener;

View file

@ -1,4 +1,5 @@
import React, { lazy, useEffect, useState } from 'react';
import { Helmet } from 'react-helmet';
import { ServerContext } from '@/state/server';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faCircle, faHdd, faMemory, faMicrochip, faServer } from '@fortawesome/free-solid-svg-icons';
@ -61,6 +62,9 @@ export default () => {
return (
<PageContentBlock css={tw`flex`}>
<Helmet>
<title> {server.name} | Console </title>
</Helmet>
<div css={tw`w-1/4`}>
<TitledGreyBox title={server.name} icon={faServer}>
<p css={tw`text-xs uppercase`}>

View file

@ -8,7 +8,7 @@ const StopOrKillButton = ({ onPress }: { onPress: (action: PowerAction) => void
const status = ServerContext.useStoreState(state => state.status.value);
useEffect(() => {
setClicked(state => [ 'stopping' ].indexOf(status) < 0 ? false : state);
setClicked(status === 'stopping');
}, [ status ]);
return (

View file

@ -1,50 +1,49 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect } from 'react';
import { Helmet } from 'react-helmet';
import Spinner from '@/components/elements/Spinner';
import getServerBackups 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';
import { ServerContext } from '@/state/server';
import PageContentBlock from '@/components/elements/PageContentBlock';
import tw from 'twin.macro';
import getServerBackups from '@/api/swr/getServerBackups';
export default () => {
const { uuid, featureLimits } = useServer();
const { addError, clearFlashes } = useFlash();
const [ loading, setLoading ] = useState(true);
const { clearFlashes, clearAndAddHttpError } = useFlash();
const { featureLimits, name: serverName } = useServer();
const backups = ServerContext.useStoreState(state => state.backups.data);
const setBackups = ServerContext.useStoreActions(actions => actions.backups.setBackups);
const { data: backups, error, isValidating } = getServerBackups();
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 (!error) {
clearFlashes('backups');
if (backups.length === 0 && loading) {
return;
}
clearAndAddHttpError({ error, key: 'backups' });
}, [ error ]);
if (!backups || (error && isValidating)) {
return <Spinner size={'large'} centered/>;
}
return (
<PageContentBlock>
<Helmet>
<title>{serverName} | Backups</title>
</Helmet>
<FlashMessageRender byKey={'backups'} css={tw`mb-4`}/>
{!backups.length ?
{!backups.items.length ?
<p css={tw`text-center text-sm text-neutral-400`}>
There are no backups stored for this server.
</p>
:
<div>
{backups.map((backup, index) => <BackupRow
{backups.items.map((backup, index) => <BackupRow
key={backup.uuid}
backup={backup}
css={index > 0 ? tw`mt-2` : undefined}
@ -52,17 +51,17 @@ export default () => {
</div>
}
{featureLimits.backups === 0 &&
<p className="text-center text-sm text-neutral-400">
Backups cannot be created for this server.
</p>
<p css={tw`text-center text-sm text-neutral-400`}>
Backups cannot be created for this server.
</p>
}
<Can action={'backup.create'}>
{(featureLimits.backups > 0 && backups.length > 0) &&
{(featureLimits.backups > 0 && backups.items.length > 0) &&
<p css={tw`text-center text-xs text-neutral-400 mt-2`}>
{backups.length} of {featureLimits.backups} backups have been created for this server.
{backups.items.length} of {featureLimits.backups} backups have been created for this server.
</p>
}
{featureLimits.backups > 0 && featureLimits.backups !== backups.length &&
{featureLimits.backups > 0 && featureLimits.backups !== backups.items.length &&
<div css={tw`mt-6 flex justify-end`}>
<CreateBackupButton/>
</div>

View file

@ -1,19 +1,18 @@
import React, { useState } from 'react';
import { ServerBackup } from '@/api/server/backups/getServerBackups';
import { faCloudDownloadAlt, faEllipsisH, faLock, faTrashAlt } 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 { httpErrorToHuman } from '@/api/http';
import useFlash from '@/plugins/useFlash';
import ChecksumModal from '@/components/server/backups/ChecksumModal';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import useServer from '@/plugins/useServer';
import deleteBackup from '@/api/server/backups/deleteBackup';
import { ServerContext } from '@/state/server';
import ConfirmationModal from '@/components/elements/ConfirmationModal';
import Can from '@/components/elements/Can';
import tw from 'twin.macro';
import getServerBackups from '@/api/swr/getServerBackups';
import { ServerBackup } from '@/api/server/types';
interface Props {
backup: ServerBackup;
@ -24,8 +23,8 @@ export default ({ backup }: Props) => {
const [ loading, setLoading ] = useState(false);
const [ visible, setVisible ] = useState(false);
const [ deleteVisible, setDeleteVisible ] = useState(false);
const { addError, clearFlashes } = useFlash();
const removeBackup = ServerContext.useStoreActions(actions => actions.backups.removeBackup);
const { clearFlashes, clearAndAddHttpError } = useFlash();
const { mutate } = getServerBackups();
const doDownload = () => {
setLoading(true);
@ -37,7 +36,7 @@ export default ({ backup }: Props) => {
})
.catch(error => {
console.error(error);
addError({ key: 'backups', message: httpErrorToHuman(error) });
clearAndAddHttpError({ key: 'backups', error });
})
.then(() => setLoading(false));
};
@ -46,10 +45,15 @@ export default ({ backup }: Props) => {
setLoading(true);
clearFlashes('backups');
deleteBackup(uuid, backup.uuid)
.then(() => removeBackup(backup.uuid))
.then(() => {
mutate(data => ({
...data,
items: data.items.filter(b => b.uuid !== backup.uuid),
}), false);
})
.catch(error => {
console.error(error);
addError({ key: 'backups', message: httpErrorToHuman(error) });
clearAndAddHttpError({ key: 'backups', error });
setLoading(false);
setDeleteVisible(false);
});
@ -65,48 +69,55 @@ export default ({ backup }: Props) => {
checksum={backup.sha256Hash}
/>
}
{deleteVisible &&
<ConfirmationModal
visible={deleteVisible}
title={'Delete this backup?'}
buttonText={'Yes, delete backup'}
onConfirmed={() => doDeletion()}
visible={deleteVisible}
onDismissed={() => setDeleteVisible(false)}
onModalDismissed={() => setDeleteVisible(false)}
>
Are you sure you wish to delete this backup? This is a permanent operation and the backup cannot
be recovered once deleted.
</ConfirmationModal>
}
<SpinnerOverlay visible={loading} fixed/>
<DropdownMenu
renderToggle={onClick => (
<button
onClick={onClick}
css={tw`text-neutral-200 transition-colors duration-150 hover:text-neutral-100 p-2`}
>
<FontAwesomeIcon icon={faEllipsisH}/>
</button>
)}
>
<div css={tw`text-sm`}>
<Can action={'backup.download'}>
<DropdownButtonRow onClick={() => doDownload()}>
<FontAwesomeIcon fixedWidth icon={faCloudDownloadAlt} css={tw`text-xs`}/>
<span css={tw`ml-2`}>Download</span>
{backup.isSuccessful ?
<DropdownMenu
renderToggle={onClick => (
<button
onClick={onClick}
css={tw`text-neutral-200 transition-colors duration-150 hover:text-neutral-100 p-2`}
>
<FontAwesomeIcon icon={faEllipsisH}/>
</button>
)}
>
<div css={tw`text-sm`}>
<Can action={'backup.download'}>
<DropdownButtonRow onClick={() => doDownload()}>
<FontAwesomeIcon fixedWidth icon={faCloudDownloadAlt} css={tw`text-xs`}/>
<span css={tw`ml-2`}>Download</span>
</DropdownButtonRow>
</Can>
<DropdownButtonRow onClick={() => setVisible(true)}>
<FontAwesomeIcon fixedWidth icon={faLock} css={tw`text-xs`}/>
<span css={tw`ml-2`}>Checksum</span>
</DropdownButtonRow>
</Can>
<DropdownButtonRow onClick={() => setVisible(true)}>
<FontAwesomeIcon fixedWidth icon={faLock} css={tw`text-xs`}/>
<span css={tw`ml-2`}>Checksum</span>
</DropdownButtonRow>
<Can action={'backup.delete'}>
<DropdownButtonRow danger onClick={() => setDeleteVisible(true)}>
<FontAwesomeIcon fixedWidth icon={faTrashAlt} css={tw`text-xs`}/>
<span css={tw`ml-2`}>Delete</span>
</DropdownButtonRow>
</Can>
</div>
</DropdownMenu>
<Can action={'backup.delete'}>
<DropdownButtonRow danger onClick={() => setDeleteVisible(true)}>
<FontAwesomeIcon fixedWidth icon={faTrashAlt} css={tw`text-xs`}/>
<span css={tw`ml-2`}>Delete</span>
</DropdownButtonRow>
</Can>
</div>
</DropdownMenu>
:
<button
onClick={() => setDeleteVisible(true)}
css={tw`text-neutral-200 transition-colors duration-150 hover:text-neutral-100 p-2`}
>
<FontAwesomeIcon icon={faTrashAlt}/>
</button>
}
</>
);
};

View file

@ -1,5 +1,4 @@
import React from 'react';
import { ServerBackup } from '@/api/server/backups/getServerBackups';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faArchive, faEllipsisH } from '@fortawesome/free-solid-svg-icons';
import { format, formatDistanceToNow } from 'date-fns';
@ -7,10 +6,11 @@ import Spinner from '@/components/elements/Spinner';
import { bytesToHuman } from '@/helpers';
import Can from '@/components/elements/Can';
import useWebsocketEvent from '@/plugins/useWebsocketEvent';
import { ServerContext } from '@/state/server';
import BackupContextMenu from '@/components/server/backups/BackupContextMenu';
import tw from 'twin.macro';
import GreyRowBox from '@/components/elements/GreyRowBox';
import getServerBackups from '@/api/swr/getServerBackups';
import { ServerBackup } from '@/api/server/types';
interface Props {
backup: ServerBackup;
@ -18,17 +18,22 @@ interface Props {
}
export default ({ backup, className }: Props) => {
const appendBackup = ServerContext.useStoreActions(actions => actions.backups.appendBackup);
const { mutate } = getServerBackups();
useWebsocketEvent(`backup completed:${backup.uuid}`, data => {
try {
const parsed = JSON.parse(data);
appendBackup({
...backup,
sha256Hash: parsed.sha256_hash || '',
bytes: parsed.file_size || 0,
completedAt: new Date(),
});
mutate(data => ({
...data,
items: data.items.map(b => b.uuid !== backup.uuid ? b : ({
...b,
isSuccessful: parsed.is_successful || true,
sha256Hash: parsed.sha256_hash || '',
bytes: parsed.file_size || 0,
completedAt: new Date(),
})),
}), false);
} catch (e) {
console.warn(e);
}
@ -45,8 +50,13 @@ export default ({ backup, className }: Props) => {
</div>
<div css={tw`flex-1`}>
<p css={tw`text-sm mb-1`}>
{!backup.isSuccessful &&
<span css={tw`bg-red-500 py-px px-2 rounded-full text-white text-xs uppercase border border-red-600 mr-2`}>
Failed
</span>
}
{backup.name}
{backup.completedAt &&
{(backup.completedAt && backup.isSuccessful) &&
<span css={tw`ml-3 text-neutral-300 text-xs font-thin`}>{bytesToHuman(backup.bytes)}</span>
}
</p>

View file

@ -7,12 +7,11 @@ 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 { ServerContext } from '@/state/server';
import Button from '@/components/elements/Button';
import tw from 'twin.macro';
import { Textarea } from '@/components/elements/Input';
import getServerBackups from '@/api/swr/getServerBackups';
interface Values {
name: string;
@ -49,7 +48,7 @@ const ModalContent = ({ ...props }: RequiredModalProps) => {
</FormikFieldWrapper>
</div>
<div css={tw`flex justify-end`}>
<Button type={'submit'}>
<Button type={'submit'} disabled={isSubmitting}>
Start backup
</Button>
</div>
@ -60,10 +59,9 @@ const ModalContent = ({ ...props }: RequiredModalProps) => {
export default () => {
const { uuid } = useServer();
const { addError, clearFlashes } = useFlash();
const { clearFlashes, clearAndAddHttpError } = useFlash();
const [ visible, setVisible ] = useState(false);
const appendBackup = ServerContext.useStoreActions(actions => actions.backups.appendBackup);
const { mutate } = getServerBackups();
useEffect(() => {
clearFlashes('backups:create');
@ -73,12 +71,11 @@ export default () => {
clearFlashes('backups:create');
createServerBackup(uuid, name, ignored)
.then(backup => {
appendBackup(backup);
mutate(data => ({ ...data, items: data.items.concat(backup) }), false);
setVisible(false);
})
.catch(error => {
console.error(error);
addError({ key: 'backups:create', message: httpErrorToHuman(error) });
clearAndAddHttpError({ key: 'backups:create', error });
setSubmitting(false);
});
};
@ -94,11 +91,7 @@ export default () => {
ignored: string(),
})}
>
<ModalContent
appear
visible={visible}
onDismissed={() => setVisible(false)}
/>
<ModalContent appear visible={visible} onDismissed={() => setVisible(false)}/>
</Formik>
}
<Button onClick={() => setVisible(true)}>

View file

@ -1,4 +1,5 @@
import React, { useEffect, useState } from 'react';
import { Helmet } from 'react-helmet';
import getServerDatabases from '@/api/server/getServerDatabases';
import { ServerContext } from '@/state/server';
import { httpErrorToHuman } from '@/api/http';
@ -14,7 +15,7 @@ import tw from 'twin.macro';
import Fade from '@/components/elements/Fade';
export default () => {
const { uuid, featureLimits } = useServer();
const { uuid, featureLimits, name: serverName } = useServer();
const { addError, clearFlashes } = useFlash();
const [ loading, setLoading ] = useState(true);
@ -36,6 +37,9 @@ export default () => {
return (
<PageContentBlock>
<Helmet>
<title> {serverName} | Databases </title>
</Helmet>
<FlashMessageRender byKey={'databases'} css={tw`mb-4`}/>
{(!databases.length && loading) ?
<Spinner size={'large'} centered/>

View file

@ -0,0 +1,10 @@
export enum SocketEvent {
DAEMON_MESSAGE = 'daemon message',
INSTALL_OUTPUT = 'install output',
INSTALL_STARTED = 'install started',
INSTALL_COMPLETED = 'install completed',
CONSOLE_OUTPUT = 'console output',
STATUS = 'status',
STATS = 'stats',
BACKUP_COMPLETED = 'backup completed',
}

View file

@ -1,6 +1,7 @@
import React, { useRef, useState } from 'react';
import React, { memo, useRef, useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faBoxOpen,
faCopy,
faEllipsisH,
faFileArchive,
@ -27,6 +28,8 @@ import DropdownMenu from '@/components/elements/DropdownMenu';
import styled from 'styled-components/macro';
import useEventListener from '@/plugins/useEventListener';
import compressFiles from '@/api/server/files/compressFiles';
import decompressFiles from '@/api/server/files/decompressFiles';
import isEqual from 'react-fast-compare';
type ModalType = 'rename' | 'move';
@ -43,12 +46,12 @@ interface RowProps extends React.HTMLAttributes<HTMLDivElement> {
const Row = ({ icon, title, ...props }: RowProps) => (
<StyledRow {...props}>
<FontAwesomeIcon icon={icon} css={tw`text-xs`}/>
<FontAwesomeIcon icon={icon} css={tw`text-xs`} fixedWidth/>
<span css={tw`ml-2`}>{title}</span>
</StyledRow>
);
export default ({ file }: { file: FileObject }) => {
const FileDropdownMenu = ({ file }: { file: FileObject }) => {
const onClickRef = useRef<DropdownMenu>(null);
const [ showSpinner, setShowSpinner ] = useState(false);
const [ modal, setModal ] = useState<ModalType | null>(null);
@ -58,7 +61,7 @@ export default ({ file }: { file: FileObject }) => {
const { clearAndAddHttpError, clearFlashes } = useFlash();
const directory = ServerContext.useStoreState(state => state.files.directory);
useEventListener(`pterodactyl:files:ctx:${file.uuid}`, (e: CustomEvent) => {
useEventListener(`pterodactyl:files:ctx:${file.key}`, (e: CustomEvent) => {
if (onClickRef.current) {
onClickRef.current.triggerMenu(e.detail);
}
@ -69,7 +72,7 @@ export default ({ file }: { file: FileObject }) => {
// For UI speed, immediately remove the file from the listing before calling the deletion function.
// If the delete actually fails, we'll fetch the current directory contents again automatically.
mutate(files => files.filter(f => f.uuid !== file.uuid), false);
mutate(files => files.filter(f => f.key !== file.key), false);
deleteFiles(uuid, directory, [ file.name ]).catch(error => {
mutate();
@ -110,6 +113,16 @@ export default ({ file }: { file: FileObject }) => {
.then(() => setShowSpinner(false));
};
const doUnarchive = () => {
setShowSpinner(true);
clearFlashes('files');
decompressFiles(uuid, directory, file.name)
.then(() => mutate())
.catch(error => clearAndAddHttpError({ key: 'files', error }))
.then(() => setShowSpinner(false));
};
return (
<DropdownMenu
ref={onClickRef}
@ -138,9 +151,15 @@ export default ({ file }: { file: FileObject }) => {
<Row onClick={doCopy} icon={faCopy} title={'Copy'}/>
</Can>
}
<Can action={'file.archive'}>
<Row onClick={doArchive} icon={faFileArchive} title={'Archive'}/>
</Can>
{file.isArchiveType() ?
<Can action={'file.create'}>
<Row onClick={doUnarchive} icon={faBoxOpen} title={'Unarchive'}/>
</Can>
:
<Can action={'file.archive'}>
<Row onClick={doArchive} icon={faFileArchive} title={'Archive'}/>
</Can>
}
<Row onClick={doDownload} icon={faFileDownload} title={'Download'}/>
<Can action={'file.delete'}>
<Row onClick={doDeletion} icon={faTrashAlt} title={'Delete'} $danger/>
@ -148,3 +167,5 @@ export default ({ file }: { file: FileObject }) => {
</DropdownMenu>
);
};
export default memo(FileDropdownMenu, isEqual);

View file

@ -1,8 +1,5 @@
import React, { lazy, useEffect, useState } from 'react';
import { ServerContext } from '@/state/server';
import getFileContents from '@/api/server/files/getFileContents';
import { Actions, useStoreActions } from 'easy-peasy';
import { ApplicationStore } from '@/state';
import { httpErrorToHuman } from '@/api/http';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import saveFileContents from '@/api/server/files/saveFileContents';
@ -15,6 +12,10 @@ import PageContentBlock from '@/components/elements/PageContentBlock';
import ServerError from '@/components/screens/ServerError';
import tw from 'twin.macro';
import Button from '@/components/elements/Button';
import Select from '@/components/elements/Select';
import modes from '@/modes';
import useServer from '@/plugins/useServer';
import useFlash from '@/plugins/useFlash';
const LazyAceEditor = lazy(() => import(/* webpackChunkName: "editor" */'@/components/elements/AceEditor'));
@ -24,12 +25,13 @@ export default () => {
const [ loading, setLoading ] = useState(action === 'edit');
const [ content, setContent ] = useState('');
const [ modalVisible, setModalVisible ] = useState(false);
const [ mode, setMode ] = useState('plain_text');
const history = useHistory();
const { hash } = useLocation();
const { id, uuid } = ServerContext.useStoreState(state => state.server.data!);
const { addError, clearFlashes } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
const { id, uuid } = useServer();
const { addError, clearFlashes } = useFlash();
let fetchFileContent: null | (() => Promise<string>) = null;
@ -75,10 +77,7 @@ export default () => {
if (error) {
return (
<ServerError
message={error}
onBack={() => history.goBack()}
/>
<ServerError message={error} onBack={() => history.goBack()}/>
);
}
@ -109,15 +108,24 @@ export default () => {
<div css={tw`relative`}>
<SpinnerOverlay visible={loading}/>
<LazyAceEditor
initialModePath={hash.replace(/^#/, '') || 'plain_text'}
mode={mode}
filename={hash.replace(/^#/, '')}
onModeChanged={setMode}
initialContent={content}
fetchContent={value => {
fetchFileContent = value;
}}
onContentSaved={() => save()}
onContentSaved={save}
/>
</div>
<div css={tw`flex justify-end mt-4`}>
<div css={tw`rounded bg-neutral-900 mr-4`}>
<Select value={mode} onChange={e => setMode(e.currentTarget.value)}>
{Object.keys(modes).map(key => (
<option key={key} value={key}>{modes[key]}</option>
))}
</Select>
</div>
{action === 'edit' ?
<Can action={'file.update'}>
<Button onClick={() => save()}>

View file

@ -1,4 +1,5 @@
import React, { useEffect } from 'react';
import { Helmet } from 'react-helmet';
import { httpErrorToHuman } from '@/api/http';
import { CSSTransition } from 'react-transition-group';
import Spinner from '@/components/elements/Spinner';
@ -24,17 +25,14 @@ const sortFiles = (files: FileObject[]): FileObject[] => {
};
export default () => {
const { id } = useServer();
const { id, name: serverName } = useServer();
const { hash } = useLocation();
const { data: files, error, mutate } = useFileManagerSwr();
const setDirectory = ServerContext.useStoreActions(actions => actions.files.setDirectory);
const setSelectedFiles = ServerContext.useStoreActions(actions => actions.files.setSelectedFiles);
useEffect(() => {
// We won't automatically mutate the store when the component re-mounts, otherwise because of
// my (horrible) programming this fires off way more than we intend it to.
mutate();
setSelectedFiles([]);
setDirectory(hash.length > 0 ? hash : '/');
}, [ hash ]);
@ -47,6 +45,9 @@ export default () => {
return (
<PageContentBlock showFlashKey={'files'}>
<Helmet>
<title> {serverName} | File Manager </title>
</Helmet>
<FileManagerBreadcrumbs/>
{
!files ?
@ -70,7 +71,7 @@ export default () => {
}
{
sortFiles(files.slice(0, 250)).map(file => (
<FileObjectRow key={file.uuid} file={file}/>
<FileObjectRow key={file.key} file={file}/>
))
}
<MassActionsBar/>

View file

@ -1,5 +1,5 @@
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faFileAlt, faFileImport, faFolder } from '@fortawesome/free-solid-svg-icons';
import { faFileAlt, faFileArchive, faFileImport, faFolder } from '@fortawesome/free-solid-svg-icons';
import { bytesToHuman, cleanDirectoryPath } from '@/helpers';
import { differenceInHours, format, formatDistanceToNow } from 'date-fns';
import React, { memo } from 'react';
@ -18,7 +18,6 @@ const Row = styled.div`
const FileObjectRow = ({ file }: { file: FileObject }) => {
const directory = ServerContext.useStoreState(state => state.files.directory);
const setDirectory = ServerContext.useStoreActions(actions => actions.files.setDirectory);
const history = useHistory();
const match = useRouteMatch();
@ -31,9 +30,7 @@ const FileObjectRow = ({ file }: { file: FileObject }) => {
// Just trust me future me, leave this be.
if (!file.isFile) {
e.preventDefault();
history.push(`#${cleanDirectoryPath(`${directory}/${file.name}`)}`);
setDirectory(`${directory}/${file.name}`);
}
};
@ -42,7 +39,7 @@ const FileObjectRow = ({ file }: { file: FileObject }) => {
key={file.name}
onContextMenu={e => {
e.preventDefault();
window.dispatchEvent(new CustomEvent(`pterodactyl:files:ctx:${file.uuid}`, { detail: e.clientX }));
window.dispatchEvent(new CustomEvent(`pterodactyl:files:ctx:${file.key}`, { detail: e.clientX }));
}}
>
<SelectFileCheckbox name={file.name}/>
@ -53,7 +50,7 @@ const FileObjectRow = ({ file }: { file: FileObject }) => {
>
<div css={tw`flex-none self-center text-neutral-400 mr-4 text-lg pl-3 ml-6`}>
{file.isFile ?
<FontAwesomeIcon icon={file.isSymlink ? faFileImport : faFileAlt}/>
<FontAwesomeIcon icon={file.isSymlink ? faFileImport : file.isArchiveType() ? faFileArchive : faFileAlt}/>
:
<FontAwesomeIcon icon={faFolder}/>
}

View file

@ -72,7 +72,7 @@ const MassActionsBar = () => {
title={'Delete these files?'}
buttonText={'Yes, Delete Files'}
onConfirmed={onClickConfirmDeletion}
onDismissed={() => setShowConfirm(false)}
onModalDismissed={() => setShowConfirm(false)}
>
Deleting files is a permanent operation, you cannot undo this action.
</ConfirmationModal>

View file

@ -6,14 +6,12 @@ import Field from '@/components/elements/Field';
import { join } from 'path';
import { object, string } from 'yup';
import createDirectory from '@/api/server/files/createDirectory';
import v4 from 'uuid/v4';
import tw from 'twin.macro';
import Button from '@/components/elements/Button';
import { mutate } from 'swr';
import useServer from '@/plugins/useServer';
import { FileObject } from '@/api/server/files/loadDirectory';
import { useLocation } from 'react-router';
import useFlash from '@/plugins/useFlash';
import useFileManagerSwr from '@/plugins/useFileManagerSwr';
interface Values {
directoryName: string;
@ -24,7 +22,7 @@ const schema = object().shape({
});
const generateDirectoryData = (name: string): FileObject => ({
uuid: v4(),
key: `dir_${name}`,
name: name,
mode: '0644',
size: 0,
@ -34,24 +32,21 @@ const generateDirectoryData = (name: string): FileObject => ({
mimetype: '',
createdAt: new Date(),
modifiedAt: new Date(),
isArchiveType: () => false,
});
export default () => {
const { uuid } = useServer();
const { hash } = useLocation();
const { clearAndAddHttpError } = useFlash();
const [ visible, setVisible ] = useState(false);
const { mutate } = useFileManagerSwr();
const directory = ServerContext.useStoreState(state => state.files.directory);
const submit = ({ directoryName }: Values, { setSubmitting }: FormikHelpers<Values>) => {
createDirectory(uuid, directory, directoryName)
.then(() => {
mutate(
`${uuid}:files:${hash}`,
(data: FileObject[]) => [ ...data, generateDirectoryData(directoryName) ],
);
setVisible(false);
})
.then(() => mutate(data => [ ...data, generateDirectoryData(directoryName) ], false))
.then(() => setVisible(false))
.catch(error => {
console.error(error);
setSubmitting(false);
@ -78,6 +73,7 @@ export default () => {
>
<Form css={tw`m-0`}>
<Field
autoFocus
id={'directoryName'}
name={'directoryName'}
label={'Directory Name'}

View file

@ -15,9 +15,9 @@ interface FormikValues {
name: string;
}
type Props = RequiredModalProps & { files: string[]; useMoveTerminology?: boolean };
type OwnProps = RequiredModalProps & { files: string[]; useMoveTerminology?: boolean };
export default ({ files, useMoveTerminology, ...props }: Props) => {
const RenameFileModal = ({ files, useMoveTerminology, ...props }: OwnProps) => {
const { uuid } = useServer();
const { mutate } = useFileManagerSwr();
const { clearFlashes, clearAndAddHttpError } = useFlash();
@ -96,3 +96,5 @@ export default ({ files, useMoveTerminology, ...props }: Props) => {
</Formik>
);
};
export default RenameFileModal;

View file

@ -1,4 +1,5 @@
import React, { useEffect, useState } from 'react';
import { Helmet } from 'react-helmet';
import tw from 'twin.macro';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faNetworkWired } from '@fortawesome/free-solid-svg-icons';
@ -23,7 +24,7 @@ const Code = styled.code`${tw`font-mono py-1 px-2 bg-neutral-900 rounded text-sm
const Label = styled.label`${tw`uppercase text-xs mt-1 text-neutral-400 block px-1 select-none transition-colors duration-150`}`;
const NetworkContainer = () => {
const { uuid, allocations } = useServer();
const { uuid, allocations, name: serverName } = useServer();
const { clearFlashes, clearAndAddHttpError } = useFlash();
const [ loading, setLoading ] = useState<false | number>(false);
const { data, error, mutate } = useSWR<Allocation[]>(uuid, key => getServerAllocations(key), { initialData: allocations });
@ -61,6 +62,9 @@ const NetworkContainer = () => {
return (
<PageContentBlock showFlashKey={'server:network'}>
<Helmet>
<title> {serverName} | Network </title>
</Helmet>
{!data ?
<Spinner size={'large'} centered/>
:

View file

@ -39,12 +39,12 @@ export default ({ scheduleId, onDeleted }: Props) => {
return (
<>
<ConfirmationModal
showSpinnerOverlay={isLoading}
visible={visible}
title={'Delete schedule?'}
buttonText={'Yes, delete schedule'}
onConfirmed={onDelete}
visible={visible}
onDismissed={() => setVisible(false)}
showSpinnerOverlay={isLoading}
onModalDismissed={() => setVisible(false)}
>
Are you sure you want to delete this schedule? All tasks will be removed and any running processes
will be terminated.

View file

@ -65,7 +65,7 @@ const EditScheduleModal = ({ schedule, ...props }: Omit<Props, 'onScheduleUpdate
/>
</div>
<div css={tw`mt-6 text-right`}>
<Button type={'submit'}>
<Button type={'submit'} disabled={isSubmitting}>
{schedule ? 'Save changes' : 'Create schedule'}
</Button>
</div>

View file

@ -1,4 +1,5 @@
import React, { useEffect, useState } from 'react';
import { Helmet } from 'react-helmet';
import getServerSchedules from '@/api/server/schedules/getServerSchedules';
import { ServerContext } from '@/state/server';
import Spinner from '@/components/elements/Spinner';
@ -16,7 +17,7 @@ import GreyRowBox from '@/components/elements/GreyRowBox';
import Button from '@/components/elements/Button';
export default ({ match, history }: RouteComponentProps) => {
const { uuid } = useServer();
const { uuid, name: serverName } = useServer();
const { clearFlashes, addError } = useFlash();
const [ loading, setLoading ] = useState(true);
const [ visible, setVisible ] = useState(false);
@ -37,6 +38,9 @@ export default ({ match, history }: RouteComponentProps) => {
return (
<PageContentBlock>
<Helmet>
<title> {serverName} | Schedules </title>
</Helmet>
<FlashMessageRender byKey={'schedules'} css={tw`mb-4`}/>
{(!schedules.length && loading) ?
<Spinner size={'large'} centered/>

View file

@ -14,7 +14,7 @@ export default ({ schedule }: { schedule: Schedule }) => (
<p>{schedule.name}</p>
<p css={tw`text-xs text-neutral-400`}>
Last run
at: {schedule.lastRunAt ? format(schedule.lastRunAt, 'MMM Do [at] h:mma') : 'never'}
at: {schedule.lastRunAt ? format(schedule.lastRunAt, 'MMM do \'at\' h:mma') : 'never'}
</p>
</div>
<div css={tw`flex items-center mx-8`}>

View file

@ -69,7 +69,7 @@ export default ({ schedule, task }: Props) => {
buttonText={'Delete Task'}
onConfirmed={onConfirmDeletion}
visible={visible}
onDismissed={() => setVisible(false)}
onModalDismissed={() => setVisible(false)}
>
Are you sure you want to delete this task? This action cannot be undone.
</ConfirmationModal>

View file

@ -32,11 +32,16 @@ interface Values {
}
const TaskDetailsForm = ({ isEditingTask }: { isEditingTask: boolean }) => {
const { values: { action }, setFieldValue, setFieldTouched } = useFormikContext<Values>();
const { values: { action }, initialValues, setFieldValue, setFieldTouched, isSubmitting } = useFormikContext<Values>();
useEffect(() => {
setFieldValue('payload', action === 'power' ? 'start' : '');
setFieldTouched('payload', false);
if (action !== initialValues.action) {
setFieldValue('payload', action === 'power' ? 'start' : '');
setFieldTouched('payload', false);
} else {
setFieldValue('payload', initialValues.payload);
setFieldTouched('payload', false);
}
}, [ action ]);
return (
@ -94,7 +99,7 @@ const TaskDetailsForm = ({ isEditingTask }: { isEditingTask: boolean }) => {
/>
</div>
<div css={tw`flex justify-end mt-6`}>
<Button type={'submit'}>
<Button type={'submit'} disabled={isSubmitting}>
{isEditingTask ? 'Save Changes' : 'Create Task'}
</Button>
</div>

View file

@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import { ServerContext } from '@/state/server';
import TitledGreyBox from '@/components/elements/TitledGreyBox';
import ConfirmationModal from '@/components/elements/ConfirmationModal';
@ -37,15 +37,19 @@ export default () => {
});
};
useEffect(() => {
clearFlashes();
}, []);
return (
<TitledGreyBox title={'Reinstall Server'} css={tw`relative`}>
<ConfirmationModal
title={'Confirm server reinstallation'}
buttonText={'Yes, reinstall server'}
onConfirmed={() => reinstall()}
onConfirmed={reinstall}
showSpinnerOverlay={isSubmitting}
visible={modalVisible}
onDismissed={() => setModalVisible(false)}
onModalDismissed={() => setModalVisible(false)}
>
Your server will be stopped and some files may be deleted or modified during this process, are you sure
you wish to continue?

View file

@ -1,4 +1,5 @@
import React from 'react';
import { Helmet } from 'react-helmet';
import TitledGreyBox from '@/components/elements/TitledGreyBox';
import { ServerContext } from '@/state/server';
import { useStoreState } from 'easy-peasy';
@ -20,6 +21,9 @@ export default () => {
return (
<PageContentBlock>
<Helmet>
<title> {server.name} | Settings </title>
</Helmet>
<FlashMessageRender byKey={'settings'} css={tw`mb-4`}/>
<div css={tw`md:flex`}>
<div css={tw`w-full md:flex-1 md:mr-10`}>

View file

@ -0,0 +1,27 @@
import React from 'react';
import PageContentBlock from '@/components/elements/PageContentBlock';
import TitledGreyBox from '@/components/elements/TitledGreyBox';
import useServer from '@/plugins/useServer';
import tw from 'twin.macro';
import VariableBox from '@/components/server/startup/VariableBox';
const StartupContainer = () => {
const { invocation, variables } = useServer();
return (
<PageContentBlock title={'Startup Settings'} showFlashKey={'server:startup'}>
<TitledGreyBox title={'Startup Command'}>
<div css={tw`px-1 py-2`}>
<p css={tw`font-mono bg-neutral-900 rounded py-2 px-4`}>
{invocation}
</p>
</div>
</TitledGreyBox>
<div css={tw`grid gap-8 grid-cols-2 mt-10`}>
{variables.map(variable => <VariableBox key={variable.envVariable} variable={variable}/>)}
</div>
</PageContentBlock>
);
};
export default StartupContainer;

View file

@ -0,0 +1,64 @@
import React, { useState } from 'react';
import { ServerEggVariable } from '@/api/server/types';
import TitledGreyBox from '@/components/elements/TitledGreyBox';
import { usePermissions } from '@/plugins/usePermissions';
import InputSpinner from '@/components/elements/InputSpinner';
import Input from '@/components/elements/Input';
import tw from 'twin.macro';
import { debounce } from 'debounce';
import updateStartupVariable from '@/api/server/updateStartupVariable';
import useServer from '@/plugins/useServer';
import { ServerContext } from '@/state/server';
import useFlash from '@/plugins/useFlash';
import FlashMessageRender from '@/components/FlashMessageRender';
interface Props {
variable: ServerEggVariable;
}
const VariableBox = ({ variable }: Props) => {
const FLASH_KEY = `server:startup:${variable.envVariable}`;
const server = useServer();
const [ loading, setLoading ] = useState(false);
const [ canEdit ] = usePermissions([ 'startup.update' ]);
const { clearFlashes, clearAndAddHttpError } = useFlash();
const setServer = ServerContext.useStoreActions(actions => actions.server.setServer);
const setVariableValue = debounce((value: string) => {
setLoading(true);
clearFlashes(FLASH_KEY);
updateStartupVariable(server.uuid, variable.envVariable, value)
.then(response => setServer({
...server,
variables: server.variables.map(v => v.envVariable === response.envVariable ? response : v),
}))
.catch(error => {
console.error(error);
clearAndAddHttpError({ error, key: FLASH_KEY });
})
.then(() => setLoading(false));
}, 500);
return (
<TitledGreyBox title={variable.name}>
<FlashMessageRender byKey={FLASH_KEY} css={tw`mb-4`}/>
<InputSpinner visible={loading}>
<Input
onKeyUp={e => setVariableValue(e.currentTarget.value)}
readOnly={!canEdit}
name={variable.envVariable}
defaultValue={variable.serverValue}
placeholder={variable.defaultValue}
/>
</InputSpinner>
<p css={tw`mt-1 text-xs text-neutral-400`}>
{variable.description}
</p>
</TitledGreyBox>
);
};
export default VariableBox;

View file

@ -35,19 +35,17 @@ export default ({ subuser }: { subuser: Subuser }) => {
return (
<>
{showConfirmation &&
<ConfirmationModal
title={'Delete this subuser?'}
buttonText={'Yes, remove subuser'}
visible
visible={showConfirmation}
showSpinnerOverlay={loading}
onConfirmed={() => doDeletion()}
onDismissed={() => setShowConfirmation(false)}
onModalDismissed={() => setShowConfirmation(false)}
>
Are you sure you wish to remove this subuser? They will have all access to this server revoked
immediately.
</ConfirmationModal>
}
<button
type={'button'}
aria-label={'Delete subuser'}

View file

@ -51,17 +51,18 @@ export default ({ subuser }: Props) => {
</p>
<p css={tw`text-2xs text-neutral-500 uppercase`}>Permissions</p>
</div>
<button
type={'button'}
aria-label={'Edit subuser'}
css={[
tw`block text-sm p-2 text-neutral-500 hover:text-neutral-100 transition-colors duration-150 mx-4`,
subuser.uuid === uuid ? tw`hidden` : undefined,
]}
onClick={() => setVisible(true)}
>
<FontAwesomeIcon icon={faPencilAlt}/>
</button>
<Can action={'user.update'}>
{subuser.uuid !== uuid &&
<button
type={'button'}
aria-label={'Edit subuser'}
css={tw`block text-sm p-2 text-neutral-500 hover:text-neutral-100 transition-colors duration-150 mx-4`}
onClick={() => setVisible(true)}
>
<FontAwesomeIcon icon={faPencilAlt}/>
</button>
}
</Can>
<Can action={'user.delete'}>
<RemoveSubuserButton subuser={subuser}/>
</Can>

View file

@ -1,4 +1,5 @@
import React, { useEffect, useState } from 'react';
import { Helmet } from 'react-helmet';
import { ServerContext } from '@/state/server';
import { Actions, useStoreActions, useStoreState } from 'easy-peasy';
import { ApplicationStore } from '@/state';
@ -17,6 +18,7 @@ export default () => {
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
const subusers = ServerContext.useStoreState(state => state.subusers.data);
const servername = ServerContext.useStoreState(state => state.server.data!.name);
const setSubusers = ServerContext.useStoreActions(actions => actions.subusers.setSubusers);
const permissions = useStoreState((state: ApplicationStore) => state.permissions.data);
@ -49,6 +51,9 @@ export default () => {
return (
<PageContentBlock>
<Helmet>
<title> {servername} | Subusers </title>
</Helmet>
<FlashMessageRender byKey={'users'} css={tw`mb-4`}/>
{!subusers.length ?
<p css={tw`text-center text-sm text-neutral-400`}>