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

@ -39,6 +39,8 @@ rules:
comma-dangle:
- warn
- always-multiline
spaced-comment:
- warn
array-bracket-spacing:
- warn
- always

View file

@ -1,9 +1,10 @@
import React from 'react';
import React, { useRef } from 'react';
import { Route } from 'react-router';
import { SwitchTransition } from 'react-transition-group';
import Fade from '@/components/elements/Fade';
import styled from 'styled-components/macro';
import tw from 'twin.macro';
import v4 from 'uuid/v4';
const StyledSwitchTransition = styled(SwitchTransition)`
${tw`relative`};
@ -13,18 +14,22 @@ const StyledSwitchTransition = styled(SwitchTransition)`
}
`;
const TransitionRouter: React.FC = ({ children }) => (
<Route
render={({ location }) => (
<StyledSwitchTransition>
<Fade timeout={150} key={location.key} in appear unmountOnExit>
<section>
{children}
</section>
</Fade>
</StyledSwitchTransition>
)}
/>
);
const TransitionRouter: React.FC = ({ children }) => {
const uuid = useRef(v4()).current;
return (
<Route
render={({ location }) => (
<StyledSwitchTransition>
<Fade timeout={150} key={location.key || uuid} in appear unmountOnExit>
<section>
{children}
</section>
</Fade>
</StyledSwitchTransition>
)}
/>
);
};
export default TransitionRouter;

View file

@ -1,8 +1,8 @@
import http from '@/api/http';
export default (email: string): Promise<string> => {
export default (email: string, recaptchaData?: string): Promise<string> => {
return new Promise((resolve, reject) => {
http.post('/auth/password', { email })
http.post('/auth/password', { email, 'g-recaptcha-response': recaptchaData })
.then(response => resolve(response.data.status || ''))
.catch(reject);
});

View file

@ -1,13 +1,19 @@
import { rawDataToServerObject, Server } from '@/api/server/getServer';
import http, { getPaginationSet, PaginatedResult } from '@/api/http';
export default (query?: string, includeAdmin?: boolean): Promise<PaginatedResult<Server>> => {
interface QueryParams {
query?: string;
page?: number;
onlyAdmin?: boolean;
}
export default ({ query, page = 1, onlyAdmin = false }: QueryParams): Promise<PaginatedResult<Server>> => {
return new Promise((resolve, reject) => {
http.get('/api/client', {
params: {
include: [ 'allocation' ],
type: includeAdmin ? 'all' : undefined,
type: onlyAdmin ? 'admin' : undefined,
'filter[name]': query,
page,
},
})
.then(({ data }) => resolve({

View file

@ -1,5 +1,6 @@
import { rawDataToServerBackup, ServerBackup } from '@/api/server/backups/getServerBackups';
import http from '@/api/http';
import { ServerBackup } from '@/api/server/types';
import { rawDataToServerBackup } from '@/api/transformers';
export default (uuid: string, name?: string, ignored?: string): Promise<ServerBackup> => {
return new Promise((resolve, reject) => {

View file

@ -1,32 +0,0 @@
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

@ -4,8 +4,8 @@ import { rawDataToFileObject } from '@/api/transformers';
export default async (uuid: string, directory: string, files: string[]): Promise<FileObject> => {
const { data } = await http.post(`/api/client/servers/${uuid}/files/compress`, { root: directory, files }, {
timeout: 300000,
timeoutErrorMessage: 'It looks like this archive is taking a long time to generate. It will appear when completed.',
timeout: 60000,
timeoutErrorMessage: 'It looks like this archive is taking a long time to generate. It will appear once completed.',
});
return rawDataToFileObject(data);

View file

@ -0,0 +1,8 @@
import http from '@/api/http';
export default async (uuid: string, directory: string, file: string): Promise<void> => {
await http.post(`/api/client/servers/${uuid}/files/decompress`, { root: directory, file }, {
timeout: 300000,
timeoutErrorMessage: 'It looks like this archive is taking a long time to be unarchived. Once completed the unarchived files will appear.',
});
};

View file

@ -2,7 +2,7 @@ import http from '@/api/http';
import { rawDataToFileObject } from '@/api/transformers';
export interface FileObject {
uuid: string;
key: string;
name: string;
mode: string;
size: number;
@ -12,6 +12,7 @@ export interface FileObject {
mimetype: string;
createdAt: Date;
modifiedAt: Date;
isArchiveType: () => boolean;
}
export default async (uuid: string, directory?: string): Promise<FileObject[]> => {

View file

@ -1,5 +1,6 @@
import http, { FractalResponseData, FractalResponseList } from '@/api/http';
import { rawDataToServerAllocation } from '@/api/transformers';
import { rawDataToServerAllocation, rawDataToServerEggVariable } from '@/api/transformers';
import { ServerEggVariable } from '@/api/server/types';
export interface Allocation {
id: number;
@ -19,8 +20,8 @@ export interface Server {
ip: string;
port: number;
};
invocation: string;
description: string;
allocations: Allocation[];
limits: {
memory: number;
swap: number;
@ -36,6 +37,8 @@ export interface Server {
};
isSuspended: boolean;
isInstalling: boolean;
variables: ServerEggVariable[];
allocations: Allocation[];
}
export const rawDataToServerObject = ({ attributes: data }: FractalResponseData): Server => ({
@ -43,6 +46,7 @@ export const rawDataToServerObject = ({ attributes: data }: FractalResponseData)
uuid: data.uuid,
name: data.name,
node: data.node,
invocation: data.invocation,
sftpDetails: {
ip: data.sftp_details.ip,
port: data.sftp_details.port,
@ -52,6 +56,7 @@ export const rawDataToServerObject = ({ attributes: data }: FractalResponseData)
featureLimits: { ...data.feature_limits },
isSuspended: data.is_suspended,
isInstalling: data.is_installing,
variables: ((data.relationships?.variables as FractalResponseList | undefined)?.data || []).map(rawDataToServerEggVariable),
allocations: ((data.relationships?.allocations as FractalResponseList | undefined)?.data || []).map(rawDataToServerAllocation),
});

20
resources/scripts/api/server/types.d.ts vendored Normal file
View file

@ -0,0 +1,20 @@
export interface ServerBackup {
uuid: string;
isSuccessful: boolean;
name: string;
ignoredFiles: string;
sha256Hash: string;
bytes: number;
createdAt: Date;
completedAt: Date | null;
}
export interface ServerEggVariable {
name: string;
description: string;
envVariable: string;
defaultValue: string;
serverValue: string;
isEditable: boolean;
rules: string[];
}

View file

@ -0,0 +1,9 @@
import http from '@/api/http';
import { ServerEggVariable } from '@/api/server/types';
import { rawDataToServerEggVariable } from '@/api/transformers';
export default async (uuid: string, key: string, value: string): Promise<ServerEggVariable> => {
const { data } = await http.put(`/api/client/servers/${uuid}/startup/variable`, { key, value });
return rawDataToServerEggVariable(data);
};

View file

@ -0,0 +1,18 @@
import useSWR from 'swr';
import http, { getPaginationSet, PaginatedResult } from '@/api/http';
import { ServerBackup } from '@/api/server/types';
import { rawDataToServerBackup } from '@/api/transformers';
import useServer from '@/plugins/useServer';
export default (page?: number | string) => {
const { uuid } = useServer();
return useSWR<PaginatedResult<ServerBackup>>([ 'server:backups', uuid, page ], async () => {
const { data } = await http.get(`/api/client/servers/${uuid}/backups`, { params: { page } });
return ({
items: (data.data || []).map(rawDataToServerBackup),
pagination: getPaginationSet(data.meta.pagination),
});
});
};

View file

@ -1,7 +1,7 @@
import { Allocation } from '@/api/server/getServer';
import { FractalResponseData } from '@/api/http';
import { FileObject } from '@/api/server/files/loadDirectory';
import v4 from 'uuid/v4';
import { ServerBackup, ServerEggVariable } from '@/api/server/types';
export const rawDataToServerAllocation = (data: FractalResponseData): Allocation => ({
id: data.attributes.id,
@ -13,7 +13,7 @@ export const rawDataToServerAllocation = (data: FractalResponseData): Allocation
});
export const rawDataToFileObject = (data: FractalResponseData): FileObject => ({
uuid: v4(),
key: `${data.attributes.is_file ? 'file' : 'dir'}_${data.attributes.name}`,
name: data.attributes.name,
mode: data.attributes.mode,
size: Number(data.attributes.size),
@ -23,4 +23,41 @@ export const rawDataToFileObject = (data: FractalResponseData): FileObject => ({
mimetype: data.attributes.mimetype,
createdAt: new Date(data.attributes.created_at),
modifiedAt: new Date(data.attributes.modified_at),
isArchiveType: function () {
return this.isFile && [
'application/vnd.rar', // .rar
'application/x-rar-compressed', // .rar (2)
'application/x-tar', // .tar
'application/x-br', // .tar.br
'application/x-bzip2', // .tar.bz2, .bz2
'application/gzip', // .tar.gz, .gz
'application/x-lzip', // .tar.lz4, .lz4 (not sure if this mime type is correct)
'application/x-sz', // .tar.sz, .sz (not sure if this mime type is correct)
'application/x-xz', // .tar.xz, .xz
'application/zstd', // .tar.zst, .zst
'application/zip', // .zip
].indexOf(this.mimetype) >= 0;
},
});
export const rawDataToServerBackup = ({ attributes }: FractalResponseData): ServerBackup => ({
uuid: attributes.uuid,
isSuccessful: attributes.is_successful,
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 const rawDataToServerEggVariable = ({ attributes }: FractalResponseData): ServerEggVariable => ({
name: attributes.name,
description: attributes.description,
envVariable: attributes.env_variable,
defaultValue: attributes.default_value,
serverValue: attributes.server_value,
isEditable: attributes.is_editable,
rules: attributes.rules.split('|'),
});

View file

@ -6,19 +6,19 @@ export default createGlobalStyle`
${tw`font-sans bg-neutral-800 text-neutral-200`};
letter-spacing: 0.015em;
}
h1, h2, h3, h4, h5, h6 {
${tw`font-medium tracking-normal font-header`};
}
p {
${tw`text-neutral-200 leading-snug font-sans`};
}
form {
${tw`m-0`};
}
textarea, select, input, button, button:focus, button:focus-visible {
${tw`outline-none`};
}
@ -32,4 +32,41 @@ export default createGlobalStyle`
input[type=number] {
-moz-appearance: textfield !important;
}
/* Scroll Bar Style */
::-webkit-scrollbar {
background: none;
width: 16px;
height: 16px;
}
::-webkit-scrollbar-thumb {
border: solid 0 rgb(0 0 0 / 0%);
border-right-width: 4px;
border-left-width: 4px;
-webkit-border-radius: 9px 4px;
-webkit-box-shadow: inset 0 0 0 1px hsl(211, 10%, 53%), inset 0 0 0 4px hsl(209deg 18% 30%);
}
::-webkit-scrollbar-track-piece {
margin: 4px 0;
}
::-webkit-scrollbar-thumb:horizontal {
border-right-width: 0;
border-left-width: 0;
border-top-width: 4px;
border-bottom-width: 4px;
-webkit-border-radius: 4px 9px;
}
::-webkit-scrollbar-thumb:hover {
-webkit-box-shadow:
inset 0 0 0 1px hsl(212, 92%, 43%),
inset 0 0 0 4px hsl(212, 92%, 43%);
}
::-webkit-scrollbar-corner {
background: transparent;
}
`;

View file

@ -1,4 +1,5 @@
import * as React from 'react';
import React, { useEffect } from 'react';
import ReactGA from 'react-ga';
import { hot } from 'react-hot-loader/root';
import { BrowserRouter, Route, Switch } from 'react-router-dom';
import { StoreProvider } from 'easy-peasy';
@ -48,6 +49,11 @@ const App = () => {
store.getActions().settings.setSettings(SiteConfiguration!);
}
useEffect(() => {
ReactGA.initialize(SiteConfiguration!.analytics);
ReactGA.pageview(location.pathname);
}, []);
return (
<>
<GlobalStylesheet/>

View file

@ -1,27 +1,40 @@
import * as React from 'react';
import { useRef, useState } from 'react';
import { Link } from 'react-router-dom';
import requestPasswordResetEmail from '@/api/auth/requestPasswordResetEmail';
import { httpErrorToHuman } from '@/api/http';
import LoginFormContainer from '@/components/auth/LoginFormContainer';
import { Actions, useStoreActions } from 'easy-peasy';
import { ApplicationStore } from '@/state';
import { useStoreState } from 'easy-peasy';
import Field from '@/components/elements/Field';
import { Formik, FormikHelpers } from 'formik';
import { object, string } from 'yup';
import tw from 'twin.macro';
import Button from '@/components/elements/Button';
import Reaptcha from 'reaptcha';
import useFlash from '@/plugins/useFlash';
interface Values {
email: string;
}
export default () => {
const { clearFlashes, addFlash } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
const ref = useRef<Reaptcha>(null);
const [ token, setToken ] = useState('');
const { clearFlashes, addFlash } = useFlash();
const { enabled: recaptchaEnabled, siteKey } = useStoreState(state => state.settings.data!.recaptcha);
const handleSubmission = ({ email }: Values, { setSubmitting, resetForm }: FormikHelpers<Values>) => {
setSubmitting(true);
clearFlashes();
requestPasswordResetEmail(email)
// If there is no token in the state yet, request the token and then abort this submit request
// since it will be re-submitted when the recaptcha data is returned by the component.
if (recaptchaEnabled && !token) {
ref.current!.execute().catch(error => console.error(error));
return;
}
requestPasswordResetEmail(email, token)
.then(response => {
resetForm();
addFlash({ type: 'success', title: 'Success', message: response });
@ -42,7 +55,7 @@ export default () => {
.required('A valid email address must be provided to continue.'),
})}
>
{({ isSubmitting }) => (
{({ isSubmitting, setSubmitting, submitForm }) => (
<LoginFormContainer
title={'Request Password Reset'}
css={tw`w-full flex`}
@ -64,6 +77,21 @@ export default () => {
Send Email
</Button>
</div>
{recaptchaEnabled &&
<Reaptcha
ref={ref}
size={'invisible'}
sitekey={siteKey || '_invalid_key'}
onVerify={response => {
setToken(response);
submitForm();
}}
onExpire={() => {
setSubmitting(false);
setToken('');
}}
/>
}
<div css={tw`mt-6 text-center`}>
<Link
type={'button'}

View file

@ -1,105 +1,39 @@
import React, { useRef } from 'react';
import React, { useRef, useState } from 'react';
import { Link, RouteComponentProps } from 'react-router-dom';
import login, { LoginData } from '@/api/auth/login';
import login from '@/api/auth/login';
import LoginFormContainer from '@/components/auth/LoginFormContainer';
import { ActionCreator, Actions, useStoreActions, useStoreState } from 'easy-peasy';
import { ApplicationStore } from '@/state';
import { FormikProps, withFormik } from 'formik';
import { useStoreState } from 'easy-peasy';
import { Formik, FormikHelpers } from 'formik';
import { object, string } from 'yup';
import Field from '@/components/elements/Field';
import { httpErrorToHuman } from '@/api/http';
import { FlashMessage } from '@/state/flashes';
import ReCAPTCHA from 'react-google-recaptcha';
import tw from 'twin.macro';
import Button from '@/components/elements/Button';
import Reaptcha from 'reaptcha';
import useFlash from '@/plugins/useFlash';
type OwnProps = RouteComponentProps & {
clearFlashes: ActionCreator<void>;
addFlash: ActionCreator<FlashMessage>;
interface Values {
username: string;
password: string;
}
const LoginContainer = ({ isSubmitting, setFieldValue, values, submitForm, handleSubmit }: OwnProps & FormikProps<LoginData>) => {
const ref = useRef<ReCAPTCHA | null>(null);
const { enabled: recaptchaEnabled, siteKey } = useStoreState<ApplicationStore, any>(state => state.settings.data!.recaptcha);
const LoginContainer = ({ history }: RouteComponentProps) => {
const ref = useRef<Reaptcha>(null);
const [ token, setToken ] = useState('');
const submit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const { clearFlashes, clearAndAddHttpError } = useFlash();
const { enabled: recaptchaEnabled, siteKey } = useStoreState(state => state.settings.data!.recaptcha);
if (ref.current && !values.recaptchaData) {
return ref.current.execute();
const onSubmit = (values: Values, { setSubmitting }: FormikHelpers<Values>) => {
clearFlashes();
// If there is no token in the state yet, request the token and then abort this submit request
// since it will be re-submitted when the recaptcha data is returned by the component.
if (recaptchaEnabled && !token) {
ref.current!.execute().catch(error => console.error(error));
return;
}
handleSubmit(e);
};
return (
<React.Fragment>
{ref.current && ref.current.render()}
<LoginFormContainer title={'Login to Continue'} css={tw`w-full flex`} onSubmit={submit}>
<Field
type={'text'}
label={'Username or Email'}
id={'username'}
name={'username'}
light
/>
<div css={tw`mt-6`}>
<Field
type={'password'}
label={'Password'}
id={'password'}
name={'password'}
light
/>
</div>
<div css={tw`mt-6`}>
<Button type={'submit'} size={'xlarge'} isLoading={isSubmitting}>
Login
</Button>
</div>
{recaptchaEnabled &&
<ReCAPTCHA
ref={ref}
size={'invisible'}
sitekey={siteKey || '_invalid_key'}
onChange={token => {
ref.current && ref.current.reset();
setFieldValue('recaptchaData', token);
submitForm();
}}
onExpired={() => setFieldValue('recaptchaData', null)}
/>
}
<div css={tw`mt-6 text-center`}>
<Link
to={'/auth/password'}
css={tw`text-xs text-neutral-500 tracking-wide no-underline uppercase hover:text-neutral-600`}
>
Forgot password?
</Link>
</div>
</LoginFormContainer>
</React.Fragment>
);
};
const EnhancedForm = withFormik<OwnProps, LoginData>({
displayName: 'LoginContainerForm',
mapPropsToValues: () => ({
username: '',
password: '',
recaptchaData: null,
}),
validationSchema: () => object().shape({
username: string().required('A username or email must be provided.'),
password: string().required('Please enter your account password.'),
}),
handleSubmit: (values, { props, setFieldValue, setSubmitting }) => {
props.clearFlashes();
login(values)
login({ ...values, recaptchaData: token })
.then(response => {
if (response.complete) {
// @ts-ignore
@ -107,26 +41,75 @@ const EnhancedForm = withFormik<OwnProps, LoginData>({
return;
}
props.history.replace('/auth/login/checkpoint', { token: response.confirmationToken });
history.replace('/auth/login/checkpoint', { token: response.confirmationToken });
})
.catch(error => {
console.error(error);
setSubmitting(false);
setFieldValue('recaptchaData', null);
props.addFlash({ type: 'error', title: 'Error', message: httpErrorToHuman(error) });
clearAndAddHttpError({ error });
});
},
})(LoginContainer);
export default (props: RouteComponentProps) => {
const { clearFlashes, addFlash } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
};
return (
<EnhancedForm
{...props}
addFlash={addFlash}
clearFlashes={clearFlashes}
/>
<Formik
onSubmit={onSubmit}
initialValues={{ username: '', password: '' }}
validationSchema={object().shape({
username: string().required('A username or email must be provided.'),
password: string().required('Please enter your account password.'),
})}
>
{({ isSubmitting, setSubmitting, submitForm }) => (
<LoginFormContainer title={'Login to Continue'} css={tw`w-full flex`}>
<Field
type={'text'}
label={'Username or Email'}
id={'username'}
name={'username'}
light
/>
<div css={tw`mt-6`}>
<Field
type={'password'}
label={'Password'}
id={'password'}
name={'password'}
light
/>
</div>
<div css={tw`mt-6`}>
<Button type={'submit'} size={'xlarge'} isLoading={isSubmitting}>
Login
</Button>
</div>
{recaptchaEnabled &&
<Reaptcha
ref={ref}
size={'invisible'}
sitekey={siteKey || '_invalid_key'}
onVerify={response => {
setToken(response);
submitForm();
}}
onExpire={() => {
setSubmitting(false);
setToken('');
}}
/>
}
<div css={tw`mt-6 text-center`}>
<Link
to={'/auth/password'}
css={tw`text-xs text-neutral-500 tracking-wide no-underline uppercase hover:text-neutral-600`}
>
Forgot password?
</Link>
</div>
</LoginFormContainer>
)}
</Formik>
);
};
export default LoginContainer;

View file

@ -1,4 +1,5 @@
import React, { useEffect, useState } from 'react';
import { Helmet } from 'react-helmet';
import ContentBox from '@/components/elements/ContentBox';
import CreateApiKeyForm from '@/components/dashboard/forms/CreateApiKeyForm';
import getApiKeys, { ApiKey } from '@/api/account/getApiKeys';
@ -7,7 +8,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faKey, faTrashAlt } from '@fortawesome/free-solid-svg-icons';
import ConfirmationModal from '@/components/elements/ConfirmationModal';
import deleteApiKey from '@/api/account/deleteApiKey';
import { Actions, useStoreActions } from 'easy-peasy';
import { Actions, useStoreActions, useStoreState } from 'easy-peasy';
import { ApplicationStore } from '@/state';
import FlashMessageRender from '@/components/FlashMessageRender';
import { httpErrorToHuman } from '@/api/http';
@ -21,6 +22,7 @@ export default () => {
const [ keys, setKeys ] = useState<ApiKey[]>([]);
const [ loading, setLoading ] = useState(true);
const { addError, clearFlashes } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
const name = useStoreState((state: ApplicationStore) => state.settings.data!.name);
useEffect(() => {
clearFlashes('account');
@ -49,6 +51,9 @@ export default () => {
return (
<PageContentBlock>
<Helmet>
<title> {name} | API</title>
</Helmet>
<FlashMessageRender byKey={'account'} css={tw`mb-4`}/>
<div css={tw`flex`}>
<ContentBox title={'Create API Key'} css={tw`flex-1`}>
@ -56,21 +61,19 @@ export default () => {
</ContentBox>
<ContentBox title={'API Keys'} css={tw`ml-10 flex-1`}>
<SpinnerOverlay visible={loading}/>
{deleteIdentifier &&
<ConfirmationModal
visible
visible={!!deleteIdentifier}
title={'Confirm key deletion'}
buttonText={'Yes, delete key'}
onConfirmed={() => {
doDeletion(deleteIdentifier);
setDeleteIdentifier('');
}}
onDismissed={() => setDeleteIdentifier('')}
onModalDismissed={() => setDeleteIdentifier('')}
>
Are you sure you wish to delete this API key? All requests using it will immediately be
invalidated and will fail.
</ConfirmationModal>
}
{
keys.length === 0 ?
<p css={tw`text-center text-sm`}>

View file

@ -1,4 +1,6 @@
import * as React from 'react';
import { Helmet } from 'react-helmet';
import { ApplicationStore } from '@/state';
import ContentBox from '@/components/elements/ContentBox';
import UpdatePasswordForm from '@/components/dashboard/forms/UpdatePasswordForm';
import UpdateEmailAddressForm from '@/components/dashboard/forms/UpdateEmailAddressForm';
@ -7,6 +9,7 @@ import PageContentBlock from '@/components/elements/PageContentBlock';
import tw from 'twin.macro';
import { breakpoint } from '@/theme';
import styled from 'styled-components/macro';
import { useStoreState } from 'easy-peasy';
const Container = styled.div`
${tw`flex flex-wrap my-10`};
@ -25,8 +28,12 @@ const Container = styled.div`
`;
export default () => {
const name = useStoreState((state: ApplicationStore) => state.settings.data!.name);
return (
<PageContentBlock>
<Helmet>
<title> {name} | Account Overview</title>
</Helmet>
<Container>
<ContentBox title={'Update Password'} showFlashes={'account:password'}>
<UpdatePasswordForm/>

View file

@ -0,0 +1,38 @@
import React, { useContext } from 'react';
import tw from 'twin.macro';
import Button from '@/components/elements/Button';
import asModal from '@/hoc/asModal';
import ModalContext from '@/context/ModalContext';
interface Props {
apiKey: string;
}
const ApiKeyModal = ({ apiKey }: Props) => {
const { dismiss } = useContext(ModalContext);
return (
<>
<h3 css={tw`mb-6`}>Your API Key</h3>
<p css={tw`text-sm mb-6`}>
The API key you have requested is shown below. Please store this in a safe location, it will not be
shown again.
</p>
<pre css={tw`text-sm bg-neutral-900 rounded py-2 px-4 font-mono`}>
<code css={tw`font-mono`}>{apiKey}</code>
</pre>
<div css={tw`flex justify-end mt-6`}>
<Button type={'button'} onClick={() => dismiss()}>
Close
</Button>
</div>
</>
);
};
ApiKeyModal.displayName = 'ApiKeyModal';
export default asModal<Props>({
closeOnEscape: false,
closeOnBackground: false,
})(ApiKeyModal);

View file

@ -1,5 +1,7 @@
import React, { useEffect } from 'react';
import React, { useEffect, useState } from 'react';
import { Helmet } from 'react-helmet';
import { Server } from '@/api/server/getServer';
import { ApplicationStore } from '@/state';
import getServers from '@/api/getServers';
import ServerRow from '@/components/dashboard/ServerRow';
import Spinner from '@/components/elements/Spinner';
@ -11,15 +13,18 @@ import Switch from '@/components/elements/Switch';
import tw from 'twin.macro';
import useSWR from 'swr';
import { PaginatedResult } from '@/api/http';
import Pagination from '@/components/elements/Pagination';
export default () => {
const { clearFlashes, clearAndAddHttpError } = useFlash();
const [ page, setPage ] = useState(1);
const { rootAdmin } = useStoreState(state => state.user.data!);
const [ showAdmin, setShowAdmin ] = usePersistedState('show_all_servers', false);
const [ showOnlyAdmin, setShowOnlyAdmin ] = usePersistedState('show_all_servers', false);
const name = useStoreState((state: ApplicationStore) => state.settings.data!.name);
const { data: servers, error } = useSWR<PaginatedResult<Server>>(
[ '/api/client/servers', showAdmin ],
() => getServers(undefined, showAdmin)
[ '/api/client/servers', showOnlyAdmin, page ],
() => getServers({ onlyAdmin: showOnlyAdmin, page }),
);
useEffect(() => {
@ -29,29 +34,44 @@ export default () => {
return (
<PageContentBlock showFlashKey={'dashboard'}>
<Helmet>
<title> {name} | Dashboard</title>
</Helmet>
{rootAdmin &&
<div css={tw`mb-2 flex justify-end items-center`}>
<p css={tw`uppercase text-xs text-neutral-400 mr-2`}>
{showAdmin ? 'Showing all servers' : 'Showing your servers'}
{showOnlyAdmin ? 'Showing other\'s servers' : 'Showing your servers'}
</p>
<Switch
name={'show_all_servers'}
defaultChecked={showAdmin}
onChange={() => setShowAdmin(s => !s)}
defaultChecked={showOnlyAdmin}
onChange={() => setShowOnlyAdmin(s => !s)}
/>
</div>
}
{!servers ?
<Spinner centered size={'large'}/>
:
servers.items.length > 0 ?
servers.items.map((server, index) => (
<ServerRow key={server.uuid} server={server} css={index > 0 ? tw`mt-2` : undefined}/>
))
:
<p css={tw`text-center text-sm text-neutral-400`}>
There are no servers associated with your account.
</p>
<Pagination data={servers} onPageSelect={setPage}>
{({ items }) => (
items.length > 0 ?
items.map((server, index) => (
<ServerRow
key={server.uuid}
server={server}
css={index > 0 ? tw`mt-2` : undefined}
/>
))
:
<p css={tw`text-center text-sm text-neutral-400`}>
{showOnlyAdmin ?
'There are no other servers to display.'
:
'There are no servers associated with your account.'
}
</p>
)}
</Pagination>
}
</PageContentBlock>
);

View file

@ -1,9 +1,8 @@
import React, { useEffect, useRef, useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faServer, faEthernet, faMicrochip, faMemory, faHdd } from '@fortawesome/free-solid-svg-icons';
import { faEthernet, faHdd, faMemory, faMicrochip, faServer } from '@fortawesome/free-solid-svg-icons';
import { Link } from 'react-router-dom';
import { Server } from '@/api/server/getServer';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import getServerResourceUsage, { ServerStats } from '@/api/server/getServerResourceUsage';
import { bytesToHuman, megabytesToHuman } from '@/helpers';
import tw from 'twin.macro';

View file

@ -2,7 +2,6 @@ import React, { useState } from 'react';
import { Field, Form, Formik, FormikHelpers } from 'formik';
import { object, string } from 'yup';
import FormikFieldWrapper from '@/components/elements/FormikFieldWrapper';
import Modal from '@/components/elements/Modal';
import createApiKey from '@/api/account/createApiKey';
import { Actions, useStoreActions } from 'easy-peasy';
import { ApplicationStore } from '@/state';
@ -12,12 +11,16 @@ import { ApiKey } from '@/api/account/getApiKeys';
import tw from 'twin.macro';
import Button from '@/components/elements/Button';
import Input, { Textarea } from '@/components/elements/Input';
import styled from 'styled-components/macro';
import ApiKeyModal from '@/components/dashboard/ApiKeyModal';
interface Values {
description: string;
allowedIps: string;
}
const CustomTextarea = styled(Textarea)`${tw`h-32`}`;
export default ({ onKeyCreated }: { onKeyCreated: (key: ApiKey) => void }) => {
const [ apiKey, setApiKey ] = useState('');
const { addError, clearFlashes } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
@ -41,35 +44,14 @@ export default ({ onKeyCreated }: { onKeyCreated: (key: ApiKey) => void }) => {
return (
<>
<Modal
<ApiKeyModal
visible={apiKey.length > 0}
onDismissed={() => setApiKey('')}
closeOnEscape={false}
closeOnBackground={false}
>
<h3 css={tw`mb-6`}>Your API Key</h3>
<p css={tw`text-sm mb-6`}>
The API key you have requested is shown below. Please store this in a safe location, it will not be
shown again.
</p>
<pre css={tw`text-sm bg-neutral-900 rounded py-2 px-4 font-mono`}>
<code css={tw`font-mono`}>{apiKey}</code>
</pre>
<div css={tw`flex justify-end mt-6`}>
<Button
type={'button'}
onClick={() => setApiKey('')}
>
Close
</Button>
</div>
</Modal>
onModalDismissed={() => setApiKey('')}
apiKey={apiKey}
/>
<Formik
onSubmit={submit}
initialValues={{
description: '',
allowedIps: '',
}}
initialValues={{ description: '', allowedIps: '' }}
validationSchema={object().shape({
allowedIps: string(),
description: string().required().min(4),
@ -91,7 +73,7 @@ export default ({ onKeyCreated }: { onKeyCreated: (key: ApiKey) => void }) => {
name={'allowedIps'}
description={'Leave blank to allow any IP address to use this API key, otherwise provide each IP address on a new line.'}
>
<Field as={Textarea} name={'allowedIps'} css={tw`h-32`}/>
<Field name={'allowedIps'} as={CustomTextarea}/>
</FormikFieldWrapper>
<div css={tw`flex justify-end mt-6`}>
<Button>Create</Button>

View file

@ -57,7 +57,7 @@ export default ({ ...props }: Props) => {
setSubmitting(false);
clearFlashes('search');
getServers(term)
getServers({ query: term })
.then(servers => setServers(servers.items.filter((_, index) => index < 5)))
.catch(error => {
console.error(error);

View file

@ -2,8 +2,6 @@ import React, { useCallback, useEffect, useState } from 'react';
import ace, { Editor } from 'brace';
import styled from 'styled-components/macro';
import tw from 'twin.macro';
import Select from '@/components/elements/Select';
// @ts-ignore
import modes from '@/modes';
// @ts-ignore
@ -21,42 +19,38 @@ const EditorContainer = styled.div`
`;
Object.keys(modes).forEach(mode => require(`brace/mode/${mode}`));
const modelist = ace.acequire('ace/ext/modelist');
export interface Props {
style?: React.CSSProperties;
initialContent?: string;
initialModePath?: string;
mode: string;
filename?: string;
onModeChanged: (mode: string) => void;
fetchContent: (callback: () => Promise<string>) => void;
onContentSaved: (content: string) => void;
}
export default ({ style, initialContent, initialModePath, fetchContent, onContentSaved }: Props) => {
const [ mode, setMode ] = useState('ace/mode/plain_text');
export default ({ style, initialContent, filename, mode, fetchContent, onContentSaved, onModeChanged }: Props) => {
const [ editor, setEditor ] = useState<Editor>();
const ref = useCallback(node => {
if (node) {
setEditor(ace.edit('editor'));
}
if (node) setEditor(ace.edit('editor'));
}, []);
useEffect(() => {
editor && editor.session.setMode(mode);
if (modelist && filename) {
onModeChanged(modelist.getModeForPath(filename).mode.replace(/^ace\/mode\//, ''));
}
}, [ filename ]);
useEffect(() => {
editor && editor.session.setMode(`ace/mode/${mode}`);
}, [ editor, mode ]);
useEffect(() => {
editor && editor.session.setValue(initialContent || '');
}, [ editor, initialContent ]);
useEffect(() => {
if (initialModePath) {
const modelist = ace.acequire('ace/ext/modelist');
if (modelist) {
setMode(modelist.getModeForPath(initialModePath).mode);
}
}
}, [ initialModePath ]);
useEffect(() => {
if (!editor) {
fetchContent(() => Promise.reject(new Error('no editor session has been configured')));
@ -85,20 +79,6 @@ export default ({ style, initialContent, initialModePath, fetchContent, onConten
return (
<EditorContainer style={style}>
<div id={'editor'} ref={ref}/>
<div css={tw`absolute right-0 bottom-0 z-50`}>
<div css={tw`m-3 rounded bg-neutral-900 border border-black`}>
<Select
value={mode.split('/').pop()}
onChange={e => setMode(`ace/mode/${e.currentTarget.value}`)}
>
{
Object.keys(modes).map(key => (
<option key={key} value={key}>{(modes as { [k: string]: string })[key]}</option>
))
}
</Select>
</div>
</div>
</EditorContainer>
);
};

View file

@ -1,7 +1,8 @@
import React from 'react';
import Modal, { RequiredModalProps } from '@/components/elements/Modal';
import React, { useContext } from 'react';
import tw from 'twin.macro';
import Button from '@/components/elements/Button';
import asModal from '@/hoc/asModal';
import ModalContext from '@/context/ModalContext';
type Props = {
title: string;
@ -9,26 +10,29 @@ type Props = {
children: string;
onConfirmed: () => void;
showSpinnerOverlay?: boolean;
} & RequiredModalProps;
};
const ConfirmationModal = ({ title, appear, children, visible, buttonText, onConfirmed, showSpinnerOverlay, onDismissed }: Props) => (
<Modal
appear={appear || true}
visible={visible}
showSpinnerOverlay={showSpinnerOverlay}
onDismissed={() => onDismissed()}
>
<h2 css={tw`text-2xl mb-6`}>{title}</h2>
<p css={tw`text-sm`}>{children}</p>
<div css={tw`flex items-center justify-end mt-8`}>
<Button isSecondary onClick={() => onDismissed()}>
Cancel
</Button>
<Button color={'red'} css={tw`ml-4`} onClick={() => onConfirmed()}>
{buttonText}
</Button>
</div>
</Modal>
);
const ConfirmationModal = ({ title, children, buttonText, onConfirmed }: Props) => {
const { dismiss } = useContext(ModalContext);
export default ConfirmationModal;
return (
<>
<h2 css={tw`text-2xl mb-6`}>{title}</h2>
<p css={tw`text-sm`}>{children}</p>
<div css={tw`flex items-center justify-end mt-8`}>
<Button isSecondary onClick={() => dismiss()}>
Cancel
</Button>
<Button color={'red'} css={tw`ml-4`} onClick={() => onConfirmed()}>
{buttonText}
</Button>
</div>
</>
);
};
ConfirmationModal.displayName = 'ConfirmationModal';
export default asModal<Props>(props => ({
showSpinnerOverlay: props.showSpinnerOverlay,
}))(ConfirmationModal);

View file

@ -8,14 +8,14 @@ interface Props extends Omit<CSSTransitionProps, 'timeout' | 'classNames'> {
}
const Container = styled.div<{ timeout: number }>`
.fade-enter, .fade-exit {
.fade-enter, .fade-exit, .fade-appear {
will-change: opacity;
}
.fade-enter {
.fade-enter, .fade-appear {
${tw`opacity-0`};
&.fade-enter-active {
&.fade-enter-active, &.fade-appear-active {
${tw`opacity-100 transition-opacity ease-in`};
transition-duration: ${props => props.timeout}ms;
}

View file

@ -13,7 +13,7 @@ export interface RequiredModalProps {
top?: boolean;
}
interface Props extends RequiredModalProps {
export interface ModalProps extends RequiredModalProps {
dismissable?: boolean;
closeOnEscape?: boolean;
closeOnBackground?: boolean;
@ -40,7 +40,7 @@ const ModalContainer = styled.div<{ alignTop?: boolean }>`
}
`;
const Modal: React.FC<Props> = ({ visible, appear, dismissable, showSpinnerOverlay, top = true, closeOnBackground = true, closeOnEscape = true, onDismissed, children }) => {
const Modal: React.FC<ModalProps> = ({ visible, appear, dismissable, showSpinnerOverlay, top = true, closeOnBackground = true, closeOnEscape = true, onDismissed, children }) => {
const [ render, setRender ] = useState(visible);
const isDismissable = useMemo(() => {
@ -62,7 +62,13 @@ const Modal: React.FC<Props> = ({ visible, appear, dismissable, showSpinnerOverl
}, [ render ]);
return (
<Fade timeout={150} appear={appear} in={render} unmountOnExit onExited={onDismissed}>
<Fade
in={render}
timeout={150}
appear={appear || true}
unmountOnExit
onExited={() => onDismissed()}
>
<ModalMask
onClick={e => {
if (isDismissable && closeOnBackground) {
@ -80,12 +86,14 @@ const Modal: React.FC<Props> = ({ visible, appear, dismissable, showSpinnerOverl
</div>
}
{showSpinnerOverlay &&
<div
css={tw`absolute w-full h-full rounded flex items-center justify-center`}
style={{ background: 'hsla(211, 10%, 53%, 0.25)' }}
>
<Spinner/>
</div>
<Fade timeout={150} appear in>
<div
css={tw`absolute w-full h-full rounded flex items-center justify-center`}
style={{ background: 'hsla(211, 10%, 53%, 0.25)' }}
>
<Spinner/>
</div>
</Fade>
}
<div css={tw`bg-neutral-800 p-6 rounded shadow-md overflow-y-scroll transition-all duration-150`}>
{children}

View file

@ -3,31 +3,48 @@ import ContentContainer from '@/components/elements/ContentContainer';
import { CSSTransition } from 'react-transition-group';
import tw from 'twin.macro';
import FlashMessageRender from '@/components/FlashMessageRender';
import { Helmet } from 'react-helmet';
import useServer from '@/plugins/useServer';
const PageContentBlock: React.FC<{ showFlashKey?: string; className?: string }> = ({ children, showFlashKey, className }) => (
<CSSTransition timeout={150} classNames={'fade'} appear in>
<>
<ContentContainer css={tw`my-10`} className={className}>
{showFlashKey &&
<FlashMessageRender byKey={showFlashKey} css={tw`mb-4`}/>
interface Props {
title?: string;
className?: string;
showFlashKey?: string;
}
const PageContentBlock: React.FC<Props> = ({ title, showFlashKey, className, children }) => {
const { name } = useServer();
return (
<CSSTransition timeout={150} classNames={'fade'} appear in>
<>
{!!title &&
<Helmet>
<title>{name} | {title}</title>
</Helmet>
}
{children}
</ContentContainer>
<ContentContainer css={tw`mb-4`}>
<p css={tw`text-center text-neutral-500 text-xs`}>
&copy; 2015 - 2020&nbsp;
<a
rel={'noopener nofollow noreferrer'}
href={'https://pterodactyl.io'}
target={'_blank'}
css={tw`no-underline text-neutral-500 hover:text-neutral-300`}
>
Pterodactyl Software
</a>
</p>
</ContentContainer>
</>
</CSSTransition>
);
<ContentContainer css={tw`my-10`} className={className}>
{showFlashKey &&
<FlashMessageRender byKey={showFlashKey} css={tw`mb-4`}/>
}
{children}
</ContentContainer>
<ContentContainer css={tw`mb-4`}>
<p css={tw`text-center text-neutral-500 text-xs`}>
&copy; 2015 - 2020&nbsp;
<a
rel={'noopener nofollow noreferrer'}
href={'https://pterodactyl.io'}
target={'_blank'}
css={tw`no-underline text-neutral-500 hover:text-neutral-300`}
>
Pterodactyl Software
</a>
</p>
</ContentContainer>
</>
</CSSTransition>
);
};
export default PageContentBlock;

View file

@ -0,0 +1,87 @@
import React from 'react';
import { PaginatedResult } from '@/api/http';
import tw from 'twin.macro';
import styled from 'styled-components/macro';
import Button from '@/components/elements/Button';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faAngleDoubleLeft, faAngleDoubleRight } from '@fortawesome/free-solid-svg-icons';
interface RenderFuncProps<T> {
items: T[];
isLastPage: boolean;
isFirstPage: boolean;
}
interface Props<T> {
data: PaginatedResult<T>;
showGoToLast?: boolean;
showGoToFirst?: boolean;
onPageSelect: (page: number) => void;
children: (props: RenderFuncProps<T>) => React.ReactNode;
}
const Block = styled(Button)`
${tw`p-0 w-10 h-10`}
&:not(:last-of-type) {
${tw`mr-2`};
}
`;
function Pagination<T> ({ data: { items, pagination }, onPageSelect, children }: Props<T>) {
const isFirstPage = pagination.currentPage === 1;
const isLastPage = pagination.currentPage >= pagination.totalPages;
const pages = [];
// Start two spaces before the current page. If that puts us before the starting page default
// to the first page as the starting point.
const start = Math.max(pagination.currentPage - 2, 1);
const end = Math.min(pagination.totalPages, pagination.currentPage + 5);
for (let i = start; i <= end; i++) {
pages.push(i);
}
return (
<>
{children({ items, isFirstPage, isLastPage })}
{(pages.length > 1) &&
<div css={tw`mt-4 flex justify-center`}>
{(pages[0] > 1 && !isFirstPage) &&
<Block
isSecondary
color={'primary'}
onClick={() => onPageSelect(1)}
>
<FontAwesomeIcon icon={faAngleDoubleLeft}/>
</Block>
}
{
pages.map(i => (
<Block
isSecondary={pagination.currentPage !== i}
color={'primary'}
key={`block_page_${i}`}
onClick={() => onPageSelect(i)}
>
{i}
</Block>
))
}
{(pages[4] < pagination.totalPages && !isLastPage) &&
<Block
isSecondary
color={'primary'}
onClick={() => onPageSelect(pagination.totalPages)}
>
<FontAwesomeIcon icon={faAngleDoubleRight}/>
</Block>
}
</div>
}
</>
);
}
export default Pagination;

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`}>

View file

@ -0,0 +1,15 @@
import React from 'react';
export interface ModalContextValues {
dismiss: () => void;
toggleSpinner: (visible?: boolean) => void;
}
const ModalContext = React.createContext<ModalContextValues>({
dismiss: () => null,
toggleSpinner: () => null,
});
ModalContext.displayName = 'ModalContext';
export default ModalContext;

View file

@ -0,0 +1,94 @@
import React from 'react';
import Modal, { ModalProps } from '@/components/elements/Modal';
import ModalContext from '@/context/ModalContext';
import isEqual from 'react-fast-compare';
export interface AsModalProps {
visible: boolean;
onModalDismissed?: () => void;
}
type ExtendedModalProps = Omit<ModalProps, 'appear' | 'visible' | 'onDismissed'>;
interface State {
render: boolean;
visible: boolean;
modalProps: ExtendedModalProps | undefined;
}
type ExtendedComponentType<T> = (C: React.ComponentType<T>) => React.ComponentType<T & AsModalProps>;
// eslint-disable-next-line @typescript-eslint/ban-types
function asModal<P extends object> (modalProps?: ExtendedModalProps | ((props: P) => ExtendedModalProps)): ExtendedComponentType<P> {
return function (Component) {
return class extends React.PureComponent <P & AsModalProps, State> {
static displayName = `asModal(${Component.displayName})`;
constructor (props: P & AsModalProps) {
super(props);
this.state = {
render: props.visible,
visible: props.visible,
modalProps: typeof modalProps === 'function' ? modalProps(this.props) : modalProps,
};
}
componentDidUpdate (prevProps: Readonly<P & AsModalProps>) {
const mapped = typeof modalProps === 'function' ? modalProps(this.props) : modalProps;
if (!isEqual(this.state.modalProps, mapped)) {
// noinspection JSPotentiallyInvalidUsageOfThis
this.setState({ modalProps: mapped });
}
if (prevProps.visible && !this.props.visible) {
// noinspection JSPotentiallyInvalidUsageOfThis
this.setState({ visible: false });
} else if (!prevProps.visible && this.props.visible) {
// noinspection JSPotentiallyInvalidUsageOfThis
this.setState({ render: true, visible: true });
}
}
dismiss = () => this.setState({ visible: false });
toggleSpinner = (value?: boolean) => this.setState(s => ({
modalProps: {
...s.modalProps,
showSpinnerOverlay: value || false,
},
}));
render () {
return (
<ModalContext.Provider
value={{
dismiss: this.dismiss.bind(this),
toggleSpinner: this.toggleSpinner.bind(this),
}}
>
{
this.state.render ?
<Modal
appear
visible={this.state.visible}
onDismissed={() => this.setState({ render: false }, () => {
if (typeof this.props.onModalDismissed === 'function') {
this.props.onModalDismissed();
}
})}
{...this.state.modalProps}
>
<Component {...this.props}/>
</Modal>
:
null
}
</ModalContext.Provider>
);
}
};
};
}
export default asModal;

3
resources/scripts/modes.d.ts vendored Normal file
View file

@ -0,0 +1,3 @@
declare const modes: Record<string, string>;
export default modes;

View file

@ -1,3 +1,4 @@
// This file must be plain Javascript since we're using it within Webpack.
module.exports = {
assembly_x86: 'Assembly (x86)',
c_cpp: 'C++',

View file

@ -1,13 +1,6 @@
import Sockette from 'sockette';
import { EventEmitter } from 'events';
export const SOCKET_EVENTS = [
'SOCKET_OPEN',
'SOCKET_RECONNECT',
'SOCKET_CLOSE',
'SOCKET_ERROR',
];
export class Websocket extends EventEmitter {
// Timer instance for this socket.
private timer: any = null;

View file

@ -2,18 +2,18 @@ import useSWR from 'swr';
import loadDirectory, { FileObject } from '@/api/server/files/loadDirectory';
import { cleanDirectoryPath } from '@/helpers';
import useServer from '@/plugins/useServer';
import { useLocation } from 'react-router';
import { ServerContext } from '@/state/server';
export default () => {
const { uuid } = useServer();
const { hash } = useLocation();
const directory = ServerContext.useStoreState(state => state.files.directory);
return useSWR<FileObject[]>(
`${uuid}:files:${hash}`,
() => loadDirectory(uuid, cleanDirectoryPath(hash)),
`${uuid}:files:${directory}`,
() => loadDirectory(uuid, cleanDirectoryPath(directory)),
{
revalidateOnMount: false,
revalidateOnMount: true,
refreshInterval: 0,
}
},
);
};

View file

@ -1,9 +1,8 @@
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 ]);
const useServer = (dependencies?: any[] | undefined): Server => {
return ServerContext.useStoreState(state => state.server.data!, dependencies);
};
export default useServer;

View file

@ -1,4 +1,5 @@
import React from 'react';
import React, { useEffect } from 'react';
import ReactGA from 'react-ga';
import { Route, RouteComponentProps, Switch } from 'react-router-dom';
import LoginContainer from '@/components/auth/LoginContainer';
import ForgotPasswordContainer from '@/components/auth/ForgotPasswordContainer';
@ -6,17 +7,23 @@ import ResetPasswordContainer from '@/components/auth/ResetPasswordContainer';
import LoginCheckpointContainer from '@/components/auth/LoginCheckpointContainer';
import NotFound from '@/components/screens/NotFound';
export default ({ location, history, match }: RouteComponentProps) => (
<div className={'pt-8 xl:pt-32'}>
<Switch location={location}>
<Route path={`${match.path}/login`} component={LoginContainer} exact/>
<Route path={`${match.path}/login/checkpoint`} component={LoginCheckpointContainer}/>
<Route path={`${match.path}/password`} component={ForgotPasswordContainer} exact/>
<Route path={`${match.path}/password/reset/:token`} component={ResetPasswordContainer}/>
<Route path={`${match.path}/checkpoint`}/>
<Route path={'*'}>
<NotFound onBack={() => history.push('/auth/login')}/>
</Route>
</Switch>
</div>
);
export default ({ location, history, match }: RouteComponentProps) => {
useEffect(() => {
ReactGA.pageview(location.pathname);
}, [ location.pathname ]);
return (
<div className={'pt-8 xl:pt-32'}>
<Switch location={location}>
<Route path={`${match.path}/login`} component={LoginContainer} exact/>
<Route path={`${match.path}/login/checkpoint`} component={LoginCheckpointContainer}/>
<Route path={`${match.path}/password`} component={ForgotPasswordContainer} exact/>
<Route path={`${match.path}/password/reset/:token`} component={ResetPasswordContainer}/>
<Route path={`${match.path}/checkpoint`} />
<Route path={'*'}>
<NotFound onBack={() => history.push('/auth/login')} />
</Route>
</Switch>
</div>
);
};

View file

@ -1,4 +1,5 @@
import * as React from 'react';
import React, { useEffect } from 'react';
import ReactGA from 'react-ga';
import { NavLink, Route, RouteComponentProps, Switch } from 'react-router-dom';
import AccountOverviewContainer from '@/components/dashboard/AccountOverviewContainer';
import NavigationBar from '@/components/NavigationBar';
@ -8,24 +9,30 @@ import NotFound from '@/components/screens/NotFound';
import TransitionRouter from '@/TransitionRouter';
import SubNavigation from '@/components/elements/SubNavigation';
export default ({ location }: RouteComponentProps) => (
<>
<NavigationBar/>
{location.pathname.startsWith('/account') &&
<SubNavigation>
<div>
<NavLink to={'/account'} exact>Settings</NavLink>
<NavLink to={'/account/api'}>API Credentials</NavLink>
</div>
</SubNavigation>
}
<TransitionRouter>
<Switch location={location}>
<Route path={'/'} component={DashboardContainer} exact/>
<Route path={'/account'} component={AccountOverviewContainer} exact/>
<Route path={'/account/api'} component={AccountApiContainer} exact/>
<Route path={'*'} component={NotFound}/>
</Switch>
</TransitionRouter>
</>
);
export default ({ location }: RouteComponentProps) => {
useEffect(() => {
ReactGA.pageview(location.pathname);
}, [ location.pathname ]);
return (
<>
<NavigationBar />
{location.pathname.startsWith('/account') &&
<SubNavigation>
<div>
<NavLink to={'/account'} exact>Settings</NavLink>
<NavLink to={'/account/api'}>API Credentials</NavLink>
</div>
</SubNavigation>
}
<TransitionRouter>
<Switch location={location}>
<Route path={'/'} component={DashboardContainer} exact />
<Route path={'/account'} component={AccountOverviewContainer} exact/>
<Route path={'/account/api'} component={AccountApiContainer} exact/>
<Route path={'*'} component={NotFound} />
</Switch>
</TransitionRouter>
</>
);
};

View file

@ -1,4 +1,5 @@
import React, { useEffect, useState } from 'react';
import ReactGA from 'react-ga';
import { NavLink, Route, RouteComponentProps, Switch } from 'react-router-dom';
import NavigationBar from '@/components/NavigationBar';
import ServerConsole from '@/components/server/ServerConsole';
@ -25,6 +26,8 @@ import useServer from '@/plugins/useServer';
import ScreenBlock from '@/components/screens/ScreenBlock';
import SubNavigation from '@/components/elements/SubNavigation';
import NetworkContainer from '@/components/server/network/NetworkContainer';
import InstallListener from '@/components/server/InstallListener';
import StartupContainer from '@/components/server/startup/StartupContainer';
const ServerRouter = ({ match, location }: RouteComponentProps<{ id: string }>) => {
const { rootAdmin } = useStoreState(state => state.user.data!);
@ -60,6 +63,10 @@ const ServerRouter = ({ match, location }: RouteComponentProps<{ id: string }>)
};
}, [ match.params.id ]);
useEffect(() => {
ReactGA.pageview(location.pathname);
}, [ location.pathname ]);
return (
<React.Fragment key={'server-router'}>
<NavigationBar/>
@ -92,12 +99,17 @@ const ServerRouter = ({ match, location }: RouteComponentProps<{ id: string }>)
<Can action={'allocations.*'}>
<NavLink to={`${match.url}/network`}>Network</NavLink>
</Can>
<Can action={'startup.*'}>
<NavLink to={`${match.url}/startup`}>Startup</NavLink>
</Can>
<Can action={[ 'settings.*', 'file.sftp' ]} matchAny>
<NavLink to={`${match.url}/settings`}>Settings</NavLink>
</Can>
</div>
</SubNavigation>
</CSSTransition>
<InstallListener/>
<WebsocketHandler/>
{(installing && (!rootAdmin || (rootAdmin && !location.pathname.endsWith(`/server/${server.id}`)))) ?
<ScreenBlock
title={'Your server is installing.'}
@ -106,7 +118,6 @@ const ServerRouter = ({ match, location }: RouteComponentProps<{ id: string }>)
/>
:
<>
<WebsocketHandler/>
<TransitionRouter>
<Switch location={location}>
<Route path={`${match.path}`} component={ServerConsole} exact/>
@ -130,6 +141,7 @@ const ServerRouter = ({ match, location }: RouteComponentProps<{ id: string }>)
<Route path={`${match.path}/users`} component={UsersContainer} exact/>
<Route path={`${match.path}/backups`} component={BackupContainer} exact/>
<Route path={`${match.path}/network`} component={NetworkContainer} exact/>
<Route path={`${match.path}/startup`} component={StartupContainer} exact/>
<Route path={`${match.path}/settings`} component={SettingsContainer} exact/>
<Route path={'*'} component={NotFound}/>
</Switch>

View file

@ -6,7 +6,7 @@ export interface FlashStore {
items: FlashMessage[];
addFlash: Action<FlashStore, FlashMessage>;
addError: Action<FlashStore, { message: string; key?: string }>;
clearAndAddHttpError: Action<FlashStore, { error: any, key: string }>;
clearAndAddHttpError: Action<FlashStore, { error: any, key?: string }>;
clearFlashes: Action<FlashStore, string | void>;
}

View file

@ -1,31 +0,0 @@
import { ServerBackup } from '@/api/server/backups/getServerBackups';
import { action, Action } from 'easy-peasy';
export interface ServerBackupStore {
data: ServerBackup[];
setBackups: Action<ServerBackupStore, ServerBackup[]>;
appendBackup: Action<ServerBackupStore, ServerBackup>;
removeBackup: Action<ServerBackupStore, string>;
}
const backups: ServerBackupStore = {
data: [],
setBackups: action((state, payload) => {
state.data = payload;
}),
appendBackup: action((state, payload) => {
if (state.data.find(backup => backup.uuid === payload.uuid)) {
state.data = state.data.map(backup => backup.uuid === payload.uuid ? payload : backup);
} else {
state.data = [ ...state.data, payload ];
}
}),
removeBackup: action((state, payload) => {
state.data = [ ...state.data.filter(backup => backup.uuid !== payload) ];
}),
};
export default backups;

View file

@ -4,7 +4,6 @@ import socket, { SocketStore } from './socket';
import files, { ServerFileStore } from '@/state/server/files';
import subusers, { ServerSubuserStore } from '@/state/server/subusers';
import { composeWithDevTools } from 'redux-devtools-extension';
import backups, { ServerBackupStore } from '@/state/server/backups';
import schedules, { ServerScheduleStore } from '@/state/server/schedules';
import databases, { ServerDatabaseStore } from '@/state/server/databases';
@ -56,7 +55,6 @@ export interface ServerStore {
databases: ServerDatabaseStore;
files: ServerFileStore;
schedules: ServerScheduleStore;
backups: ServerBackupStore;
socket: SocketStore;
status: ServerStatusStore;
clearServerState: Action<ServerStore>;
@ -69,7 +67,6 @@ export const ServerContext = createContextStore<ServerStore>({
databases,
files,
subusers,
backups,
schedules,
clearServerState: action(state => {
state.server.data = undefined;
@ -78,7 +75,6 @@ export const ServerContext = createContextStore<ServerStore>({
state.subusers.data = [];
state.files.directory = '/';
state.files.selectedFiles = [];
state.backups.data = [];
state.schedules.data = [];
if (state.socket.instance) {

View file

@ -7,6 +7,7 @@ export interface SiteSettings {
enabled: boolean;
siteKey: string;
};
analytics: string;
}
export interface SettingsStore {

View file

@ -31,6 +31,13 @@
<p class="text-muted"><small>This is the name that is used throughout the panel and in emails sent to clients.</small></p>
</div>
</div>
<div class="form-group col-md-4">
<label class="control-label">Google Analytics</label>
<div>
<input type="text" class="form-control" name="app:analytics" value="{{ old('app:analytics', config('app.analytics')) }}" />
<p class="text-muted"><small>This is your Google Analytics Tracking ID, Ex. UA-123723645-2</small></p>
</div>
</div>
<div class="form-group col-md-4">
<label class="control-label">Require 2-Factor Authentication</label>
<div>