Merge branch 'develop' into feature/file-uploads
This commit is contained in:
commit
54f9c5f187
136 changed files with 2178 additions and 971 deletions
|
@ -39,6 +39,8 @@ rules:
|
|||
comma-dangle:
|
||||
- warn
|
||||
- always-multiline
|
||||
spaced-comment:
|
||||
- warn
|
||||
array-bracket-spacing:
|
||||
- warn
|
||||
- always
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
};
|
|
@ -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);
|
||||
|
|
8
resources/scripts/api/server/files/decompressFiles.ts
Normal file
8
resources/scripts/api/server/files/decompressFiles.ts
Normal 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.',
|
||||
});
|
||||
};
|
|
@ -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[]> => {
|
||||
|
|
|
@ -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
20
resources/scripts/api/server/types.d.ts
vendored
Normal 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[];
|
||||
}
|
9
resources/scripts/api/server/updateStartupVariable.ts
Normal file
9
resources/scripts/api/server/updateStartupVariable.ts
Normal 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);
|
||||
};
|
18
resources/scripts/api/swr/getServerBackups.ts
Normal file
18
resources/scripts/api/swr/getServerBackups.ts
Normal 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),
|
||||
});
|
||||
});
|
||||
};
|
|
@ -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('|'),
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
`;
|
||||
|
|
|
@ -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/>
|
||||
|
|
|
@ -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'}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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`}>
|
||||
|
|
|
@ -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/>
|
||||
|
|
38
resources/scripts/components/dashboard/ApiKeyModal.tsx
Normal file
38
resources/scripts/components/dashboard/ApiKeyModal.tsx
Normal 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);
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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`}>
|
||||
© 2015 - 2020
|
||||
<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`}>
|
||||
© 2015 - 2020
|
||||
<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;
|
||||
|
|
87
resources/scripts/components/elements/Pagination.tsx
Normal file
87
resources/scripts/components/elements/Pagination.tsx
Normal 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;
|
26
resources/scripts/components/server/InstallListener.tsx
Normal file
26
resources/scripts/components/server/InstallListener.tsx
Normal 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;
|
|
@ -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`}>
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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)}>
|
||||
|
|
|
@ -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/>
|
||||
|
|
10
resources/scripts/components/server/events.ts
Normal file
10
resources/scripts/components/server/events.ts
Normal 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',
|
||||
}
|
|
@ -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);
|
||||
|
|
|
@ -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()}>
|
||||
|
|
|
@ -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/>
|
||||
|
|
|
@ -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}/>
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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'}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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/>
|
||||
:
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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/>
|
||||
|
|
|
@ -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`}>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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?
|
||||
|
|
|
@ -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`}>
|
||||
|
|
|
@ -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;
|
64
resources/scripts/components/server/startup/VariableBox.tsx
Normal file
64
resources/scripts/components/server/startup/VariableBox.tsx
Normal 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;
|
|
@ -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'}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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`}>
|
||||
|
|
15
resources/scripts/context/ModalContext.ts
Normal file
15
resources/scripts/context/ModalContext.ts
Normal 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;
|
94
resources/scripts/hoc/asModal.tsx
Normal file
94
resources/scripts/hoc/asModal.tsx
Normal 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
3
resources/scripts/modes.d.ts
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
declare const modes: Record<string, string>;
|
||||
|
||||
export default modes;
|
|
@ -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++',
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>;
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
|
@ -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) {
|
||||
|
|
|
@ -7,6 +7,7 @@ export interface SiteSettings {
|
|||
enabled: boolean;
|
||||
siteKey: string;
|
||||
};
|
||||
analytics: string;
|
||||
}
|
||||
|
||||
export interface SettingsStore {
|
||||
|
|
|
@ -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>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue