Merge branch 'develop' into subusers

This commit is contained in:
Charles Morgan 2020-10-31 17:32:10 -04:00 committed by GitHub
commit aad3019747
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
71 changed files with 1375 additions and 753 deletions

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

@ -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

@ -41,20 +41,22 @@ const StatusIndicatorBox = styled(GreyRowBox)<{ $status: ServerPowerState | unde
export default ({ server, className }: { server: Server; className?: string }) => {
const interval = useRef<number>(null);
const [ isSuspended, setIsSuspended ] = useState(server.isSuspended);
const [ stats, setStats ] = useState<ServerStats | null>(null);
const [ statsError, setStatsError ] = useState(false);
const getStats = () => {
setStatsError(false);
return getServerResourceUsage(server.uuid)
.then(data => setStats(data))
.catch(error => {
setStatsError(true);
console.error(error);
});
};
const getStats = () => getServerResourceUsage(server.uuid)
.then(data => setStats(data))
.catch(error => console.error(error));
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 +65,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 +103,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 +118,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

@ -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

@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useRef } from 'react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { ITerminalOptions, Terminal } from 'xterm';
import { FitAddon } from 'xterm-addon-fit';
import { SearchAddon } from 'xterm-addon-search';
@ -11,6 +11,7 @@ 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: th`colors.black`.toString(),
@ -63,6 +64,9 @@ export default () => {
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',
@ -77,12 +81,28 @@ 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(() => {

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

@ -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

@ -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

@ -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>