Merge branch 'develop' into patch-1

This commit is contained in:
Dane Everitt 2019-07-25 18:37:35 -04:00 committed by GitHub
commit 574855a4ac
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
33 changed files with 654 additions and 270 deletions

View file

@ -0,0 +1,15 @@
import { rawDataToServerDatabase, ServerDatabase } from '@/api/server/getServerDatabases';
import http from '@/api/http';
export default (uuid: string, data: { connectionsFrom: string; databaseName: string }): Promise<ServerDatabase> => {
return new Promise((resolve, reject) => {
http.post(`/api/client/servers/${uuid}/databases`, {
database: data.databaseName,
remote: data.connectionsFrom,
}, {
params: { include: 'password' },
})
.then(response => resolve(rawDataToServerDatabase(response.data.attributes)))
.catch(reject);
});
};

View file

@ -0,0 +1,9 @@
import http from '@/api/http';
export default (uuid: string, database: string): Promise<void> => {
return new Promise((resolve, reject) => {
http.delete(`/api/client/servers/${uuid}/databases/${database}`)
.then(() => resolve())
.catch(reject);
});
};

View file

@ -0,0 +1,31 @@
import http from '@/api/http';
export interface ServerDatabase {
id: string;
name: string;
username: string;
connectionString: string;
allowConnectionsFrom: string;
password?: string;
}
export const rawDataToServerDatabase = (data: any): ServerDatabase => ({
id: data.id,
name: data.name,
username: data.username,
connectionString: `${data.host.address}:${data.host.port}`,
allowConnectionsFrom: data.connections_from,
password: data.relationships && data.relationships.password ? data.relationships.password.attributes.password : undefined,
});
export default (uuid: string, includePassword: boolean = true): Promise<ServerDatabase[]> => {
return new Promise((resolve, reject) => {
http.get(`/api/client/servers/${uuid}/databases`, {
params: includePassword ? { include: 'password' } : undefined,
})
.then(response => resolve(
(response.data.data || []).map((item: any) => rawDataToServerDatabase(item.attributes))
))
.catch(reject);
});
};

View file

@ -1,16 +1,16 @@
import React from 'react';
import MessageBox from '@/components/MessageBox';
import { State, useStoreState } from 'easy-peasy';
import { ApplicationState } from '@/state/types';
import { ApplicationStore } from '@/state';
type Props = Readonly<{
byKey?: string;
spacerClass?: string;
withBottomSpace?: boolean;
className?: string;
}>;
export default ({ withBottomSpace, spacerClass, byKey }: Props) => {
const flashes = useStoreState((state: State<ApplicationState>) => state.flashes.items);
export default ({ className, spacerClass, byKey }: Props) => {
const flashes = useStoreState((state: State<ApplicationStore>) => state.flashes.items);
let filtered = flashes;
if (byKey) {
@ -21,9 +21,8 @@ export default ({ withBottomSpace, spacerClass, byKey }: Props) => {
return null;
}
// noinspection PointlessBooleanExpressionJS
return (
<div className={withBottomSpace === false ? undefined : 'mb-2'}>
<div className={className}>
{
filtered.map((flash, index) => (
<React.Fragment key={flash.id || flash.type + flash.message}>

View file

@ -4,14 +4,14 @@ import requestPasswordResetEmail from '@/api/auth/requestPasswordResetEmail';
import { httpErrorToHuman } from '@/api/http';
import LoginFormContainer from '@/components/auth/LoginFormContainer';
import { Actions, useStoreActions } from 'easy-peasy';
import { ApplicationState } from '@/state/types';
import FlashMessageRender from '@/components/FlashMessageRender';
import { ApplicationStore } from '@/state';
export default () => {
const [ isSubmitting, setSubmitting ] = React.useState(false);
const [ email, setEmail ] = React.useState('');
const { clearFlashes, addFlash } = useStoreActions((actions: Actions<ApplicationState>) => actions.flashes);
const { clearFlashes, addFlash } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
const handleFieldUpdate = (e: React.ChangeEvent<HTMLInputElement>) => setEmail(e.target.value);

View file

@ -4,15 +4,15 @@ import loginCheckpoint from '@/api/auth/loginCheckpoint';
import { httpErrorToHuman } from '@/api/http';
import LoginFormContainer from '@/components/auth/LoginFormContainer';
import { Actions, useStoreActions } from 'easy-peasy';
import { ApplicationState } from '@/state/types';
import { StaticContext } from 'react-router';
import FlashMessageRender from '@/components/FlashMessageRender';
import { ApplicationStore } from '@/state';
export default ({ history, location: { state } }: RouteComponentProps<{}, StaticContext, { token?: string }>) => {
const [ code, setCode ] = useState('');
const [ isLoading, setIsLoading ] = useState(false);
const { clearFlashes, addFlash } = useStoreActions((actions: Actions<ApplicationState>) => actions.flashes);
const { clearFlashes, addFlash } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
if (!state || !state.token) {
history.replace('/auth/login');

View file

@ -5,14 +5,14 @@ import { httpErrorToHuman } from '@/api/http';
import LoginFormContainer from '@/components/auth/LoginFormContainer';
import FlashMessageRender from '@/components/FlashMessageRender';
import { Actions, useStoreActions } from 'easy-peasy';
import { ApplicationState } from '@/state/types';
import { ApplicationStore } from '@/state';
export default ({ history }: RouteComponentProps) => {
const [ username, setUsername ] = useState('');
const [ password, setPassword ] = useState('');
const [ isLoading, setLoading ] = useState(false);
const { clearFlashes, addFlash } = useStoreActions((actions: Actions<ApplicationState>) => actions.flashes);
const { clearFlashes, addFlash } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
const submit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();

View file

@ -7,7 +7,7 @@ import { httpErrorToHuman } from '@/api/http';
import LoginFormContainer from '@/components/auth/LoginFormContainer';
import FlashMessageRender from '@/components/FlashMessageRender';
import { Actions, useStoreActions } from 'easy-peasy';
import { ApplicationState } from '@/state/types';
import { ApplicationStore } from '@/state';
type Props = Readonly<RouteComponentProps<{ token: string }> & {}>;
@ -17,7 +17,7 @@ export default (props: Props) => {
const [ password, setPassword ] = useState('');
const [ passwordConfirm, setPasswordConfirm ] = useState('');
const { clearFlashes, addFlash } = useStoreActions((actions: Actions<ApplicationState>) => actions.flashes);
const { clearFlashes, addFlash } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
const parsed = parse(props.location.search);
if (email.length === 0 && parsed.email) {

View file

@ -9,8 +9,8 @@ import { Link } from 'react-router-dom';
export default () => (
<div className={'my-10'}>
<Link to={'/server/e9d6c836'} className={'flex no-underline text-neutral-200 cursor-pointer items-center bg-neutral-700 p-4 border border-transparent hover:border-neutral-500'}>
<div className={'rounded-full bg-neutral-500 p-3'}>
<Link to={'/server/e9d6c836'} className={'grey-row-box cursor-pointer'}>
<div className={'icon'}>
<FontAwesomeIcon icon={faServer}/>
</div>
<div className={'w-1/2 ml-4'}>
@ -49,8 +49,8 @@ export default () => (
</div>
</div>
</Link>
<div className={'flex mt-px cursor-pointer items-center bg-neutral-700 p-4 border border-transparent hover:border-neutral-500'}>
<div className={'rounded-full bg-neutral-500 p-3'}>
<div className={'grey-row-box cursor-pointer mt-2'}>
<div className={'icon'}>
<FontAwesomeIcon icon={faServer}/>
</div>
<div className={'w-1/2 ml-4'}>

View file

@ -1,11 +1,11 @@
import React from 'react';
import { Actions, State, useStoreActions, useStoreState } from 'easy-peasy';
import { ApplicationState } from '@/state/types';
import { Form, Formik, FormikActions } from 'formik';
import * as Yup from 'yup';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import Field from '@/components/elements/Field';
import { httpErrorToHuman } from '@/api/http';
import { ApplicationStore } from '@/state';
interface Values {
email: string;
@ -18,10 +18,10 @@ const schema = Yup.object().shape({
});
export default () => {
const user = useStoreState((state: State<ApplicationState>) => state.user.data);
const updateEmail = useStoreActions((state: Actions<ApplicationState>) => state.user.updateUserEmail);
const user = useStoreState((state: State<ApplicationStore>) => state.user.data);
const updateEmail = useStoreActions((state: Actions<ApplicationStore>) => state.user.updateUserEmail);
const { clearFlashes, addFlash } = useStoreActions((actions: Actions<ApplicationState>) => actions.flashes);
const { clearFlashes, addFlash } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
const submit = (values: Values, { resetForm, setSubmitting }: FormikActions<Values>) => {
clearFlashes('account:email');

View file

@ -1,12 +1,12 @@
import React from 'react';
import { Actions, State, useStoreActions, useStoreState } from 'easy-peasy';
import { ApplicationState } from '@/state/types';
import { Form, Formik, FormikActions } from 'formik';
import Field from '@/components/elements/Field';
import * as Yup from 'yup';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import updateAccountPassword from '@/api/account/updateAccountPassword';
import { httpErrorToHuman } from '@/api/http';
import { ApplicationStore } from '@/state';
interface Values {
current: string;
@ -23,8 +23,8 @@ const schema = Yup.object().shape({
});
export default () => {
const user = useStoreState((state: State<ApplicationState>) => state.user.data);
const { clearFlashes, addFlash } = useStoreActions((actions: Actions<ApplicationState>) => actions.flashes);
const user = useStoreState((state: State<ApplicationStore>) => state.user.data);
const { clearFlashes, addFlash } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
if (!user) {
return null;

View file

@ -0,0 +1,71 @@
import React, { useEffect, useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faTimes } from '@fortawesome/free-solid-svg-icons/faTimes';
import { CSSTransition } from 'react-transition-group';
import Spinner from '@/components/elements/Spinner';
interface Props {
visible: boolean;
onDismissed: () => void;
dismissable?: boolean;
closeOnEscape?: boolean;
closeOnBackground?: boolean;
showSpinnerOverlay?: boolean;
children: React.ReactNode;
}
export default (props: Props) => {
const [render, setRender] = useState(props.visible);
const handleEscapeEvent = (e: KeyboardEvent) => {
if (props.dismissable !== false && props.closeOnEscape !== false && e.key === 'Escape') {
setRender(false);
}
};
useEffect(() => setRender(props.visible), [props.visible]);
useEffect(() => {
window.addEventListener('keydown', handleEscapeEvent);
return () => window.removeEventListener('keydown', handleEscapeEvent);
}, [render]);
return (
<CSSTransition
timeout={250}
classNames={'fade'}
in={render}
unmountOnExit={true}
onExited={() => props.onDismissed()}
>
<div className={'modal-mask'} onClick={e => {
if (props.dismissable !== false && props.closeOnBackground !== false) {
e.stopPropagation();
if (e.target === e.currentTarget) {
setRender(false);
}
}
}}>
<div className={'modal-container top'}>
{props.dismissable !== false &&
<div className={'modal-close-icon'} onClick={() => setRender(false)}>
<FontAwesomeIcon icon={faTimes}/>
</div>
}
{props.showSpinnerOverlay &&
<div
className={'absolute w-full h-full rounded flex items-center justify-center'}
style={{ background: 'hsla(211, 10%, 53%, 0.25)' }}
>
<Spinner large={false}/>
</div>
}
<div className={'modal-content p-6'}>
{props.children}
</div>
</div>
</div>
</CSSTransition>
);
};

View file

@ -1,6 +1,11 @@
import React from 'react';
import classNames from 'classnames';
export default ({ large }: { large?: boolean }) => (
<div className={classNames('spinner-circle spinner-white', { 'spinner-lg': large })}/>
export default ({ large, centered }: { large?: boolean; centered?: boolean }) => (
centered ?
<div className={classNames('flex justify-center', { 'm-20': large, 'm-6': !large })}>
<div className={classNames('spinner-circle spinner-white', { 'spinner-lg': large })}/>
</div>
:
<div className={classNames('spinner-circle spinner-white', { 'spinner-lg': large })}/>
);

View file

@ -2,9 +2,9 @@ import React, { createRef } from 'react';
import { Terminal } from 'xterm';
import * as TerminalFit from 'xterm/lib/addons/fit/fit';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import { ApplicationState } from '@/state/types';
import { connect } from 'react-redux';
import { Websocket } from '@/plugins/Websocket';
import { ServerStore } from '@/state/server';
const theme = {
background: 'transparent',
@ -113,8 +113,8 @@ class Console extends React.PureComponent<Readonly<Props>> {
}
export default connect(
(state: ApplicationState) => ({
connected: state.server.socket.connected,
instance: state.server.socket.instance,
(state: ServerStore) => ({
connected: state.socket.connected,
instance: state.socket.instance,
}),
)(Console);

View file

@ -1,10 +1,9 @@
import React from 'react';
import Console from '@/components/server/Console';
import { State, useStoreState } from 'easy-peasy';
import { ApplicationState } from '@/state/types';
import { ServerContext } from '@/state/server';
export default () => {
const status = useStoreState((state: State<ApplicationState>) => state.server.status);
const status = ServerContext.useStoreState(state => state.status.value);
return (
<div className={'my-10 flex'}>

View file

@ -1,13 +1,12 @@
import React, { useEffect } from 'react';
import { Actions, State, useStoreActions, useStoreState } from 'easy-peasy';
import { ApplicationState } from '@/state/types';
import { Websocket } from '@/plugins/Websocket';
import { ServerContext } from '@/state/server';
export default () => {
const server = useStoreState((state: State<ApplicationState>) => state.server.data);
const instance = useStoreState((state: State<ApplicationState>) => state.server.socket.instance);
const setServerStatus = useStoreActions((actions: Actions<ApplicationState>) => actions.server.setServerStatus);
const { setInstance, setConnectionState } = useStoreActions((actions: Actions<ApplicationState>) => actions.server.socket);
const server = ServerContext.useStoreState(state => state.server.data);
const instance = ServerContext.useStoreState(state => state.socket.instance);
const setServerStatus = ServerContext.useStoreActions(actions => actions.status.setServerStatus);
const { setInstance, setConnectionState } = ServerContext.useStoreActions(actions => actions.socket);
useEffect(() => {
// If there is already an instance or there is no server, just exit out of this process
@ -20,7 +19,7 @@ export default () => {
const socket = new Websocket(
`wss://wings.pterodactyl.test:8080/api/servers/${server.uuid}/ws`,
'CC8kHCuMkXPosgzGO6d37wvhNcksWxG6kTrA'
'CC8kHCuMkXPosgzGO6d37wvhNcksWxG6kTrA',
);
socket.on('SOCKET_OPEN', () => setConnectionState(true));

View file

@ -0,0 +1,113 @@
import React, { useState } from 'react';
import { ServerDatabase } from '@/api/server/getServerDatabases';
import Modal from '@/components/elements/Modal';
import { Form, Formik, FormikActions } from 'formik';
import Field from '@/components/elements/Field';
import { object, string } from 'yup';
import createServerDatabase from '@/api/server/createServerDatabase';
import { ServerContext } from '@/state/server';
import { Actions, useStoreActions } from 'easy-peasy';
import { ApplicationStore } from '@/state';
import { httpErrorToHuman } from '@/api/http';
import FlashMessageRender from '@/components/FlashMessageRender';
interface Values {
databaseName: string;
connectionsFrom: string;
}
const schema = object().shape({
databaseName: string()
.required('A database name must be provided.')
.min(5, 'Database name must be at least 5 characters.')
.max(64, 'Database name must not exceed 64 characters.')
.matches(/^[A-Za-z0-9_\-.]{5,64}$/, 'Database name should only contain alphanumeric characters, underscores, dashes, and/or periods.'),
connectionsFrom: string()
.required('A connection value must be provided.')
.matches(/^([1-9]{1,3}|%)(\.([0-9]{1,3}|%))?(\.([0-9]{1,3}|%))?(\.([0-9]{1,3}|%))?$/, 'A valid connection address must be provided.'),
});
export default ({ onCreated }: { onCreated: (database: ServerDatabase) => void }) => {
const [ visible, setVisible ] = useState(false);
const { addFlash, clearFlashes } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
const server = ServerContext.useStoreState(state => state.server.data!);
const submit = (values: Values, { setSubmitting }: FormikActions<Values>) => {
clearFlashes();
createServerDatabase(server.uuid, { ...values })
.then(database => {
onCreated(database);
setVisible(false);
})
.catch(error => {
console.log(error);
addFlash({
key: 'create-database-modal',
type: 'error',
title: 'Error',
message: httpErrorToHuman(error),
});
})
.then(() => setSubmitting(false));
};
return (
<React.Fragment>
<Formik
onSubmit={submit}
initialValues={{ databaseName: '', connectionsFrom: '%' }}
validationSchema={schema}
>
{
({ isSubmitting, resetForm }) => (
<Modal
visible={visible}
dismissable={!isSubmitting}
showSpinnerOverlay={isSubmitting}
onDismissed={() => {
resetForm();
setVisible(false);
}}
>
<FlashMessageRender byKey={'create-database-modal'} className={'mb-6'}/>
<h3 className={'mb-6'}>Create new database</h3>
<Form className={'m-0'}>
<Field
type={'string'}
id={'database_name'}
name={'databaseName'}
label={'Database Name'}
description={'A descriptive name for your database instance.'}
/>
<div className={'mt-6'}>
<Field
type={'string'}
id={'connections_from'}
name={'connectionsFrom'}
label={'Connections From'}
description={'Where connections should be allowed from. Use % for wildcards.'}
/>
</div>
<div className={'mt-6 text-right'}>
<button
type={'button'}
className={'btn btn-sm btn-secondary mr-2'}
onClick={() => setVisible(false)}
>
Cancel
</button>
<button className={'btn btn-sm btn-primary'} type={'submit'}>
Create Database
</button>
</div>
</Form>
</Modal>
)
}
</Formik>
<button className={'btn btn-primary btn-lg'} onClick={() => setVisible(true)}>
New Database
</button>
</React.Fragment>
);
};

View file

@ -0,0 +1,159 @@
import React, { useState } from 'react';
import { ServerDatabase } from '@/api/server/getServerDatabases';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faDatabase } from '@fortawesome/free-solid-svg-icons/faDatabase';
import { faTrashAlt } from '@fortawesome/free-solid-svg-icons/faTrashAlt';
import { faEye } from '@fortawesome/free-solid-svg-icons/faEye';
import classNames from 'classnames';
import Modal from '@/components/elements/Modal';
import { Form, Formik, FormikActions } from 'formik';
import Field from '@/components/elements/Field';
import { object, string } from 'yup';
import FlashMessageRender from '@/components/FlashMessageRender';
import { Actions, useStoreActions } from 'easy-peasy';
import { ApplicationStore } from '@/state';
import { ServerContext } from '@/state/server';
import deleteServerDatabase from '@/api/server/deleteServerDatabase';
import { httpErrorToHuman } from '@/api/http';
interface Props {
database: ServerDatabase;
className?: string;
onDelete: () => void;
}
export default ({ database, className, onDelete }: Props) => {
const [visible, setVisible] = useState(false);
const [connectionVisible, setConnectionVisible] = useState(false);
const { addFlash, clearFlashes } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
const server = ServerContext.useStoreState(state => state.server.data!);
const schema = object().shape({
confirm: string()
.required('The database name must be provided.')
.oneOf([database.name.split('_', 2)[1], database.name], 'The database name must be provided.'),
});
const submit = (values: { confirm: string }, { setSubmitting }: FormikActions<{ confirm: string }>) => {
clearFlashes();
deleteServerDatabase(server.uuid, database.id)
.then(() => {
setVisible(false);
setTimeout(() => onDelete(), 150);
})
.catch(error => {
console.error(error);
setSubmitting(false);
addFlash({
key: 'delete-database-modal',
type: 'error',
title: 'Error',
message: httpErrorToHuman(error),
});
});
};
return (
<React.Fragment>
<Formik
onSubmit={submit}
initialValues={{ confirm: '' }}
validationSchema={schema}
>
{
({ isSubmitting, isValid, resetForm }) => (
<Modal
visible={visible}
dismissable={!isSubmitting}
showSpinnerOverlay={isSubmitting}
onDismissed={() => { setVisible(false); resetForm(); }}
>
<FlashMessageRender byKey={'delete-database-modal'} className={'mb-6'}/>
<h3 className={'mb-6'}>Confirm database deletion</h3>
<p className={'text-sm'}>
Deleting a database is a permanent action, it cannot be undone. This will permanetly
delete the <strong>{database.name}</strong> database and remove all associated data.
</p>
<Form className={'m-0 mt-6'}>
<Field
type={'text'}
id={'confirm_name'}
name={'confirm'}
label={'Confirm Database Name'}
description={'Enter the database name to confirm deletion.'}
/>
<div className={'mt-6 text-right'}>
<button
type={'button'}
className={'btn btn-sm btn-secondary mr-2'}
onClick={() => setVisible(false)}
>
Cancel
</button>
<button
type={'submit'}
className={'btn btn-sm btn-red'}
disabled={!isValid}
>
Delete Database
</button>
</div>
</Form>
</Modal>
)
}
</Formik>
<Modal visible={connectionVisible} onDismissed={() => setConnectionVisible(false)}>
<h3 className={'mb-6'}>Database connection details</h3>
<div>
<label className={'input-dark-label'}>Password</label>
<input type={'text'} className={'input-dark'} readOnly={true} value={database.password}/>
</div>
<div className={'mt-6'}>
<label className={'input-dark-label'}>JBDC Connection String</label>
<input
type={'text'}
className={'input-dark'}
readOnly={true}
value={`jdbc:mysql://${database.username}:${database.password}@${database.connectionString}/${database.name}`}
/>
</div>
<div className={'mt-6 text-right'}>
<button className={'btn btn-sm btn-secondary'} onClick={() => setConnectionVisible(false)}>
Close
</button>
</div>
</Modal>
<div className={classNames('grey-row-box no-hover', className)}>
<div className={'icon'}>
<FontAwesomeIcon icon={faDatabase}/>
</div>
<div className={'flex-1 ml-4'}>
<p className={'text-lg'}>{database.name}</p>
</div>
<div className={'ml-6'}>
<p className={'text-center text-xs text-neutral-500 uppercase mb-1 select-none'}>Endpoint:</p>
<p className={'text-center text-sm'}>{database.connectionString}</p>
</div>
<div className={'ml-6'}>
<p className={'text-center text-xs text-neutral-500 uppercase mb-1 select-none'}>
Connections From:
</p>
<p className={'text-center text-sm'}>{database.allowConnectionsFrom}</p>
</div>
<div className={'ml-6'}>
<p className={'text-center text-xs text-neutral-500 uppercase mb-1 select-none'}>Username:</p>
<p className={'text-center text-sm'}>{database.username}</p>
</div>
<div className={'ml-6'}>
<button className={'btn btn-sm btn-secondary mr-2'} onClick={() => setConnectionVisible(true)}>
<FontAwesomeIcon icon={faEye} fixedWidth={true}/>
</button>
<button className={'btn btn-sm btn-secondary btn-red'} onClick={() => setVisible(true)}>
<FontAwesomeIcon icon={faTrashAlt} fixedWidth={true}/>
</button>
</div>
</div>
</React.Fragment>
);
};

View file

@ -0,0 +1,64 @@
import React, { useEffect, useState } from 'react';
import getServerDatabases, { ServerDatabase } from '@/api/server/getServerDatabases';
import { ServerContext } from '@/state/server';
import { Actions, useStoreActions } from 'easy-peasy';
import { ApplicationStore } from '@/state';
import { httpErrorToHuman } from '@/api/http';
import FlashMessageRender from '@/components/FlashMessageRender';
import DatabaseRow from '@/components/server/databases/DatabaseRow';
import Spinner from '@/components/elements/Spinner';
import { CSSTransition } from 'react-transition-group';
import CreateDatabaseButton from '@/components/server/databases/CreateDatabaseButton';
export default () => {
const [ loading, setLoading ] = useState(true);
const [ databases, setDatabases ] = useState<ServerDatabase[]>([]);
const server = ServerContext.useStoreState(state => state.server.data!);
const { addFlash, clearFlashes } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
useEffect(() => {
clearFlashes('databases');
getServerDatabases(server.uuid)
.then(databases => {
setDatabases(databases);
setLoading(false);
})
.catch(error => addFlash({
key: 'databases',
title: 'Error',
message: httpErrorToHuman(error),
type: 'error',
}));
}, []);
return (
<div className={'my-10 mb-6'}>
<FlashMessageRender byKey={'databases'}/>
{loading ?
<Spinner large={true} centered={true}/>
:
<CSSTransition classNames={'fade'} timeout={250}>
<React.Fragment>
{databases.length > 0 ?
databases.map((database, index) => (
<DatabaseRow
key={database.id}
database={database}
onDelete={() => setDatabases(s => [ ...s.filter(d => d.id !== database.id) ])}
className={index > 0 ? 'mt-1' : undefined}
/>
))
:
<p className={'text-center text-sm text-neutral-200'}>
It looks like you have no databases. Click the button below to create one now.
</p>
}
<div className={'mt-6 flex justify-end'}>
<CreateDatabaseButton onCreated={database => setDatabases(s => [ ...s, database ])}/>
</div>
</React.Fragment>
</CSSTransition>
}
</div>
);
};

View file

@ -3,14 +3,16 @@ import { NavLink, Route, RouteComponentProps, Switch } from 'react-router-dom';
import NavigationBar from '@/components/NavigationBar';
import ServerConsole from '@/components/server/ServerConsole';
import TransitionRouter from '@/TransitionRouter';
import { Actions, State, useStoreActions, useStoreState } from 'easy-peasy';
import { ApplicationState } from '@/state/types';
import Spinner from '@/components/elements/Spinner';
import WebsocketHandler from '@/components/server/WebsocketHandler';
import { ServerContext } from '@/state/server';
import { Provider } from 'react-redux';
import DatabasesContainer from '@/components/server/databases/DatabasesContainer';
export default ({ match, location }: RouteComponentProps<{ id: string }>) => {
const server = useStoreState((state: State<ApplicationState>) => state.server.data);
const { clearServerState, getServer } = useStoreActions((actions: Actions<ApplicationState>) => actions.server);
const ServerRouter = ({ match, location }: RouteComponentProps<{ id: string }>) => {
const server = ServerContext.useStoreState(state => state.server.data);
const getServer = ServerContext.useStoreActions(actions => actions.server.getServer);
const clearServerState = ServerContext.useStoreActions(actions => actions.clearServerState);
if (!server) {
getServer(match.params.id);
@ -31,22 +33,31 @@ export default ({ match, location }: RouteComponentProps<{ id: string }>) => {
</div>
</div>
</div>
<TransitionRouter>
<div className={'w-full mx-auto'} style={{ maxWidth: '1200px' }}>
{!server ?
<div className={'flex justify-center m-20'}>
<Spinner large={true}/>
</div>
:
<React.Fragment>
<WebsocketHandler/>
<Switch location={location}>
<Route path={`${match.path}`} component={ServerConsole} exact/>
</Switch>
</React.Fragment>
}
</div>
</TransitionRouter>
<Provider store={ServerContext.useStore()}>
<TransitionRouter>
<div className={'w-full mx-auto'} style={{ maxWidth: '1200px' }}>
{!server ?
<div className={'flex justify-center m-20'}>
<Spinner large={true}/>
</div>
:
<React.Fragment>
<WebsocketHandler/>
<Switch location={location}>
<Route path={`${match.path}`} component={ServerConsole} exact/>
<Route path={`${match.path}/databases`} component={DatabasesContainer}/>
</Switch>
</React.Fragment>
}
</div>
</TransitionRouter>
</Provider>
</React.Fragment>
);
};
export default (props: RouteComponentProps<any>) => (
<ServerContext.Provider>
<ServerRouter {...props}/>
</ServerContext.Provider>
);

View file

@ -0,0 +1,28 @@
import { Action, action } from 'easy-peasy';
import { FlashMessageType } from '@/components/MessageBox';
export interface FlashStore {
items: FlashMessage[];
addFlash: Action<FlashStore, FlashMessage>;
clearFlashes: Action<FlashStore, string | void>;
}
export interface FlashMessage {
id?: string;
key?: string;
type: FlashMessageType;
title?: string;
message: string;
}
const flashes: FlashStore = {
items: [],
addFlash: action((state, payload) => {
state.items.push(payload);
}),
clearFlashes: action((state, payload) => {
state.items = payload ? state.items.filter(flashes => flashes.key !== payload) : [];
}),
};
export default flashes;

View file

@ -1,13 +1,15 @@
import { createStore } from 'easy-peasy';
import { ApplicationState } from '@/state/types';
import flashes from '@/state/models/flashes';
import user from '@/state/models/user';
import server from '@/state/models/server';
import flashes, { FlashStore } from '@/state/flashes';
import user, { UserStore } from '@/state/user';
const state: ApplicationState = {
export interface ApplicationStore {
flashes: FlashStore;
user: UserStore;
}
const state: ApplicationStore = {
flashes,
user,
server,
};
export const store = createStore(state);

View file

@ -1,14 +0,0 @@
import { action } from 'easy-peasy';
import { FlashState } from '@/state/types';
const flashes: FlashState = {
items: [],
addFlash: action((state, payload) => {
state.items.push(payload);
}),
clearFlashes: action((state, payload) => {
state.items = payload ? state.items.filter(flashes => flashes.key !== payload) : [];
}),
};
export default flashes;

View file

@ -1,43 +0,0 @@
import getServer, { Server } from '@/api/server/getServer';
import { action, Action, thunk, Thunk } from 'easy-peasy';
import socket, { SocketState } from './socket';
export type ServerStatus = 'offline' | 'starting' | 'stopping' | 'running';
export interface ServerState {
data?: Server;
status: ServerStatus;
socket: SocketState;
getServer: Thunk<ServerState, string, {}, any, Promise<void>>;
setServer: Action<ServerState, Server>;
setServerStatus: Action<ServerState, ServerStatus>;
clearServerState: Action<ServerState>;
}
const server: ServerState = {
socket,
status: 'offline',
getServer: thunk(async (actions, payload) => {
const server = await getServer(payload);
actions.setServer(server);
}),
setServer: action((state, payload) => {
state.data = payload;
}),
setServerStatus: action((state, payload) => {
state.status = payload;
}),
clearServerState: action(state => {
state.data = undefined;
if (state.socket.instance) {
state.socket.instance.removeAllListeners();
state.socket.instance.close();
}
state.socket.instance = null;
state.socket.connected = false;
}),
};
export default server;

View file

@ -0,0 +1,57 @@
import getServer, { Server } from '@/api/server/getServer';
import { action, Action, createContextStore, thunk, Thunk } from 'easy-peasy';
import socket, { SocketStore } from './socket';
export type ServerStatus = 'offline' | 'starting' | 'stopping' | 'running';
interface ServerDataStore {
data?: Server;
getServer: Thunk<ServerDataStore, string, {}, any, Promise<void>>;
setServer: Action<ServerDataStore, Server>;
}
const server: ServerDataStore = {
getServer: thunk(async (actions, payload) => {
const server = await getServer(payload);
actions.setServer(server);
}),
setServer: action((state, payload) => {
state.data = payload;
}),
};
interface ServerStatusStore {
value: ServerStatus;
setServerStatus: Action<ServerStatusStore, ServerStatus>;
}
const status: ServerStatusStore = {
value: 'offline',
setServerStatus: action((state, payload) => {
state.value = payload;
}),
};
export interface ServerStore {
server: ServerDataStore;
socket: SocketStore;
status: ServerStatusStore;
clearServerState: Action<ServerStore>;
}
export const ServerContext = createContextStore<ServerStore>({
server,
socket,
status,
clearServerState: action(state => {
state.server.data = undefined;
if (state.socket.instance) {
state.socket.instance.removeAllListeners();
state.socket.instance.close();
}
state.socket.instance = null;
state.socket.connected = false;
}),
}, { name: 'ServerStore' });

View file

@ -1,14 +1,14 @@
import { Action, action } from 'easy-peasy';
import { Websocket } from '@/plugins/Websocket';
export interface SocketState {
export interface SocketStore {
instance: Websocket | null;
connected: boolean;
setInstance: Action<SocketState, Websocket | null>;
setConnectionState: Action<SocketState, boolean>;
setInstance: Action<SocketStore, Websocket | null>;
setConnectionState: Action<SocketStore, boolean>;
}
const socket: SocketState = {
const socket: SocketStore = {
instance: null,
connected: false,
setInstance: action((state, payload) => {

View file

@ -1,24 +0,0 @@
import { FlashMessageType } from '@/components/MessageBox';
import { Action } from 'easy-peasy';
import { UserState } from '@/state/models/user';
import { ServerState } from '@/state/models/server';
export interface ApplicationState {
flashes: FlashState;
user: UserState;
server: ServerState;
}
export interface FlashState {
items: FlashMessage[];
addFlash: Action<FlashState, FlashMessage>;
clearFlashes: Action<FlashState, string | void>;
}
export interface FlashMessage {
id?: string;
key?: string;
type: FlashMessageType;
title?: string;
message: string;
}

View file

@ -12,14 +12,14 @@ export interface UserData {
updatedAt: Date;
}
export interface UserState {
export interface UserStore {
data?: UserData;
setUserData: Action<UserState, UserData>;
updateUserData: Action<UserState, Partial<UserData>>;
updateUserEmail: Thunk<UserState, { email: string; password: string }, any, {}, Promise<void>>;
setUserData: Action<UserStore, UserData>;
updateUserData: Action<UserStore, Partial<UserData>>;
updateUserEmail: Thunk<UserStore, { email: string; password: string }, any, {}, Promise<void>>;
}
const user: UserState = {
const user: UserStore = {
data: undefined,
setUserData: action((state, payload) => {
state.data = payload;

View file

@ -7,114 +7,15 @@ code.clean {
display: inline-block;
}
/**
* Indicators for server online status.
*/
.indicator {
@apply .bg-neutral-800 .border .border-primary-500;
border-radius: 50%;
width: 16px;
height: 16px;
.grey-row-box {
@apply .flex .rounded .no-underline .text-neutral-200 .items-center .bg-neutral-700 .p-4 .border .border-transparent;
transition: border-color 150ms linear;
&.online {
@apply .bg-green-600 .border-green-500;
animation: onlineblink 2s infinite alternate;
&:not(.no-hover):hover {
@apply .border-neutral-500;
}
&.offline {
@apply .bg-green-600 .border-red-500;
animation: offlineblink 2s infinite alternate;
}
}
/**
* Usage indicator labels for the server listing.
*/
.usage {
@apply .flex-1 .text-center .relative;
& > .indicator-title {
@apply .text-xs .uppercase .font-hairline .bg-white .absolute .text-primary-500;
margin-top:-9px;
padding: 0 8px;
left: 50%;
transform: translate(-50%, 0);
}
}
/**
* Styling for elements that contain the core page content.
*/
.content-box {
@apply .bg-white .p-6 .rounded .shadow .border .border-neutral-100;
}
/**
* Flex boxes for server listing on user dashboard.
*/
.server-card-container {
@apply .mb-4 .w-full;
@screen md {
@apply .w-1/2 .pr-4;
&:nth-of-type(2n) {
@apply .pr-0;
}
}
@screen lg {
@apply .w-1/3 .pr-4;
&:nth-of-type(2n) {
@apply .pr-4;
}
&:nth-of-type(3n) {
@apply .pr-0;
}
}
& > div {
@apply .flex .flex-col;
transition: box-shadow 150ms ease-in;
&:hover {
@apply .shadow-md;
}
}
& > div > .server-card {
@apply .flex .flex-col .p-4 .border .border-t-4 .border-neutral-100 .bg-white;
transition: all 100ms ease-in;
& .identifier-icon {
@apply .select-none .inline-block .rounded-full .text-white .text-center .leading-none .justify-center .w-8 .h-8 .mr-2 .flex .flex-row .items-center;
}
& a, & a:visited {
@apply .no-underline .text-neutral-800;
}
}
& > div > .footer {
@apply .border .border-neutral-100 .border-t-0 .bg-neutral-50 .shadow-inner;
}
}
.pillbox {
@apply .rounded-full .px-2 .py-1 .text-white .font-medium .leading-none .text-xs;
}
.server-search {
@apply .w-full .my-4;
& > input[type="text"] {
@apply .w-full .p-4 .rounded .border .border-neutral-200 .text-neutral-500;
transition: border 150ms ease-in;
&:focus {
@apply .border-primary-500;
}
& > div.icon {
@apply .rounded-full .bg-neutral-500 .p-3;
}
}

View file

@ -1,19 +1,19 @@
.modal-mask {
@apply .fixed .pin .z-50 .overflow-auto .flex;
background: rgba(0, 0, 0, 0.7);
background: rgba(0, 0, 0, 0.70);
transition: opacity 250ms ease;
& > .modal-container {
@apply .relative .w-full .max-w-md .m-auto .flex-col .flex;
&.top {
margin-top: 15%;
margin-top: 10%;
}
& > .modal-close-icon {
@apply .absolute .pin-r .p-2 .text-white .cursor-pointer .opacity-50;
transition: opacity 150ms linear, transform 150ms ease-in;
top: -2.5rem;
top: -2rem;
&:hover {
@apply .opacity-100;
@ -22,7 +22,7 @@
}
& > .modal-content {
@apply .bg-white .rounded .shadow-md;
@apply .bg-neutral-800 .rounded .shadow-md;
transition: all 250ms ease;
}

View file

@ -13,5 +13,5 @@ h1, h2, h3, h4, h5, h6 {
}
p {
@apply .text-neutral-200;
@apply .text-neutral-200 .leading-snug;
}

View file

@ -290,7 +290,9 @@ module.exports = {
leading: {
'none': 1,
'tight': 1.25,
'snug': 1.375,
'normal': 1.5,
'relaxed': 1.625,
'loose': 2,
},

View file

@ -135,7 +135,7 @@ module.exports = {
},
plugins: plugins,
optimization: {
minimize: true,
minimize: isProduction,
minimizer: [
new TerserPlugin({
cache: true,