Merge branch 'develop' into develop

This commit is contained in:
Dane Everitt 2020-10-31 13:47:12 -07:00 committed by GitHub
commit 665a4dd8a4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
115 changed files with 3434 additions and 1970 deletions

View file

@ -14,7 +14,7 @@ return [
'deleted' => 'Successfully deleted the requested location.',
],
'user' => [
'search_users' => 'Enter a Username, UUID, or Email Address',
'search_users' => 'Enter a Username, User ID, or Email Address',
'select_search_user' => 'ID of user to delete (Enter \'0\' to re-search)',
'deleted' => 'User successfully deleted from the Panel.',
'confirm_delete' => 'Are you sure you want to delete this user from the Panel?',

View file

@ -1,89 +0,0 @@
parser: "@typescript-eslint/parser"
parserOptions:
ecmaVersion: 6
ecmaFeatures:
jsx: true
project: "./tsconfig.json"
tsconfigRootDir: "./"
settings:
react:
pragma: "React"
version: "detect"
linkComponents:
- name: Link
linkAttribute: to
- name: NavLink
linkAttribute: to
env:
browser: true
es6: true
plugins:
- "react"
- "react-hooks"
- "@typescript-eslint"
extends:
- "standard"
- "plugin:react/recommended"
- "plugin:@typescript-eslint/recommended"
rules:
quotes:
- warn
- single
indent:
- warn
- 4
- SwitchCase: 1
semi:
- warn
- always
comma-dangle:
- warn
- always-multiline
spaced-comment:
- warn
array-bracket-spacing:
- warn
- always
"react-hooks/rules-of-hooks":
- error
"react-hooks/exhaustive-deps": 0
"@typescript-eslint/explicit-function-return-type": 0
"@typescript-eslint/explicit-member-accessibility": 0
"@typescript-eslint/ban-ts-ignore": 0
"@typescript-eslint/no-explicit-any": 0
"@typescript-eslint/no-non-null-assertion": 0
"@typescript-eslint/ban-ts-comment": 0
# This would be nice to have, but don't want to deal with the warning spam at the moment.
"@typescript-eslint/explicit-module-boundary-types": 0
no-restricted-imports:
- error
- paths:
- name: styled-components
message: Please import from styled-components/macro.
patterns:
- "!styled-components/macro"
# Not sure, this rule just doesn't work right and is protected by our use of Typescript anyways
# so I'm just not going to worry about it.
"react/prop-types": 0
"react/display-name": 0
"react/jsx-indent-props":
- warn
- 4
"react/jsx-boolean-value":
- warn
- never
"react/jsx-closing-bracket-location":
- 1
- "line-aligned"
"react/jsx-closing-tag-location": 1
overrides:
- files:
- "**/*.tsx"
rules:
operator-linebreak:
- error
- before
- overrides:
"&&": "after"
"?": "ignore"
":": "ignore"

View file

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

View file

@ -1,18 +1,10 @@
import http from '@/api/http';
export default (uuid: string, file: string, content: string): Promise<void> => {
return new Promise((resolve, reject) => {
http.post(
`/api/client/servers/${uuid}/files/write`,
content,
{
params: { file },
headers: {
'Content-Type': 'text/plain',
},
},
)
.then(() => resolve())
.catch(reject);
export default async (uuid: string, file: string, content: string): Promise<void> => {
await http.post(`/api/client/servers/${uuid}/files/write`, content, {
params: { file },
headers: {
'Content-Type': 'text/plain',
},
});
};

View file

@ -0,0 +1,4 @@
import http from '@/api/http';
export default async (server: string, schedule: number): Promise<void> =>
await http.post(`/api/client/servers/${server}/schedules/${schedule}/execute`);

View file

@ -63,7 +63,7 @@ export default () => {
<FontAwesomeIcon icon={faUserCircle}/>
</NavLink>
{rootAdmin &&
<a href={'/admin'} target={'_blank'} rel={'noreferrer'}>
<a href={'/admin'} rel={'noreferrer'}>
<FontAwesomeIcon icon={faCogs}/>
</a>
}

View file

@ -1,5 +1,5 @@
import * as React from 'react';
import { useRef, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { Link } from 'react-router-dom';
import requestPasswordResetEmail from '@/api/auth/requestPasswordResetEmail';
import { httpErrorToHuman } from '@/api/http';
@ -24,13 +24,23 @@ export default () => {
const { clearFlashes, addFlash } = useFlash();
const { enabled: recaptchaEnabled, siteKey } = useStoreState(state => state.settings.data!.recaptcha);
useEffect(() => {
clearFlashes();
}, []);
const handleSubmission = ({ email }: Values, { setSubmitting, resetForm }: 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));
ref.current!.execute().catch(error => {
console.error(error);
setSubmitting(false);
addFlash({ type: 'error', title: 'Error', message: httpErrorToHuman(error) });
});
return;
}
@ -43,7 +53,12 @@ export default () => {
console.error(error);
addFlash({ type: 'error', title: 'Error', message: httpErrorToHuman(error) });
})
.then(() => setSubmitting(false));
.then(() => {
setToken('');
if (ref.current) ref.current.reset();
setSubmitting(false);
});
};
return (

View file

@ -1,7 +1,6 @@
import React, { useState } from 'react';
import { Link, RouteComponentProps } from 'react-router-dom';
import loginCheckpoint from '@/api/auth/loginCheckpoint';
import { httpErrorToHuman } from '@/api/http';
import LoginFormContainer from '@/components/auth/LoginFormContainer';
import { ActionCreator } from 'easy-peasy';
import { StaticContext } from 'react-router';
@ -20,8 +19,7 @@ interface Values {
type OwnProps = RouteComponentProps<Record<string, string | undefined>, StaticContext, { token?: string }>
type Props = OwnProps & {
addError: ActionCreator<FlashStore['addError']['payload']>;
clearFlashes: ActionCreator<FlashStore['clearFlashes']['payload']>;
clearAndAddHttpError: ActionCreator<FlashStore['clearAndAddHttpError']['payload']>;
}
const LoginCheckpointContainer = () => {
@ -79,9 +77,7 @@ const LoginCheckpointContainer = () => {
};
const EnhancedForm = withFormik<Props, Values>({
handleSubmit: ({ code, recoveryCode }, { setSubmitting, props: { addError, clearFlashes, location } }) => {
clearFlashes();
handleSubmit: ({ code, recoveryCode }, { setSubmitting, props: { clearAndAddHttpError, location } }) => {
loginCheckpoint(location.state?.token || '', code, recoveryCode)
.then(response => {
if (response.complete) {
@ -95,7 +91,7 @@ const EnhancedForm = withFormik<Props, Values>({
.catch(error => {
console.error(error);
setSubmitting(false);
addError({ message: httpErrorToHuman(error) });
clearAndAddHttpError({ error });
});
},
@ -106,7 +102,7 @@ const EnhancedForm = withFormik<Props, Values>({
})(LoginCheckpointContainer);
export default ({ history, location, ...props }: OwnProps) => {
const { addError, clearFlashes } = useFlash();
const { clearAndAddHttpError } = useFlash();
if (!location.state?.token) {
history.replace('/auth/login');
@ -115,8 +111,7 @@ export default ({ history, location, ...props }: OwnProps) => {
}
return <EnhancedForm
addError={addError}
clearFlashes={clearFlashes}
clearAndAddHttpError={clearAndAddHttpError}
history={history}
location={location}
{...props}

View file

@ -1,4 +1,4 @@
import React, { useRef, useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { Link, RouteComponentProps } from 'react-router-dom';
import login from '@/api/auth/login';
import LoginFormContainer from '@/components/auth/LoginFormContainer';
@ -23,13 +23,23 @@ const LoginContainer = ({ history }: RouteComponentProps) => {
const { clearFlashes, clearAndAddHttpError } = useFlash();
const { enabled: recaptchaEnabled, siteKey } = useStoreState(state => state.settings.data!.recaptcha);
useEffect(() => {
clearFlashes();
}, []);
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));
ref.current!.execute().catch(error => {
console.error(error);
setSubmitting(false);
clearAndAddHttpError({ error });
});
return;
}
@ -46,6 +56,9 @@ const LoginContainer = ({ history }: RouteComponentProps) => {
.catch(error => {
console.error(error);
setToken('');
if (ref.current) ref.current.reset();
setSubmitting(false);
clearAndAddHttpError({ error });
});
@ -63,23 +76,23 @@ const LoginContainer = ({ history }: RouteComponentProps) => {
{({ isSubmitting, setSubmitting, submitForm }) => (
<LoginFormContainer title={'Login to Continue'} css={tw`w-full flex`}>
<Field
light
type={'text'}
label={'Username or Email'}
id={'username'}
name={'username'}
light
disabled={isSubmitting}
/>
<div css={tw`mt-6`}>
<Field
light
type={'password'}
label={'Password'}
id={'password'}
name={'password'}
light
disabled={isSubmitting}
/>
</div>
<div css={tw`mt-6`}>
<Button type={'submit'} size={'xlarge'} isLoading={isSubmitting}>
<Button type={'submit'} size={'xlarge'} isLoading={isSubmitting} disabled={isSubmitting}>
Login
</Button>
</div>

View file

@ -21,7 +21,7 @@ export default () => {
const { data: servers, error } = useSWR<PaginatedResult<Server>>(
[ '/api/client/servers', showOnlyAdmin, page ],
() => getServers({ onlyAdmin: showOnlyAdmin, page }),
() => getServers({ page, type: showOnlyAdmin ? 'admin' : undefined }),
);
useEffect(() => {

View file

@ -41,8 +41,9 @@ const StatusIndicatorBox = styled(GreyRowBox)<{ $status: ServerPowerState | unde
export default ({ server, className }: { server: Server; className?: string }) => {
const interval = useRef<number>(null);
const [ stats, setStats ] = useState<ServerStats | null>(null);
const [ statsError, setStatsError ] = useState(false);
const [ isSuspended, setIsSuspended ] = useState(server.isSuspended);
const [ stats, setStats ] = useState<ServerStats | null>(null);
const getStats = () => {
setStatsError(false);
@ -55,6 +56,14 @@ export default ({ server, className }: { server: Server; className?: string }) =
};
useEffect(() => {
setIsSuspended(stats?.isSuspended || server.isSuspended);
}, [ stats?.isSuspended, server.isSuspended ]);
useEffect(() => {
// Don't waste a HTTP request if there is nothing important to show to the user because
// the server is suspended.
if (isSuspended) return;
getStats().then(() => {
// @ts-ignore
interval.current = setInterval(() => getStats(), 20000);
@ -63,7 +72,7 @@ export default ({ server, className }: { server: Server; className?: string }) =
return () => {
interval.current && clearInterval(interval.current);
};
}, []);
}, [ isSuspended ]);
const alarms = { cpu: false, memory: false, disk: false };
if (stats) {
@ -101,9 +110,13 @@ export default ({ server, className }: { server: Server; className?: string }) =
</p>
</div>
<div css={tw`hidden col-span-7 lg:col-span-4 sm:flex items-baseline justify-center`}>
{!stats ?
!statsError ?
<Spinner size={'small'}/>
{(!stats || isSuspended) ?
isSuspended ?
<div css={tw`flex-1 text-center`}>
<span css={tw`bg-red-500 rounded px-2 py-1 text-red-100 text-xs`}>
{server.isSuspended ? 'Suspended' : 'Connection Error'}
</span>
</div>
:
server.isInstalling ?
<div css={tw`flex-1 text-center`}>
@ -112,11 +125,7 @@ export default ({ server, className }: { server: Server; className?: string }) =
</span>
</div>
:
<div css={tw`flex-1 text-center`}>
<span css={tw`bg-red-500 rounded px-2 py-1 text-red-100 text-xs`}>
{server.isSuspended ? 'Suspended' : 'Connection Error'}
</span>
</div>
<Spinner size={'small'}/>
:
<React.Fragment>
<div css={tw`flex-1 flex md:ml-4 sm:flex hidden justify-center`}>

View file

@ -7,7 +7,6 @@ import { object, string } from 'yup';
import { Actions, useStoreActions } from 'easy-peasy';
import { ApplicationStore } from '@/state';
import disableAccountTwoFactor from '@/api/account/disableAccountTwoFactor';
import { httpErrorToHuman } from '@/api/http';
import tw from 'twin.macro';
import Button from '@/components/elements/Button';
@ -16,11 +15,10 @@ interface Values {
}
export default ({ ...props }: RequiredModalProps) => {
const { addError, clearFlashes } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
const { clearAndAddHttpError } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
const updateUserData = useStoreActions((actions: Actions<ApplicationStore>) => actions.user.updateUserData);
const submit = ({ password }: Values, { setSubmitting }: FormikHelpers<Values>) => {
clearFlashes('account:two-factor');
disableAccountTwoFactor(password)
.then(() => {
updateUserData({ useTotp: false });
@ -29,7 +27,7 @@ export default ({ ...props }: RequiredModalProps) => {
.catch(error => {
console.error(error);
addError({ message: httpErrorToHuman(error), key: 'account:two-factor' });
clearAndAddHttpError({ error, key: 'account:two-factor' });
setSubmitting(false);
});
};

View file

@ -6,7 +6,6 @@ import getTwoFactorTokenUrl from '@/api/account/getTwoFactorTokenUrl';
import enableAccountTwoFactor from '@/api/account/enableAccountTwoFactor';
import { Actions, useStoreActions } from 'easy-peasy';
import { ApplicationStore } from '@/state';
import { httpErrorToHuman } from '@/api/http';
import FlashMessageRender from '@/components/FlashMessageRender';
import Field from '@/components/elements/Field';
import tw from 'twin.macro';
@ -22,20 +21,18 @@ export default ({ onDismissed, ...props }: RequiredModalProps) => {
const [ recoveryTokens, setRecoveryTokens ] = useState<string[]>([]);
const updateUserData = useStoreActions((actions: Actions<ApplicationStore>) => actions.user.updateUserData);
const { addError, clearFlashes } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
const { clearAndAddHttpError } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
useEffect(() => {
clearFlashes('account:two-factor');
getTwoFactorTokenUrl()
.then(setToken)
.catch(error => {
console.error(error);
addError({ message: httpErrorToHuman(error), key: 'account:two-factor' });
clearAndAddHttpError({ error, key: 'account:two-factor' });
});
}, []);
const submit = ({ code }: Values, { setSubmitting }: FormikHelpers<Values>) => {
clearFlashes('account:two-factor');
enableAccountTwoFactor(code)
.then(tokens => {
setRecoveryTokens(tokens);
@ -43,7 +40,7 @@ export default ({ onDismissed, ...props }: RequiredModalProps) => {
.catch(error => {
console.error(error);
addError({ message: httpErrorToHuman(error), key: 'account:two-factor' });
clearAndAddHttpError({ error, key: 'account:two-factor' });
})
.then(() => setSubmitting(false));
};

View file

@ -9,7 +9,6 @@ import InputSpinner from '@/components/elements/InputSpinner';
import getServers from '@/api/getServers';
import { Server } from '@/api/server/getServer';
import { ApplicationStore } from '@/state';
import { httpErrorToHuman } from '@/api/http';
import { Link } from 'react-router-dom';
import styled from 'styled-components/macro';
import tw from 'twin.macro';
@ -47,88 +46,86 @@ const SearchWatcher = () => {
export default ({ ...props }: Props) => {
const ref = useRef<HTMLInputElement>(null);
const [ loading, setLoading ] = useState(false);
const [ servers, setServers ] = useState<Server[]>([]);
const isAdmin = useStoreState(state => state.user.data!.rootAdmin);
const { addError, clearFlashes } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
const [ servers, setServers ] = useState<Server[]>([]);
const { clearAndAddHttpError, clearFlashes } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
const search = debounce(({ term }: Values, { setSubmitting }: FormikHelpers<Values>) => {
setLoading(true);
setSubmitting(false);
clearFlashes('search');
getServers({ query: term })
// if (ref.current) ref.current.focus();
getServers({ query: term, type: isAdmin ? 'admin-all' : undefined })
.then(servers => setServers(servers.items.filter((_, index) => index < 5)))
.catch(error => {
console.error(error);
addError({ key: 'search', message: httpErrorToHuman(error) });
clearAndAddHttpError({ key: 'search', error });
})
.then(() => setLoading(false));
.then(() => setSubmitting(false))
.then(() => ref.current?.focus());
}, 500);
useEffect(() => {
if (props.visible) {
setTimeout(() => ref.current?.focus(), 250);
if (ref.current) ref.current.focus();
}
}, [ props.visible ]);
// Formik does not support an innerRef on custom components.
const InputWithRef = (props: any) => <Input autoFocus {...props} ref={ref}/>;
return (
<Formik
onSubmit={search}
validationSchema={object().shape({
term: string()
.min(3, 'Please enter at least three characters to begin searching.')
.required('A search term must be provided.'),
term: string().min(3, 'Please enter at least three characters to begin searching.'),
})}
initialValues={{ term: '' } as Values}
>
<Modal {...props}>
<Form>
<FormikFieldWrapper
name={'term'}
label={'Search term'}
description={
isAdmin
? 'Enter a server name, user email, or uuid to begin searching.'
: 'Enter a server name to begin searching.'
{({ isSubmitting }) => (
<Modal {...props}>
<Form>
<FormikFieldWrapper
name={'term'}
label={'Search term'}
description={'Enter a server name, uuid, or allocation to begin searching.'}
>
<SearchWatcher/>
<InputSpinner visible={isSubmitting}>
<Field as={InputWithRef} name={'term'}/>
</InputSpinner>
</FormikFieldWrapper>
</Form>
{servers.length > 0 &&
<div css={tw`mt-6`}>
{
servers.map(server => (
<ServerResult
key={server.uuid}
to={`/server/${server.id}`}
onClick={() => props.onDismissed()}
>
<div css={tw`flex-1 mr-4`}>
<p css={tw`text-sm`}>{server.name}</p>
<p css={tw`mt-1 text-xs text-neutral-400`}>
{
server.allocations.filter(alloc => alloc.isDefault).map(allocation => (
<span key={allocation.ip + allocation.port.toString()}>{allocation.alias || allocation.ip}:{allocation.port}</span>
))
}
</p>
</div>
<div css={tw`flex-none text-right`}>
<span css={tw`text-xs py-1 px-2 bg-cyan-800 text-cyan-100 rounded`}>
{server.node}
</span>
</div>
</ServerResult>
))
}
>
<SearchWatcher/>
<InputSpinner visible={loading}>
<Field as={Input} innerRef={ref} name={'term'}/>
</InputSpinner>
</FormikFieldWrapper>
</Form>
{servers.length > 0 &&
<div css={tw`mt-6`}>
{
servers.map(server => (
<ServerResult
key={server.uuid}
to={`/server/${server.id}`}
onClick={() => props.onDismissed()}
>
<div>
<p css={tw`text-sm`}>{server.name}</p>
<p css={tw`mt-1 text-xs text-neutral-400`}>
{
server.allocations.filter(alloc => alloc.isDefault).map(allocation => (
<span key={allocation.ip + allocation.port.toString()}>{allocation.alias || allocation.ip}:{allocation.port}</span>
))
}
</p>
</div>
<div css={tw`flex-1 text-right`}>
<span css={tw`text-xs py-1 px-2 bg-cyan-800 text-cyan-100 rounded`}>
{server.node}
</span>
</div>
</ServerResult>
))
</div>
}
</div>
}
</Modal>
</Modal>
)}
</Formik>
);
};

View file

@ -57,8 +57,8 @@ const ButtonStyle = styled.button<Omit<Props, 'isLoading'>>`
`};
`};
${props => props.size === 'xsmall' && tw`p-2 text-xs`};
${props => (!props.size || props.size === 'small') && tw`p-3`};
${props => props.size === 'xsmall' && tw`px-2 py-1 text-xs`};
${props => (!props.size || props.size === 'small') && tw`px-4 py-2`};
${props => props.size === 'large' && tw`p-4 text-sm`};
${props => props.size === 'xlarge' && tw`p-4 w-full`};

View file

@ -0,0 +1,63 @@
import React, { useCallback, useEffect, useState } from 'react';
import CopyToClipboard from 'react-copy-to-clipboard';
import tw from 'twin.macro';
import styled, { keyframes } from 'styled-components/macro';
import Fade from '@/components/elements/Fade';
import { SwitchTransition } from 'react-transition-group';
const fade = keyframes`
from { opacity: 0 }
to { opacity: 1 }
`;
const Toast = styled.div`
${tw`fixed z-50 bottom-0 left-0 mb-4 w-full flex justify-end pr-4`};
animation: ${fade} 250ms linear;
& > div {
${tw`rounded px-4 py-2 text-white bg-neutral-800 border border-neutral-900`};
}
`;
const CopyOnClick: React.FC<{ text: string }> = ({ text, children }) => {
const [ copied, setCopied ] = useState(false);
useEffect(() => {
if (!copied) return;
const timeout = setTimeout(() => {
setCopied(false);
}, 2500);
return () => {
clearTimeout(timeout);
};
}, [ copied ]);
const onCopy = useCallback(() => {
setCopied(true);
}, []);
return (
<>
<SwitchTransition>
<Fade timeout={250} key={copied ? 'visible' : 'invisible'}>
{copied ?
<Toast>
<div>
<p>Copied &quot;{text}&quot; to clipboard.</p>
</div>
</Toast>
:
<></>
}
</Fade>
</SwitchTransition>
<CopyToClipboard onCopy={onCopy} text={text} options={{ debug: true }}>
{children}
</CopyToClipboard>
</>
);
};
export default CopyOnClick;

View file

@ -0,0 +1,39 @@
import React from 'react';
import tw from 'twin.macro';
import Icon from '@/components/elements/Icon';
import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons';
interface State {
hasError: boolean;
}
// eslint-disable-next-line @typescript-eslint/ban-types
class ErrorBoundary extends React.Component<{}, State> {
state: State = {
hasError: false,
};
static getDerivedStateFromError () {
return { hasError: true };
}
componentDidCatch (error: Error) {
console.error(error);
}
render () {
return this.state.hasError ?
<div css={tw`flex items-center justify-center w-full my-4`}>
<div css={tw`flex items-center bg-neutral-900 rounded p-3 text-red-500`}>
<Icon icon={faExclamationTriangle} css={tw`h-4 w-auto mr-2`}/>
<p css={tw`text-sm text-neutral-100`}>
An error was encountered by the application while rendering this view. Try refreshing the page.
</p>
</div>
</div>
:
this.props.children;
}
}
export default ErrorBoundary;

View file

@ -0,0 +1,31 @@
import React, { CSSProperties } from 'react';
import { IconDefinition } from '@fortawesome/fontawesome-svg-core';
import tw from 'twin.macro';
interface Props {
icon: IconDefinition;
className?: string;
style?: CSSProperties;
}
const Icon = ({ icon, className, style }: Props) => {
let [ width, height, , , paths ] = icon.icon;
paths = Array.isArray(paths) ? paths : [ paths ];
return (
<svg
xmlns={'http://www.w3.org/2000/svg'}
viewBox={`0 0 ${width} ${height}`}
css={tw`fill-current inline-block`}
className={className}
style={style}
>
{paths.map((path, index) => (
<path key={`svg_path_${index}`} d={path}/>
))}
</svg>
);
};
export default Icon;

View file

@ -5,12 +5,7 @@ import tw from 'twin.macro';
const InputSpinner = ({ visible, children }: { visible: boolean, children: React.ReactNode }) => (
<div css={tw`relative`}>
<Fade
appear
unmountOnExit
in={visible}
timeout={150}
>
<Fade appear unmountOnExit in={visible} timeout={150}>
<div css={tw`absolute right-0 h-full flex items-center justify-end pr-3`}>
<Spinner size={'small'}/>
</div>

View file

@ -1,9 +1,10 @@
import React, { useEffect, useMemo, useState } from 'react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import Spinner from '@/components/elements/Spinner';
import tw from 'twin.macro';
import styled, { css } from 'styled-components/macro';
import { breakpoint } from '@/theme';
import Fade from '@/components/elements/Fade';
import { createPortal } from 'react-dom';
export interface RequiredModalProps {
visible: boolean;
@ -124,4 +125,10 @@ const Modal: React.FC<ModalProps> = ({ visible, appear, dismissable, showSpinner
);
};
export default Modal;
const PortaledModal: React.FC<ModalProps> = ({ children, ...props }) => {
const element = useRef(document.getElementById('modal-portal'));
return createPortal(<Modal {...props}>{children}</Modal>, element.current!);
};
export default PortaledModal;

View file

@ -1,19 +1,22 @@
import React, { useEffect, useMemo, useRef } from 'react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { ITerminalOptions, Terminal } from 'xterm';
import * as TerminalFit from 'xterm/lib/addons/fit/fit';
import { FitAddon } from 'xterm-addon-fit';
import { SearchAddon } from 'xterm-addon-search';
import { SearchBarAddon } from 'xterm-addon-search-bar';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import { ServerContext } from '@/state/server';
import styled from 'styled-components/macro';
import { usePermissions } from '@/plugins/usePermissions';
import tw from 'twin.macro';
import 'xterm/dist/xterm.css';
import tw, { theme as th } from 'twin.macro';
import 'xterm/css/xterm.css';
import useEventListener from '@/plugins/useEventListener';
import { debounce } from 'debounce';
import { usePersistedState } from '@/plugins/usePersistedState';
const theme = {
background: 'transparent',
background: th`colors.black`.toString(),
cursor: 'transparent',
black: '#000000',
black: th`colors.black`.toString(),
red: '#E54B4B',
green: '#9ECE58',
yellow: '#FAED70',
@ -29,6 +32,7 @@ const theme = {
brightMagenta: '#C792EA',
brightCyan: '#89DDFF',
brightWhite: '#ffffff',
selection: '#FAF089',
};
const terminalProps: ITerminalOptions = {
@ -55,8 +59,14 @@ export default () => {
const TERMINAL_PRELUDE = '\u001b[1m\u001b[33mcontainer@pterodactyl~ \u001b[0m';
const ref = useRef<HTMLDivElement>(null);
const terminal = useMemo(() => new Terminal({ ...terminalProps }), []);
const fitAddon = new FitAddon();
const searchAddon = new SearchAddon();
const searchBar = new SearchBarAddon({ searchAddon });
const { connected, instance } = ServerContext.useStoreState(state => state.socket);
const [ canSendCommands ] = usePermissions([ 'control.console' ]);
const serverId = ServerContext.useStoreState(state => state.server.data!.id);
const [ history, setHistory ] = usePersistedState<string[]>(`${serverId}:command_history`, []);
const [ historyIndex, setHistoryIndex ] = useState(-1);
const handleConsoleOutput = (line: string, prelude = false) => terminal.writeln(
(prelude ? TERMINAL_PRELUDE : '') + line.replace(/(?:\r\n|\r|\n)$/im, '') + '\u001b[0m',
@ -71,37 +81,61 @@ export default () => {
);
const handleCommandKeydown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key !== 'Enter' || (e.key === 'Enter' && e.currentTarget.value.length < 1)) {
return;
if (e.key === 'ArrowUp') {
const newIndex = Math.min(historyIndex + 1, history!.length - 1);
setHistoryIndex(newIndex);
e.currentTarget.value = history![newIndex] || '';
}
instance && instance.send('send command', e.currentTarget.value);
e.currentTarget.value = '';
if (e.key === 'ArrowDown') {
const newIndex = Math.max(historyIndex - 1, -1);
setHistoryIndex(newIndex);
e.currentTarget.value = history![newIndex] || '';
}
const command = e.currentTarget.value;
if (e.key === 'Enter' && command.length > 0) {
setHistory(prevHistory => [ command, ...prevHistory! ].slice(0, 32));
setHistoryIndex(-1);
instance && instance.send('send command', command);
e.currentTarget.value = '';
}
};
useEffect(() => {
if (connected && ref.current && !terminal.element) {
terminal.open(ref.current);
terminal.loadAddon(fitAddon);
terminal.loadAddon(searchAddon);
terminal.loadAddon(searchBar);
fitAddon.fit();
// @see https://github.com/xtermjs/xterm.js/issues/2265
// @see https://github.com/xtermjs/xterm.js/issues/2230
TerminalFit.fit(terminal);
// Add support for copying terminal text.
// Add support for capturing keys
terminal.attachCustomKeyEventHandler((e: KeyboardEvent) => {
// Ctrl + C
if (e.ctrlKey && (e.key === 'c')) {
// Ctrl + C ( Copy )
if (e.ctrlKey && e.key === 'c') {
document.execCommand('copy');
return false;
}
if (e.ctrlKey && e.key === 'f') {
searchBar.show();
return false;
}
if (e.key === 'Escape') {
searchBar.hidden();
}
return true;
});
}
}, [ terminal, connected ]);
const fit = debounce(() => {
TerminalFit.fit(terminal);
fitAddon.fit();
}, 100);
useEventListener('resize', () => fit());

View file

@ -16,6 +16,7 @@ import Select from '@/components/elements/Select';
import modes from '@/modes';
import useFlash from '@/plugins/useFlash';
import { ServerContext } from '@/state/server';
import ErrorBoundary from '@/components/elements/ErrorBoundary';
const LazyCodemirrorEditor = lazy(() => import(/* webpackChunkName: "editor" */'@/components/elements/CodemirrorEditor'));
@ -60,9 +61,7 @@ export default () => {
setLoading(true);
clearFlashes('files:view');
fetchFileContent()
.then(content => {
return saveFileContents(uuid, name || hash.replace(/^#/, ''), content);
})
.then(content => saveFileContents(uuid, encodeURIComponent(name || hash.replace(/^#/, '')), content))
.then(() => {
if (name) {
history.push(`/server/${id}/files/edit#/${name}`);
@ -87,7 +86,9 @@ export default () => {
return (
<PageContentBlock>
<FlashMessageRender byKey={'files:view'} css={tw`mb-4`}/>
<FileManagerBreadcrumbs withinFileEditor isNewFile={action !== 'edit'}/>
<ErrorBoundary>
<FileManagerBreadcrumbs withinFileEditor isNewFile={action !== 'edit'}/>
</ErrorBoundary>
{hash.replace(/^#/, '').endsWith('.pteroignore') &&
<div css={tw`mb-4 p-4 border-l-4 bg-neutral-900 rounded border-cyan-400`}>
<p css={tw`text-neutral-300 text-sm`}>

View file

@ -33,10 +33,10 @@ export default ({ withinFileEditor, isNewFile }: Props) => {
.filter(directory => !!directory)
.map((directory, index, dirs) => {
if (!withinFileEditor && index === dirs.length - 1) {
return { name: decodeURIComponent(directory) };
return { name: decodeURIComponent(encodeURIComponent(directory)) };
}
return { name: decodeURIComponent(directory), path: `/${dirs.slice(0, index + 1).join('/')}` };
return { name: decodeURIComponent(encodeURIComponent(directory)), path: `/${dirs.slice(0, index + 1).join('/')}` };
});
const onSelectAllClick = (e: React.ChangeEvent<HTMLInputElement>) => {
@ -79,7 +79,7 @@ export default ({ withinFileEditor, isNewFile }: Props) => {
}
{file &&
<React.Fragment>
<span css={tw`px-1 text-neutral-300`}>{decodeURIComponent(file)}</span>
<span css={tw`px-1 text-neutral-300`}>{decodeURIComponent(encodeURIComponent(file))}</span>
</React.Fragment>
}
</div>

View file

@ -17,6 +17,7 @@ import MassActionsBar from '@/components/server/files/MassActionsBar';
import UploadButton from '@/components/server/files/UploadButton';
import ServerContentBlock from '@/components/elements/ServerContentBlock';
import { useStoreActions } from '@/state/hooks';
import ErrorBoundary from '@/components/elements/ErrorBoundary';
const sortFiles = (files: FileObject[]): FileObject[] => {
return files.sort((a, b) => a.name.localeCompare(b.name))
@ -50,7 +51,9 @@ export default () => {
return (
<ServerContentBlock title={'File Manager'} showFlashKey={'files'}>
<FileManagerBreadcrumbs/>
<ErrorBoundary>
<FileManagerBreadcrumbs/>
</ErrorBoundary>
{
!files ?
<Spinner size={'large'} centered/>
@ -81,18 +84,20 @@ export default () => {
</CSSTransition>
}
<Can action={'file.create'}>
<div css={tw`flex flex-wrap-reverse justify-end mt-4`}>
<NewDirectoryButton css={tw`w-full flex-none mt-4 sm:mt-0 sm:w-auto sm:mr-4`}/>
<UploadButton css={tw`flex-1 mr-4 sm:flex-none sm:mt-0`}/>
<NavLink
to={`/server/${id}/files/new${window.location.hash}`}
css={tw`flex-1 sm:flex-none sm:mt-0`}
>
<Button css={tw`w-full`}>
New File
</Button>
</NavLink>
</div>
<ErrorBoundary>
<div css={tw`flex flex-wrap-reverse justify-end mt-4`}>
<NewDirectoryButton css={tw`w-full flex-none mt-4 sm:mt-0 sm:w-auto sm:mr-4`}/>
<UploadButton css={tw`flex-1 mr-4 sm:flex-none sm:mt-0`}/>
<NavLink
to={`/server/${id}/files/new${window.location.hash}`}
css={tw`flex-1 sm:flex-none sm:mt-0`}
>
<Button css={tw`w-full`}>
New File
</Button>
</NavLink>
</div>
</ErrorBoundary>
</Can>
</>
}

View file

@ -92,9 +92,9 @@ export default ({ className }: WithClassname) => {
<span css={tw`text-neutral-200`}>This directory will be created as</span>
&nbsp;/home/container/
<span css={tw`text-cyan-200`}>
{decodeURIComponent(
{decodeURIComponent(encodeURIComponent(
join(directory, values.directoryName).replace(/^(\.\.\/|\/)+/, ''),
)}
))}
</span>
</p>
<div css={tw`flex justify-end`}>

View file

@ -14,6 +14,7 @@ import { debounce } from 'debounce';
import setServerAllocationNotes from '@/api/server/network/setServerAllocationNotes';
import useFlash from '@/plugins/useFlash';
import { ServerContext } from '@/state/server';
import CopyOnClick from '@/components/elements/CopyOnClick';
const Code = styled.code`${tw`font-mono py-1 px-2 bg-neutral-900 rounded text-sm inline-block`}`;
const Label = styled.label`${tw`uppercase text-xs mt-1 text-neutral-400 block px-1 select-none transition-colors duration-150`}`;
@ -43,11 +44,12 @@ const AllocationRow = ({ allocation, onSetPrimary, onNotesChanged }: Props) => {
<GreyRowBox $hoverable={false} css={tw`flex-wrap md:flex-no-wrap mt-2`}>
<div css={tw`flex items-center w-full md:w-auto`}>
<div css={tw`pl-4 pr-6 text-neutral-400`}>
<FontAwesomeIcon icon={faNetworkWired}/>
<FontAwesomeIcon icon={faNetworkWired} />
</div>
<div css={tw`mr-4 flex-1 md:w-40`}>
<Code>{allocation.alias || allocation.ip}</Code>
<Label>IP Address</Label>
{allocation.alias ? <CopyOnClick text={allocation.alias}><Code css={tw`w-40 truncate`}>{allocation.alias}</Code></CopyOnClick> :
<CopyOnClick text={allocation.ip}><Code>{allocation.ip}</Code></CopyOnClick>}
<Label>{allocation.alias ? 'Hostname' : 'IP Address'}</Label>
</div>
<div css={tw`w-16 md:w-24 overflow-hidden`}>
<Code>{allocation.port}</Code>

View file

@ -49,7 +49,7 @@ export default ({ scheduleId, onDeleted }: Props) => {
Are you sure you want to delete this schedule? All tasks will be removed and any running processes
will be terminated.
</ConfirmationModal>
<Button css={tw`mr-4`} color={'red'} isSecondary onClick={() => setVisible(true)}>
<Button css={tw`flex-1 sm:flex-none mr-4 border-transparent`} color={'red'} isSecondary onClick={() => setVisible(true)}>
Delete
</Button>
</>

View file

@ -2,6 +2,7 @@ import React, { useState } from 'react';
import { Schedule } from '@/api/server/schedules/getServerSchedules';
import TaskDetailsModal from '@/components/server/schedules/TaskDetailsModal';
import Button from '@/components/elements/Button';
import tw from 'twin.macro';
interface Props {
schedule: Schedule;
@ -18,7 +19,7 @@ export default ({ schedule }: Props) => {
onDismissed={() => setVisible(false)}
/>
}
<Button onClick={() => setVisible(true)}>
<Button onClick={() => setVisible(true)} css={tw`flex-1`}>
New Task
</Button>
</>

View file

@ -0,0 +1,48 @@
import React, { useCallback, useState } from 'react';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import tw from 'twin.macro';
import Button from '@/components/elements/Button';
import triggerScheduleExecution from '@/api/server/schedules/triggerScheduleExecution';
import { ServerContext } from '@/state/server';
import useFlash from '@/plugins/useFlash';
import { Schedule } from '@/api/server/schedules/getServerSchedules';
const RunScheduleButton = ({ schedule }: { schedule: Schedule }) => {
const [ loading, setLoading ] = useState(false);
const { clearFlashes, clearAndAddHttpError } = useFlash();
const id = ServerContext.useStoreState(state => state.server.data!.id);
const appendSchedule = ServerContext.useStoreActions(actions => actions.schedules.appendSchedule);
const onTriggerExecute = useCallback(() => {
clearFlashes('schedule');
setLoading(true);
triggerScheduleExecution(id, schedule.id)
.then(() => {
setLoading(false);
appendSchedule({ ...schedule, isProcessing: true });
})
.catch(error => {
console.error(error);
clearAndAddHttpError({ error, key: 'schedules' });
})
.then(() => setLoading(false));
}, []);
return (
<>
<SpinnerOverlay visible={loading} size={'large'}/>
<Button
isSecondary
color={'grey'}
css={tw`flex-1 sm:flex-none border-transparent`}
disabled={schedule.isProcessing}
onClick={onTriggerExecute}
>
Run Now
</Button>
</>
);
};
export default RunScheduleButton;

View file

@ -0,0 +1,35 @@
import React from 'react';
import tw from 'twin.macro';
import { Schedule } from '@/api/server/schedules/getServerSchedules';
interface Props {
cron: Schedule['cron'];
className?: string;
}
const ScheduleCronRow = ({ cron, className }: Props) => (
<div css={tw`flex`} className={className}>
<div css={tw`w-1/5 sm:w-auto text-center`}>
<p css={tw`font-medium`}>{cron.minute}</p>
<p css={tw`text-2xs text-neutral-500 uppercase`}>Minute</p>
</div>
<div css={tw`w-1/5 sm:w-auto text-center ml-4`}>
<p css={tw`font-medium`}>{cron.hour}</p>
<p css={tw`text-2xs text-neutral-500 uppercase`}>Hour</p>
</div>
<div css={tw`w-1/5 sm:w-auto text-center ml-4`}>
<p css={tw`font-medium`}>{cron.dayOfMonth}</p>
<p css={tw`text-2xs text-neutral-500 uppercase`}>Day (Month)</p>
</div>
<div css={tw`w-1/5 sm:w-auto text-center ml-4`}>
<p css={tw`font-medium`}>*</p>
<p css={tw`text-2xs text-neutral-500 uppercase`}>Month</p>
</div>
<div css={tw`w-1/5 sm:w-auto text-center ml-4`}>
<p css={tw`font-medium`}>{cron.dayOfWeek}</p>
<p css={tw`text-2xs text-neutral-500 uppercase`}>Day (Week)</p>
</div>
</div>
);
export default ScheduleCronRow;

View file

@ -1,12 +1,9 @@
import React, { useEffect, useState } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import { RouteComponentProps } from 'react-router-dom';
import { Schedule } from '@/api/server/schedules/getServerSchedules';
import getServerSchedule from '@/api/server/schedules/getServerSchedule';
import Spinner from '@/components/elements/Spinner';
import FlashMessageRender from '@/components/FlashMessageRender';
import { httpErrorToHuman } from '@/api/http';
import ScheduleRow from '@/components/server/schedules/ScheduleRow';
import ScheduleTaskRow from '@/components/server/schedules/ScheduleTaskRow';
import EditScheduleModal from '@/components/server/schedules/EditScheduleModal';
import NewTaskButton from '@/components/server/schedules/NewTaskButton';
import DeleteScheduleButton from '@/components/server/schedules/DeleteScheduleButton';
@ -16,7 +13,11 @@ import { ServerContext } from '@/state/server';
import PageContentBlock from '@/components/elements/PageContentBlock';
import tw from 'twin.macro';
import Button from '@/components/elements/Button';
import GreyRowBox from '@/components/elements/GreyRowBox';
import ScheduleTaskRow from '@/components/server/schedules/ScheduleTaskRow';
import isEqual from 'react-fast-compare';
import { format } from 'date-fns';
import ScheduleCronRow from '@/components/server/schedules/ScheduleCronRow';
import RunScheduleButton from '@/components/server/schedules/RunScheduleButton';
interface Params {
id: string;
@ -26,15 +27,34 @@ interface State {
schedule?: Schedule;
}
const CronBox = ({ title, value }: { title: string; value: string }) => (
<div css={tw`bg-neutral-700 rounded p-4`}>
<p css={tw`text-neutral-300 text-sm`}>{title}</p>
<p css={tw`text-2xl font-medium text-neutral-100`}>{value}</p>
</div>
);
const ActivePill = ({ active }: { active: boolean }) => (
<span
css={[
tw`rounded-full px-2 py-px text-xs ml-4 uppercase`,
active ? tw`bg-green-600 text-green-100` : tw`bg-red-600 text-red-100`,
]}
>
{active ? 'Active' : 'Inactive'}
</span>
);
export default ({ match, history, location: { state } }: RouteComponentProps<Params, Record<string, unknown>, State>) => {
const id = ServerContext.useStoreState(state => state.server.data!.id);
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
const { clearFlashes, addError } = useFlash();
const { clearFlashes, clearAndAddHttpError } = useFlash();
const [ isLoading, setIsLoading ] = useState(true);
const [ showEditModal, setShowEditModal ] = useState(false);
const schedule = ServerContext.useStoreState(st => st.schedules.data.find(s => s.id === state.schedule?.id), [ match ]);
// @ts-ignore
const schedule: Schedule | undefined = ServerContext.useStoreState(st => st.schedules.data.find(s => s.id === state.schedule?.id), isEqual);
const appendSchedule = ServerContext.useStoreActions(actions => actions.schedules.appendSchedule);
useEffect(() => {
@ -48,11 +68,15 @@ export default ({ match, history, location: { state } }: RouteComponentProps<Par
.then(schedule => appendSchedule(schedule))
.catch(error => {
console.error(error);
addError({ message: httpErrorToHuman(error), key: 'schedules' });
clearAndAddHttpError({ error, key: 'schedules' });
})
.then(() => setIsLoading(false));
}, [ match ]);
const toggleEditModal = useCallback(() => {
setShowEditModal(s => !s);
}, []);
return (
<PageContentBlock>
<FlashMessageRender byKey={'schedules'} css={tw`mb-4`}/>
@ -60,52 +84,73 @@ export default ({ match, history, location: { state } }: RouteComponentProps<Par
<Spinner size={'large'} centered/>
:
<>
<GreyRowBox css={tw`cursor-pointer mb-2 flex-wrap`}>
<ScheduleRow schedule={schedule}/>
</GreyRowBox>
<EditScheduleModal
visible={showEditModal}
schedule={schedule}
onDismissed={() => setShowEditModal(false)}
/>
<div css={tw`flex items-center mt-8 mb-4`}>
<div css={tw`flex-1`}>
<h2 css={tw`text-2xl`}>Configured Tasks</h2>
<ScheduleCronRow cron={schedule.cron} css={tw`sm:hidden bg-neutral-700 rounded mb-4 p-3`}/>
<div css={tw`hidden sm:grid grid-cols-5 md:grid-cols-7 gap-4 mb-6`}>
<CronBox title={'Minute'} value={schedule.cron.minute}/>
<CronBox title={'Hour'} value={schedule.cron.hour}/>
<CronBox title={'Day (Month)'} value={schedule.cron.dayOfMonth}/>
<CronBox title={'Month'} value={'*'}/>
<CronBox title={'Day (Week)'} value={schedule.cron.dayOfWeek}/>
</div>
<div css={tw`rounded shadow`}>
<div css={tw`sm:flex items-center bg-neutral-900 p-3 sm:p-6 border-b-4 border-neutral-600 rounded-t`}>
<div css={tw`flex-1`}>
<h3 css={tw`flex items-center text-neutral-100 text-2xl`}>
{schedule.name}
{schedule.isProcessing ?
<span
css={tw`flex items-center rounded-full px-2 py-px text-xs ml-4 uppercase bg-neutral-600 text-white`}
>
<Spinner css={tw`w-3! h-3! mr-2`}/>
Processing
</span>
:
<ActivePill active={schedule.isActive}/>
}
</h3>
<p css={tw`mt-1 text-sm text-neutral-300`}>
Last run at:&nbsp;
{schedule.lastRunAt ? format(schedule.lastRunAt, 'MMM do \'at\' h:mma') : 'never'}
</p>
</div>
<div css={tw`flex sm:block mt-3 sm:mt-0`}>
<Can action={'schedule.update'}>
<Button
isSecondary
color={'grey'}
size={'small'}
css={tw`flex-1 mr-4 border-transparent`}
onClick={toggleEditModal}
>
Edit
</Button>
<NewTaskButton schedule={schedule}/>
</Can>
</div>
</div>
<div css={tw`bg-neutral-700 rounded-b`}>
{schedule.tasks.length > 0 ?
schedule.tasks.map(task => (
<ScheduleTaskRow key={`${schedule.id}_${task.id}`} task={task} schedule={schedule}/>
))
:
null
}
</div>
</div>
{schedule.tasks.length > 0 ?
<>
{
schedule.tasks
.sort((a, b) => a.sequenceId - b.sequenceId)
.map(task => (
<ScheduleTaskRow key={task.id} task={task} schedule={schedule}/>
))
}
{schedule.tasks.length > 1 &&
<p css={tw`text-xs text-neutral-400`}>
Task delays are relative to the previous task in the listing.
</p>
}
</>
:
<p css={tw`text-sm text-neutral-400`}>
There are no tasks configured for this schedule.
</p>
}
<div css={tw`mt-8 flex justify-end`}>
<EditScheduleModal visible={showEditModal} schedule={schedule} onDismissed={toggleEditModal}/>
<div css={tw`mt-6 flex sm:justify-end`}>
<Can action={'schedule.delete'}>
<DeleteScheduleButton
scheduleId={schedule.id}
onDeleted={() => history.push(`/server/${id}/schedules`)}
/>
</Can>
{schedule.isActive && schedule.tasks.length > 0 &&
<Can action={'schedule.update'}>
<Button css={tw`mr-4`} onClick={() => setShowEditModal(true)}>
Edit
</Button>
<NewTaskButton schedule={schedule}/>
<RunScheduleButton schedule={schedule}/>
</Can>
}
</div>
</>
}

View file

@ -4,6 +4,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faCalendarAlt } from '@fortawesome/free-solid-svg-icons';
import { format } from 'date-fns';
import tw from 'twin.macro';
import ScheduleCronRow from '@/components/server/schedules/ScheduleCronRow';
export default ({ schedule }: { schedule: Schedule }) => (
<>
@ -27,36 +28,19 @@ export default ({ schedule }: { schedule: Schedule }) => (
{schedule.isActive ? 'Active' : 'Inactive'}
</p>
</div>
<div css={tw`flex items-center mx-auto sm:mx-8 w-full sm:w-auto mt-4 sm:mt-0`}>
<div css={tw`w-1/5 sm:w-auto text-center`}>
<p css={tw`font-medium`}>{schedule.cron.minute}</p>
<p css={tw`text-2xs text-neutral-500 uppercase`}>Minute</p>
</div>
<div css={tw`w-1/5 sm:w-auto text-center ml-4`}>
<p css={tw`font-medium`}>{schedule.cron.hour}</p>
<p css={tw`text-2xs text-neutral-500 uppercase`}>Hour</p>
</div>
<div css={tw`w-1/5 sm:w-auto text-center ml-4`}>
<p css={tw`font-medium`}>{schedule.cron.dayOfMonth}</p>
<p css={tw`text-2xs text-neutral-500 uppercase`}>Day (Month)</p>
</div>
<div css={tw`w-1/5 sm:w-auto text-center ml-4`}>
<p css={tw`font-medium`}>*</p>
<p css={tw`text-2xs text-neutral-500 uppercase`}>Month</p>
</div>
<div css={tw`w-1/5 sm:w-auto text-center ml-4`}>
<p css={tw`font-medium`}>{schedule.cron.dayOfWeek}</p>
<p css={tw`text-2xs text-neutral-500 uppercase`}>Day (Week)</p>
</div>
</div>
<ScheduleCronRow cron={schedule.cron} css={tw`mx-auto sm:mx-8 w-full sm:w-auto mt-4 sm:mt-0`}/>
<div>
<p
css={[
tw`py-1 px-3 rounded text-xs uppercase text-white hidden sm:block`,
schedule.isActive ? tw`bg-green-600` : tw`bg-neutral-400`,
schedule.isActive && !schedule.isProcessing ? tw`bg-green-600` : tw`bg-neutral-400`,
]}
>
{schedule.isActive ? 'Active' : 'Inactive'}
{schedule.isProcessing ?
'Processing'
:
schedule.isActive ? 'Active' : 'Inactive'
}
</p>
</div>
</>

View file

@ -1,7 +1,7 @@
import React, { useState } from 'react';
import { Schedule, Task } from '@/api/server/schedules/getServerSchedules';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faCode, faFileArchive, faPencilAlt, faToggleOn, faTrashAlt } from '@fortawesome/free-solid-svg-icons';
import { faClock, faCode, faFileArchive, faPencilAlt, faToggleOn, faTrashAlt } from '@fortawesome/free-solid-svg-icons';
import deleteScheduleTask from '@/api/server/schedules/deleteScheduleTask';
import { httpErrorToHuman } from '@/api/http';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
@ -11,6 +11,7 @@ import useFlash from '@/plugins/useFlash';
import { ServerContext } from '@/state/server';
import tw from 'twin.macro';
import ConfirmationModal from '@/components/elements/ConfirmationModal';
import Icon from '@/components/elements/Icon';
interface Props {
schedule: Schedule;
@ -56,7 +57,7 @@ export default ({ schedule, task }: Props) => {
const [ title, icon ] = getActionDetails(task.action);
return (
<div css={tw`flex flex-wrap items-center bg-neutral-700 border border-neutral-600 mb-2 px-6 py-4 rounded`}>
<div css={tw`sm:flex items-center p-3 sm:p-6 border-b border-neutral-800`}>
<SpinnerOverlay visible={isLoading} fixed size={'large'}/>
{isEditing && <TaskDetailsModal
schedule={schedule}
@ -73,8 +74,8 @@ export default ({ schedule, task }: Props) => {
Are you sure you want to delete this task? This action cannot be undone.
</ConfirmationModal>
<FontAwesomeIcon icon={icon} css={tw`text-lg text-white hidden md:block`}/>
<div css={tw`flex-none sm:flex-1 mb-4 sm:mb-0 w-full md:w-auto overflow-x-auto`}>
<p css={tw`md:ml-6 text-neutral-300 uppercase text-xs`}>
<div css={tw`flex-none sm:flex-1 w-full sm:w-auto overflow-x-auto`}>
<p css={tw`md:ml-6 text-neutral-200 uppercase text-sm`}>
{title}
</p>
{task.payload &&
@ -87,36 +88,36 @@ export default ({ schedule, task }: Props) => {
</div>
}
</div>
{task.sequenceId > 1 &&
<div css={tw`mr-6`}>
<p css={tw`text-center mb-1`}>
{task.timeOffset}s
</p>
<p css={tw`text-neutral-300 uppercase text-2xs`}>
Delay Run By
</p>
<div css={tw`mt-3 sm:mt-0 flex items-center w-full sm:w-auto`}>
{task.sequenceId > 1 && task.timeOffset > 0 &&
<div css={tw`mr-6`}>
<div css={tw`flex items-center px-2 py-1 bg-neutral-500 text-sm rounded-full`}>
<Icon icon={faClock} css={tw`w-3 h-3 mr-2`}/>
{task.timeOffset}s later
</div>
</div>
}
<Can action={'schedule.update'}>
<button
type={'button'}
aria-label={'Edit scheduled task'}
css={tw`block text-sm p-2 text-neutral-500 hover:text-neutral-100 transition-colors duration-150 mr-4 ml-auto sm:ml-0`}
onClick={() => setIsEditing(true)}
>
<FontAwesomeIcon icon={faPencilAlt}/>
</button>
</Can>
<Can action={'schedule.update'}>
<button
type={'button'}
aria-label={'Delete scheduled task'}
css={tw`block text-sm p-2 text-neutral-500 hover:text-red-600 transition-colors duration-150`}
onClick={() => setVisible(true)}
>
<FontAwesomeIcon icon={faTrashAlt}/>
</button>
</Can>
</div>
}
<Can action={'schedule.update'}>
<button
type={'button'}
aria-label={'Edit scheduled task'}
css={tw`block text-sm p-2 text-neutral-500 hover:text-neutral-100 transition-colors duration-150 mr-4 ml-auto sm:ml-0`}
onClick={() => setIsEditing(true)}
>
<FontAwesomeIcon icon={faPencilAlt}/>
</button>
</Can>
<Can action={'schedule.update'}>
<button
type={'button'}
aria-label={'Delete scheduled task'}
css={tw`block text-sm p-2 text-neutral-500 hover:text-red-600 transition-colors duration-150`}
onClick={() => setVisible(true)}
>
<FontAwesomeIcon icon={faTrashAlt}/>
</button>
</Can>
</div>
);
};

View file

@ -10,7 +10,7 @@ export default () => {
return (
<>
{visible && <EditSubuserModal appear visible onDismissed={() => setVisible(false)}/>}
<EditSubuserModal visible={visible} onModalDismissed={() => setVisible(false)}/>
<Button onClick={() => setVisible(true)}>
<FontAwesomeIcon icon={faUserPlus} css={tw`mr-1`}/> New User
</Button>

View file

@ -1,14 +1,10 @@
import React, { forwardRef, memo, useCallback, useEffect, useRef } from 'react';
import React, { useContext, useEffect, useRef } from 'react';
import { Subuser } from '@/state/server/subusers';
import { Form, Formik, FormikHelpers, useFormikContext } from 'formik';
import { Form, Formik } from 'formik';
import { array, object, string } from 'yup';
import Modal, { RequiredModalProps } from '@/components/elements/Modal';
import Field from '@/components/elements/Field';
import { Actions, useStoreActions, useStoreState } from 'easy-peasy';
import { ApplicationStore } from '@/state';
import TitledGreyBox from '@/components/elements/TitledGreyBox';
import Checkbox from '@/components/elements/Checkbox';
import styled from 'styled-components/macro';
import createOrUpdateSubuser from '@/api/server/users/createOrUpdateSubuser';
import { ServerContext } from '@/state/server';
import FlashMessageRender from '@/components/FlashMessageRender';
@ -17,104 +13,33 @@ import { usePermissions } from '@/plugins/usePermissions';
import { useDeepCompareMemo } from '@/plugins/useDeepCompareMemo';
import tw from 'twin.macro';
import Button from '@/components/elements/Button';
import Label from '@/components/elements/Label';
import Input from '@/components/elements/Input';
import isEqual from 'react-fast-compare';
import PermissionTitleBox from '@/components/server/users/PermissionTitleBox';
import asModal from '@/hoc/asModal';
import PermissionRow from '@/components/server/users/PermissionRow';
import ModalContext from '@/context/ModalContext';
type Props = {
subuser?: Subuser;
} & RequiredModalProps;
};
interface Values {
email: string;
permissions: string[];
}
const PermissionLabel = styled.label`
${tw`flex items-center border border-transparent rounded md:p-2 transition-colors duration-75`};
text-transform: none;
const EditSubuserModal = ({ subuser }: Props) => {
const ref = useRef<HTMLHeadingElement>(null);
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
const appendSubuser = ServerContext.useStoreActions(actions => actions.subusers.appendSubuser);
const { clearFlashes, clearAndAddHttpError } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
const { dismiss, toggleSpinner } = useContext(ModalContext);
&:not(.disabled) {
${tw`cursor-pointer`};
&:hover {
${tw`border-neutral-500 bg-neutral-800`};
}
}
&:not(:first-of-type) {
${tw`mt-4 sm:mt-2`};
}
&.disabled {
${tw`opacity-50`};
& input[type="checkbox"]:not(:checked) {
${tw`border-0`};
}
}
`;
interface TitleProps {
isEditable: boolean;
permission: string;
permissions: string[];
children: React.ReactNode;
className?: string;
}
const PermissionTitledBox = memo(({ isEditable, permission, permissions, className, children }: TitleProps) => {
const { values, setFieldValue } = useFormikContext<Values>();
const onCheckboxClicked = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
console.log(e.currentTarget.checked, [
...values.permissions,
...permissions.filter(p => !values.permissions.includes(p)),
]);
if (e.currentTarget.checked) {
setFieldValue('permissions', [
...values.permissions,
...permissions.filter(p => !values.permissions.includes(p)),
]);
} else {
setFieldValue('permissions', [
...values.permissions.filter(p => !permissions.includes(p)),
]);
}
}, [ permissions, values.permissions ]);
return (
<TitledGreyBox
title={
<div css={tw`flex items-center`}>
<p css={tw`text-sm uppercase flex-1`}>{permission}</p>
{isEditable &&
<Input
type={'checkbox'}
checked={permissions.every(p => values.permissions.includes(p))}
onChange={onCheckboxClicked}
/>
}
</div>
}
className={className}
>
{children}
</TitledGreyBox>
);
}, isEqual);
const EditSubuserModal = forwardRef<HTMLHeadingElement, Props>(({ subuser, ...props }, ref) => {
const { isSubmitting } = useFormikContext<Values>();
const [ canEditUser ] = usePermissions(subuser ? [ 'user.update' ] : [ 'user.create' ]);
const isRootAdmin = useStoreState(state => state.user.data!.rootAdmin);
const permissions = useStoreState(state => state.permissions.data);
const user = useStoreState(state => state.user.data!);
// The currently logged in user's permissions. We're going to filter out any permissions
// that they should not need.
const loggedInPermissions = ServerContext.useStoreState(state => state.server.permissions);
const [ canEditUser ] = usePermissions(subuser ? [ 'user.update' ] : [ 'user.create' ]);
// The permissions that can be modified by this user.
const editablePermissions = useDeepCompareMemo(() => {
@ -123,111 +48,25 @@ const EditSubuserModal = forwardRef<HTMLHeadingElement, Props>(({ subuser, ...pr
const list: string[] = ([] as string[]).concat.apply([], Object.values(cleaned));
if (user.rootAdmin || (loggedInPermissions.length === 1 && loggedInPermissions[0] === '*')) {
if (isRootAdmin || (loggedInPermissions.length === 1 && loggedInPermissions[0] === '*')) {
return list;
}
return list.filter(key => loggedInPermissions.indexOf(key) >= 0);
}, [ permissions, loggedInPermissions ]);
}, [ isRootAdmin, permissions, loggedInPermissions ]);
return (
<Modal {...props} top={false} showSpinnerOverlay={isSubmitting}>
<h2 css={tw`text-2xl`} ref={ref}>
{subuser ?
`${canEditUser ? 'Modify' : 'View'} permissions for ${subuser.email}`
:
'Create new subuser'
}
</h2>
<FlashMessageRender byKey={'user:edit'} css={tw`mt-4`}/>
{(!user.rootAdmin && loggedInPermissions[0] !== '*') &&
<div css={tw`mt-4 pl-4 py-2 border-l-4 border-cyan-400`}>
<p css={tw`text-sm text-neutral-300`}>
Only permissions which your account is currently assigned may be selected when creating or
modifying other users.
</p>
</div>
}
{!subuser &&
<div css={tw`mt-6`}>
<Field
name={'email'}
label={'User Email'}
description={'Enter the email address of the user you wish to invite as a subuser for this server.'}
/>
</div>
}
<div css={tw`my-6`}>
{Object.keys(permissions).filter(key => key !== 'websocket').map((key, index) => {
const group = Object.keys(permissions[key].keys).map(pkey => `${key}.${pkey}`);
return (
<PermissionTitledBox
key={`permission_${key}`}
isEditable={canEditUser}
permission={key}
permissions={group}
css={index > 0 ? tw`mt-4` : undefined}
>
<p css={tw`text-sm text-neutral-400 mb-4`}>
{permissions[key].description}
</p>
{Object.keys(permissions[key].keys).map(pkey => (
<PermissionLabel
key={`permission_${key}_${pkey}`}
htmlFor={`permission_${key}_${pkey}`}
className={(!canEditUser || editablePermissions.indexOf(`${key}.${pkey}`) < 0) ? 'disabled' : undefined}
>
<div css={tw`p-2`}>
<Checkbox
id={`permission_${key}_${pkey}`}
name={'permissions'}
value={`${key}.${pkey}`}
css={tw`w-5 h-5 mr-2`}
disabled={!canEditUser || editablePermissions.indexOf(`${key}.${pkey}`) < 0}
/>
</div>
<div css={tw`flex-1`}>
<Label as={'p'} css={tw`font-medium`}>{pkey}</Label>
{permissions[key].keys[pkey].length > 0 &&
<p css={tw`text-xs text-neutral-400 mt-1`}>
{permissions[key].keys[pkey]}
</p>
}
</div>
</PermissionLabel>
))}
</PermissionTitledBox>
);
})}
</div>
<Can action={subuser ? 'user.update' : 'user.create'}>
<div css={tw`pb-6 flex justify-end`}>
<Button type={'submit'} css={tw`w-full sm:w-auto`}>
{subuser ? 'Save' : 'Invite User'}
</Button>
</div>
</Can>
</Modal>
);
});
export default ({ subuser, ...props }: Props) => {
const ref = useRef<HTMLHeadingElement>(null);
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
const appendSubuser = ServerContext.useStoreActions(actions => actions.subusers.appendSubuser);
const { clearFlashes, clearAndAddHttpError } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
const submit = (values: Values, { setSubmitting }: FormikHelpers<Values>) => {
const submit = (values: Values) => {
toggleSpinner(true);
clearFlashes('user:edit');
createOrUpdateSubuser(uuid, values, subuser)
.then(subuser => {
appendSubuser(subuser);
props.onDismissed();
dismiss();
})
.catch(error => {
console.error(error);
setSubmitting(false);
toggleSpinner(false);
clearAndAddHttpError({ key: 'user:edit', error });
if (ref.current) {
@ -236,10 +75,8 @@ export default ({ subuser, ...props }: Props) => {
});
};
useEffect(() => {
return () => {
clearFlashes('user:edit');
};
useEffect(() => () => {
clearFlashes('user:edit');
}, []);
return (
@ -258,8 +95,61 @@ export default ({ subuser, ...props }: Props) => {
})}
>
<Form>
<EditSubuserModal ref={ref} subuser={subuser} {...props}/>
<h2 css={tw`text-2xl`} ref={ref}>
{subuser ? `${canEditUser ? 'Modify' : 'View'} permissions for ${subuser.email}` : 'Create new subuser'}
</h2>
<FlashMessageRender byKey={'user:edit'} css={tw`mt-4`}/>
{(!isRootAdmin && loggedInPermissions[0] !== '*') &&
<div css={tw`mt-4 pl-4 py-2 border-l-4 border-cyan-400`}>
<p css={tw`text-sm text-neutral-300`}>
Only permissions which your account is currently assigned may be selected when creating or
modifying other users.
</p>
</div>
}
{!subuser &&
<div css={tw`mt-6`}>
<Field
name={'email'}
label={'User Email'}
description={'Enter the email address of the user you wish to invite as a subuser for this server.'}
/>
</div>
}
<div css={tw`my-6`}>
{Object.keys(permissions).filter(key => key !== 'websocket').map((key, index) => (
<PermissionTitleBox
key={`permission_${key}`}
title={key}
isEditable={canEditUser}
permissions={Object.keys(permissions[key].keys).map(pkey => `${key}.${pkey}`)}
css={index > 0 ? tw`mt-4` : undefined}
>
<p css={tw`text-sm text-neutral-400 mb-4`}>
{permissions[key].description}
</p>
{Object.keys(permissions[key].keys).map(pkey => (
<PermissionRow
key={`permission_${key}.${pkey}`}
permission={`${key}.${pkey}`}
disabled={!canEditUser || editablePermissions.indexOf(`${key}.${pkey}`) < 0}
/>
))}
</PermissionTitleBox>
))}
</div>
<Can action={subuser ? 'user.update' : 'user.create'}>
<div css={tw`pb-6 flex justify-end`}>
<Button type={'submit'} css={tw`w-full sm:w-auto`}>
{subuser ? 'Save' : 'Invite User'}
</Button>
</div>
</Can>
</Form>
</Formik>
);
};
export default asModal<Props>({
top: false,
})(EditSubuserModal);

View file

@ -0,0 +1,65 @@
import styled from 'styled-components/macro';
import tw from 'twin.macro';
import Checkbox from '@/components/elements/Checkbox';
import React from 'react';
import { useStoreState } from 'easy-peasy';
import Label from '@/components/elements/Label';
const Container = styled.label`
${tw`flex items-center border border-transparent rounded md:p-2 transition-colors duration-75`};
text-transform: none;
&:not(.disabled) {
${tw`cursor-pointer`};
&:hover {
${tw`border-neutral-500 bg-neutral-800`};
}
}
&:not(:first-of-type) {
${tw`mt-4 sm:mt-2`};
}
&.disabled {
${tw`opacity-50`};
& input[type="checkbox"]:not(:checked) {
${tw`border-0`};
}
}
`;
interface Props {
permission: string;
disabled: boolean;
}
const PermissionRow = ({ permission, disabled }: Props) => {
const [ key, pkey ] = permission.split('.', 2);
const permissions = useStoreState(state => state.permissions.data);
return (
<Container htmlFor={`permission_${permission}`} className={disabled ? 'disabled' : undefined}>
<div css={tw`p-2`}>
<Checkbox
id={`permission_${permission}`}
name={'permissions'}
value={permission}
css={tw`w-5 h-5 mr-2`}
disabled={disabled}
/>
</div>
<div css={tw`flex-1`}>
<Label as={'p'} css={tw`font-medium`}>{pkey}</Label>
{permissions[key].keys[pkey].length > 0 &&
<p css={tw`text-xs text-neutral-400 mt-1`}>
{permissions[key].keys[pkey]}
</p>
}
</div>
</Container>
);
};
export default PermissionRow;

View file

@ -0,0 +1,50 @@
import React, { memo, useCallback } from 'react';
import { useField } from 'formik';
import TitledGreyBox from '@/components/elements/TitledGreyBox';
import tw from 'twin.macro';
import Input from '@/components/elements/Input';
import isEqual from 'react-fast-compare';
interface Props {
isEditable: boolean;
title: string;
permissions: string[];
className?: string;
}
const PermissionTitleBox: React.FC<Props> = memo(({ isEditable, title, permissions, className, children }) => {
const [ { value }, , { setValue } ] = useField<string[]>('permissions');
const onCheckboxClicked = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
if (e.currentTarget.checked) {
setValue([
...value,
...permissions.filter(p => !value.includes(p)),
]);
} else {
setValue(value.filter(p => !permissions.includes(p)));
}
}, [ permissions, value ]);
return (
<TitledGreyBox
title={
<div css={tw`flex items-center`}>
<p css={tw`text-sm uppercase flex-1`}>{title}</p>
{isEditable &&
<Input
type={'checkbox'}
checked={permissions.every(p => value.includes(p))}
onChange={onCheckboxClicked}
/>
}
</div>
}
className={className}
>
{children}
</TitledGreyBox>
);
}, isEqual);
export default PermissionTitleBox;

View file

@ -19,14 +19,11 @@ export default ({ subuser }: Props) => {
return (
<GreyRowBox css={tw`mb-2`}>
{visible &&
<EditSubuserModal
appear
visible
subuser={subuser}
onDismissed={() => setVisible(false)}
visible={visible}
onModalDismissed={() => setVisible(false)}
/>
}
<div css={tw`w-10 h-10 rounded-full bg-white border-2 border-neutral-800 overflow-hidden hidden md:block`}>
<img css={tw`w-full h-full`} src={`${subuser.image}?s=400`}/>
</div>

View file

@ -9,7 +9,7 @@ import FlashMessageRender from '@/components/FlashMessageRender';
import getServerSubusers from '@/api/server/users/getServerSubusers';
import { httpErrorToHuman } from '@/api/http';
import Can from '@/components/elements/Can';
import PageContentBlock from '@/components/elements/PageContentBlock';
import ServerContentBlock from '@/components/elements/ServerContentBlock';
import tw from 'twin.macro';
export default () => {
@ -48,7 +48,7 @@ export default () => {
}
return (
<PageContentBlock title={'Subusers'}>
<ServerContentBlock title={'Subusers'}>
<FlashMessageRender byKey={'users'} css={tw`mb-4`}/>
{!subusers.length ?
<p css={tw`text-center text-sm text-neutral-400`}>
@ -64,6 +64,6 @@ export default () => {
<AddSubuserButton/>
</div>
</Can>
</PageContentBlock>
</ServerContentBlock>
);
};

View file

@ -1,7 +1,6 @@
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;
@ -13,7 +12,7 @@ type ExtendedModalProps = Omit<ModalProps, 'appear' | 'visible' | 'onDismissed'>
interface State {
render: boolean;
visible: boolean;
modalProps: ExtendedModalProps | undefined;
showSpinnerOverlay?: boolean;
}
type ExtendedComponentType<T> = (C: React.ComponentType<T>) => React.ComponentType<T & AsModalProps>;
@ -30,17 +29,18 @@ function asModal<P extends object> (modalProps?: ExtendedModalProps | ((props: P
this.state = {
render: props.visible,
visible: props.visible,
modalProps: typeof modalProps === 'function' ? modalProps(this.props) : modalProps,
showSpinnerOverlay: undefined,
};
}
get modalProps () {
return {
...(typeof modalProps === 'function' ? modalProps(this.props) : modalProps),
showSpinnerOverlay: this.state.showSpinnerOverlay,
};
}
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 });
@ -52,39 +52,32 @@ function asModal<P extends object> (modalProps?: ExtendedModalProps | ((props: P
dismiss = () => this.setState({ visible: false });
toggleSpinner = (value?: boolean) => this.setState(s => ({
modalProps: {
...s.modalProps,
showSpinnerOverlay: value || false,
},
}));
toggleSpinner = (value?: boolean) => this.setState({ showSpinnerOverlay: value });
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>
this.state.render ?
<Modal
appear
visible={this.state.visible}
onDismissed={() => this.setState({ render: false }, () => {
if (typeof this.props.onModalDismissed === 'function') {
this.props.onModalDismissed();
}
})}
{...this.modalProps}
>
<ModalContext.Provider
value={{
dismiss: this.dismiss.bind(this),
toggleSpinner: this.toggleSpinner.bind(this),
}}
>
<Component {...this.props}/>
</ModalContext.Provider>
</Modal>
:
null
);
}
};

View file

@ -28,6 +28,7 @@ import NetworkContainer from '@/components/server/network/NetworkContainer';
import InstallListener from '@/components/server/InstallListener';
import StartupContainer from '@/components/server/startup/StartupContainer';
import requireServerPermission from '@/hoc/requireServerPermission';
import ErrorBoundary from '@/components/elements/ErrorBoundary';
const ServerRouter = ({ match, location }: RouteComponentProps<{ id: string }>) => {
const rootAdmin = useStoreState(state => state.user.data!.rootAdmin);
@ -120,7 +121,7 @@ const ServerRouter = ({ match, location }: RouteComponentProps<{ id: string }>)
message={'Please check back in a few minutes.'}
/>
:
<>
<ErrorBoundary>
<TransitionRouter>
<Switch location={location}>
<Route path={`${match.path}`} component={ServerConsole} exact/>
@ -173,7 +174,7 @@ const ServerRouter = ({ match, location }: RouteComponentProps<{ id: string }>)
<Route path={'*'} component={NotFound}/>
</Switch>
</TransitionRouter>
</>
</ErrorBoundary>
}
</>
}

View file

@ -11,7 +11,7 @@
@endsection
@section('content-header')
<h1>Mounts<small>SoonTM</small></h1>
<h1>Mounts<small>Configure and manage additional mount points for servers.</small></h1>
<ol class="breadcrumb">
<li><a href="{{ route('admin.index') }}">Admin</a></li>
<li class="active">Mounts</li>

View file

@ -26,7 +26,7 @@
<div class="box-tools search01">
<form action="{{ route('admin.servers') }}" method="GET">
<div class="input-group input-group-sm">
<input type="text" name="filter[name]" class="form-control pull-right" value="{{ request()->input('filter.name') }}" placeholder="Search Servers">
<input type="text" name="filter[*]" class="form-control pull-right" value="{{ request()->input('filter[*]') }}" placeholder="Search Servers">
<div class="input-group-btn">
<button type="submit" class="btn btn-default"><i class="fa fa-search"></i></button>
<a href="{{ route('admin.servers.new') }}"><button type="button" class="btn btn-sm btn-primary" style="border-radius: 0 3px 3px 0;margin-left:-1px;">Create New</button></a>

View file

@ -96,30 +96,33 @@
</div>
@endif
@if($canTransfer)
<div class="col-sm-4">
<div class="box box-success">
<div class="box-header with-border">
<h3 class="box-title">Transfer Server</h3>
</div>
<div class="box-body">
<p>
Hopefully, you will soon be able to move servers around without needing to do a bunch of confusing
operations manually and it will work fluidly and with no problems.
</p>
</div>
<div class="box-footer">
<div class="col-sm-4">
<div class="box box-success">
<div class="box-header with-border">
<h3 class="box-title">Transfer Server</h3>
</div>
<div class="box-body">
<p>
Transfer this server to another node connected to this panel.
<strong>Warning!</strong> This feature has not been fully tested and may have bugs.
</p>
</div>
<div class="box-footer">
@if($canTransfer)
<button class="btn btn-success" data-toggle="modal" data-target="#transferServerModal">Transfer Server</button>
</div>
@else
<button class="btn btn-success disabled">Transfer Server</button>
<p style="padding-top: 1rem;">Transferring a server requires more than one node to be configured on your panel.</p>
@endif
</div>
</div>
@endif
</div>
</div>
<div class="modal fade" id="transferServerModal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<!-- TODO: Change route -->
<form action="{{ route('admin.servers.view.manage.transfer', $server->id) }}" method="POST">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>

View file

@ -64,7 +64,7 @@
@endif
</td>
<td class="text-center">
<a href="{{ route('admin.servers', ['query' => $user->email]) }}">{{ $user->servers_count }}</a>
<a href="{{ route('admin.servers', ['filter[owner_id]' => $user->id]) }}">{{ $user->servers_count }}</a>
</td>
<td class="text-center">{{ $user->subuser_of_count }}</td>
<td class="text-center"><img src="https://www.gravatar.com/avatar/{{ md5(strtolower($user->email)) }}?s=100" style="height:20px;" class="img-circle" /></td>

View file

@ -3,5 +3,6 @@
])
@section('container')
<div id="modal-portal"></div>
<div id="app"></div>
@endsection