Merge branch 'develop' into develop

This commit is contained in:
Caleb 2020-10-13 15:35:38 -04:00 committed by GitHub
commit ea778e9345
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
159 changed files with 3400 additions and 3896 deletions

View file

@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import React, { useEffect, useMemo, useRef } from 'react';
import { ITerminalOptions, Terminal } from 'xterm';
import * as TerminalFit from 'xterm/lib/addons/fit/fit';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
@ -7,6 +7,8 @@ import styled from 'styled-components/macro';
import { usePermissions } from '@/plugins/usePermissions';
import tw from 'twin.macro';
import 'xterm/dist/xterm.css';
import useEventListener from '@/plugins/useEventListener';
import { debounce } from 'debounce';
const theme = {
background: 'transparent',
@ -51,8 +53,7 @@ const TerminalDiv = styled.div`
export default () => {
const TERMINAL_PRELUDE = '\u001b[1m\u001b[33mcontainer@pterodactyl~ \u001b[0m';
const [ terminalElement, setTerminalElement ] = useState<HTMLDivElement | null>(null);
const useRef = useCallback(node => setTerminalElement(node), []);
const ref = useRef<HTMLDivElement>(null);
const terminal = useMemo(() => new Terminal({ ...terminalProps }), []);
const { connected, instance } = ServerContext.useStoreState(state => state.socket);
const [ canSendCommands ] = usePermissions([ 'control.console' ]);
@ -79,8 +80,8 @@ export default () => {
};
useEffect(() => {
if (connected && terminalElement && !terminal.element) {
terminal.open(terminalElement);
if (connected && ref.current && !terminal.element) {
terminal.open(ref.current);
// @see https://github.com/xtermjs/xterm.js/issues/2265
// @see https://github.com/xtermjs/xterm.js/issues/2230
@ -97,7 +98,13 @@ export default () => {
return true;
});
}
}, [ terminal, connected, terminalElement ]);
}, [ terminal, connected ]);
const fit = debounce(() => {
TerminalFit.fit(terminal);
}, 100);
useEventListener('resize', () => fit());
useEffect(() => {
if (connected && instance) {
@ -134,7 +141,7 @@ export default () => {
maxHeight: '32rem',
}}
>
<TerminalDiv id={'terminal'} ref={useRef}/>
<TerminalDiv id={'terminal'} ref={ref}/>
</div>
{canSendCommands &&
<div css={tw`rounded-b bg-neutral-900 text-neutral-100 flex`}>

View file

@ -0,0 +1,55 @@
import React from 'react';
import tw from 'twin.macro';
import Can from '@/components/elements/Can';
import Button from '@/components/elements/Button';
import StopOrKillButton from '@/components/server/StopOrKillButton';
import { PowerAction } from '@/components/server/ServerConsole';
import { ServerContext } from '@/state/server';
const PowerControls = () => {
const status = ServerContext.useStoreState(state => state.status.value);
const instance = ServerContext.useStoreState(state => state.socket.instance);
const sendPowerCommand = (command: PowerAction) => {
instance && instance.send('set state', command);
};
return (
<div css={tw`shadow-md bg-neutral-700 rounded p-3 flex text-xs mt-4 justify-center`}>
<Can action={'control.start'}>
<Button
size={'xsmall'}
color={'green'}
isSecondary
css={tw`mr-2`}
disabled={status !== 'offline'}
onClick={e => {
e.preventDefault();
sendPowerCommand('start');
}}
>
Start
</Button>
</Can>
<Can action={'control.restart'}>
<Button
size={'xsmall'}
isSecondary
css={tw`mr-2`}
disabled={!status}
onClick={e => {
e.preventDefault();
sendPowerCommand('restart');
}}
>
Restart
</Button>
</Can>
<Can action={'control.stop'}>
<StopOrKillButton onPress={action => sendPowerCommand(action)}/>
</Can>
</div>
);
};
export default PowerControls;

View file

@ -1,130 +1,29 @@
import React, { lazy, useEffect, useState } from 'react';
import React, { lazy, memo } from 'react';
import { ServerContext } from '@/state/server';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faCircle, faHdd, faMemory, faMicrochip, faServer } from '@fortawesome/free-solid-svg-icons';
import { bytesToHuman, megabytesToHuman } from '@/helpers';
import SuspenseSpinner from '@/components/elements/SuspenseSpinner';
import TitledGreyBox from '@/components/elements/TitledGreyBox';
import Can from '@/components/elements/Can';
import ContentContainer from '@/components/elements/ContentContainer';
import tw from 'twin.macro';
import Button from '@/components/elements/Button';
import StopOrKillButton from '@/components/server/StopOrKillButton';
import ServerContentBlock from '@/components/elements/ServerContentBlock';
import ServerDetailsBlock from '@/components/server/ServerDetailsBlock';
import isEqual from 'react-fast-compare';
import PowerControls from '@/components/server/PowerControls';
export type PowerAction = 'start' | 'stop' | 'restart' | 'kill';
const ChunkedConsole = lazy(() => import(/* webpackChunkName: "console" */'@/components/server/Console'));
const ChunkedStatGraphs = lazy(() => import(/* webpackChunkName: "graphs" */'@/components/server/StatGraphs'));
export default () => {
const [ memory, setMemory ] = useState(0);
const [ cpu, setCpu ] = useState(0);
const [ disk, setDisk ] = useState(0);
const name = ServerContext.useStoreState(state => state.server.data!.name);
const limits = ServerContext.useStoreState(state => state.server.data!.limits);
const ServerConsole = () => {
const isInstalling = ServerContext.useStoreState(state => state.server.data!.isInstalling);
const status = ServerContext.useStoreState(state => state.status.value);
const connected = ServerContext.useStoreState(state => state.socket.connected);
const instance = ServerContext.useStoreState(state => state.socket.instance);
const statsListener = (data: string) => {
let stats: any = {};
try {
stats = JSON.parse(data);
} catch (e) {
return;
}
setMemory(stats.memory_bytes);
setCpu(stats.cpu_absolute);
setDisk(stats.disk_bytes);
};
const sendPowerCommand = (command: PowerAction) => {
instance && instance.send('set state', command);
};
useEffect(() => {
if (!connected || !instance) {
return;
}
instance.addListener('stats', statsListener);
return () => {
instance.removeListener('stats', statsListener);
};
}, [ instance, connected ]);
const disklimit = limits.disk ? megabytesToHuman(limits.disk) : 'Unlimited';
const memorylimit = limits.memory ? megabytesToHuman(limits.memory) : 'Unlimited';
return (
<ServerContentBlock title={'Console'} css={tw`flex flex-wrap`}>
<div css={tw`w-full md:w-1/4`}>
<TitledGreyBox css={tw`break-all`} title={name} icon={faServer}>
<p css={tw`text-xs uppercase`}>
<FontAwesomeIcon
icon={faCircle}
fixedWidth
css={[
tw`mr-1`,
status === 'offline' ? tw`text-red-500` : (status === 'running' ? tw`text-green-500` : tw`text-yellow-500`),
]}
/>
&nbsp;{!status ? 'Connecting...' : status}
</p>
<p css={tw`text-xs mt-2`}>
<FontAwesomeIcon icon={faMicrochip} fixedWidth css={tw`mr-1`}/> {cpu.toFixed(2)}%
</p>
<p css={tw`text-xs mt-2`}>
<FontAwesomeIcon icon={faMemory} fixedWidth css={tw`mr-1`}/> {bytesToHuman(memory)}
<span css={tw`text-neutral-500`}> / {memorylimit}</span>
</p>
<p css={tw`text-xs mt-2`}>
<FontAwesomeIcon icon={faHdd} fixedWidth css={tw`mr-1`}/>&nbsp;{bytesToHuman(disk)}
<span css={tw`text-neutral-500`}> / {disklimit}</span>
</p>
</TitledGreyBox>
<div css={tw`w-full lg:w-1/4`}>
<ServerDetailsBlock/>
{!isInstalling ?
<Can action={[ 'control.start', 'control.stop', 'control.restart' ]} matchAny>
<div css={tw`shadow-md bg-neutral-700 rounded p-3 flex text-xs mt-4 justify-center`}>
<Can action={'control.start'}>
<Button
size={'xsmall'}
color={'green'}
isSecondary
css={tw`mr-2`}
disabled={status !== 'offline'}
onClick={e => {
e.preventDefault();
sendPowerCommand('start');
}}
>
Start
</Button>
</Can>
<Can action={'control.restart'}>
<Button
size={'xsmall'}
isSecondary
css={tw`mr-2`}
disabled={!status}
onClick={e => {
e.preventDefault();
sendPowerCommand('restart');
}}
>
Restart
</Button>
</Can>
<Can action={'control.stop'}>
<StopOrKillButton onPress={action => sendPowerCommand(action)}/>
</Can>
</div>
<PowerControls/>
</Can>
:
<div css={tw`mt-4 rounded bg-yellow-500 p-3`}>
@ -137,7 +36,7 @@ export default () => {
</div>
}
</div>
<div css={tw`w-full md:flex-1 md:ml-4 mt-4 md:mt-0`}>
<div css={tw`w-full lg:w-3/4 mt-4 lg:mt-0 lg:pl-4`}>
<SuspenseSpinner>
<ChunkedConsole/>
<ChunkedStatGraphs/>
@ -146,3 +45,5 @@ export default () => {
</ServerContentBlock>
);
};
export default memo(ServerConsole, isEqual);

View file

@ -0,0 +1,84 @@
import React, { useEffect, useState } from 'react';
import tw from 'twin.macro';
import { faCircle, faHdd, faMemory, faMicrochip, faServer } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { bytesToHuman, megabytesToHuman } from '@/helpers';
import TitledGreyBox from '@/components/elements/TitledGreyBox';
import { ServerContext } from '@/state/server';
interface Stats {
memory: number;
cpu: number;
disk: number;
}
const ServerDetailsBlock = () => {
const [ stats, setStats ] = useState<Stats>({ memory: 0, cpu: 0, disk: 0 });
const status = ServerContext.useStoreState(state => state.status.value);
const connected = ServerContext.useStoreState(state => state.socket.connected);
const instance = ServerContext.useStoreState(state => state.socket.instance);
const statsListener = (data: string) => {
let stats: any = {};
try {
stats = JSON.parse(data);
} catch (e) {
return;
}
setStats({
memory: stats.memory_bytes,
cpu: stats.cpu_absolute,
disk: stats.disk_bytes,
});
};
useEffect(() => {
if (!connected || !instance) {
return;
}
instance.addListener('stats', statsListener);
instance.send('send stats');
return () => {
instance.removeListener('stats', statsListener);
};
}, [ instance, connected ]);
const name = ServerContext.useStoreState(state => state.server.data!.name);
const limits = ServerContext.useStoreState(state => state.server.data!.limits);
const disklimit = limits.disk ? megabytesToHuman(limits.disk) : 'Unlimited';
const memorylimit = limits.memory ? megabytesToHuman(limits.memory) : 'Unlimited';
return (
<TitledGreyBox css={tw`break-words`} title={name} icon={faServer}>
<p css={tw`text-xs uppercase`}>
<FontAwesomeIcon
icon={faCircle}
fixedWidth
css={[
tw`mr-1`,
status === 'offline' ? tw`text-red-500` : (status === 'running' ? tw`text-green-500` : tw`text-yellow-500`),
]}
/>
&nbsp;{!status ? 'Connecting...' : status}
</p>
<p css={tw`text-xs mt-2`}>
<FontAwesomeIcon icon={faMicrochip} fixedWidth css={tw`mr-1`}/> {stats.cpu.toFixed(2)}%
</p>
<p css={tw`text-xs mt-2`}>
<FontAwesomeIcon icon={faMemory} fixedWidth css={tw`mr-1`}/> {bytesToHuman(stats.memory)}
<span css={tw`text-neutral-500`}> / {memorylimit}</span>
</p>
<p css={tw`text-xs mt-2`}>
<FontAwesomeIcon icon={faHdd} fixedWidth css={tw`mr-1`}/>&nbsp;{bytesToHuman(stats.disk)}
<span css={tw`text-neutral-500`}> / {disklimit}</span>
</p>
</TitledGreyBox>
);
};
export default ServerDetailsBlock;

View file

@ -142,24 +142,28 @@ export default () => {
return (
<div css={tw`flex flex-wrap mt-4`}>
<TitledGreyBox title={'Memory usage'} icon={faMemory} css={tw`md:flex-1 w-full md:w-1/2 md:mr-2`}>
{status !== 'offline' ?
<canvas id={'memory_chart'} ref={memoryRef} aria-label={'Server Memory Usage Graph'} role={'img'}/>
:
<p css={tw`text-xs text-neutral-400 text-center p-3`}>
Server is offline.
</p>
}
</TitledGreyBox>
<TitledGreyBox title={'CPU usage'} icon={faMicrochip} css={tw`md:flex-1 w-full md:w-1/2 md:ml-2 mt-4 md:mt-0`}>
{status !== 'offline' ?
<canvas id={'cpu_chart'} ref={cpuRef} aria-label={'Server CPU Usage Graph'} role={'img'}/>
:
<p css={tw`text-xs text-neutral-400 text-center p-3`}>
Server is offline.
</p>
}
</TitledGreyBox>
<div css={tw`w-full sm:w-1/2`}>
<TitledGreyBox title={'Memory usage'} icon={faMemory} css={tw`mr-0 sm:mr-4`}>
{status !== 'offline' ?
<canvas id={'memory_chart'} ref={memoryRef} aria-label={'Server Memory Usage Graph'} role={'img'}/>
:
<p css={tw`text-xs text-neutral-400 text-center p-3`}>
Server is offline.
</p>
}
</TitledGreyBox>
</div>
<div css={tw`w-full sm:w-1/2 mt-4 sm:mt-0`}>
<TitledGreyBox title={'CPU usage'} icon={faMicrochip} css={tw`ml-0 sm:ml-4`}>
{status !== 'offline' ?
<canvas id={'cpu_chart'} ref={cpuRef} aria-label={'Server CPU Usage Graph'} role={'img'}/>
:
<p css={tw`text-xs text-neutral-400 text-center p-3`}>
Server is offline.
</p>
}
</TitledGreyBox>
</div>
</div>
);
};

View file

@ -8,20 +8,27 @@ import Spinner from '@/components/elements/Spinner';
import tw from 'twin.macro';
export default () => {
const server = ServerContext.useStoreState(state => state.server.data);
const [ error, setError ] = useState(false);
let updatingToken = false;
const [ error, setError ] = useState<'connecting' | string>('');
const { connected, instance } = ServerContext.useStoreState(state => state.socket);
const uuid = ServerContext.useStoreState(state => state.server.data?.uuid);
const setServerStatus = ServerContext.useStoreActions(actions => actions.status.setServerStatus);
const { setInstance, setConnectionState } = ServerContext.useStoreActions(actions => actions.socket);
const updateToken = (uuid: string, socket: Websocket) => {
if (updatingToken) return;
updatingToken = true;
getWebsocketToken(uuid)
.then(data => socket.setToken(data.token, true))
.catch(error => console.error(error));
.catch(error => console.error(error))
.then(() => {
updatingToken = false;
});
};
useEffect(() => {
connected && setError(false);
connected && setError('');
}, [ connected ]);
useEffect(() => {
@ -33,7 +40,7 @@ export default () => {
useEffect(() => {
// If there is already an instance or there is no server, just exit out of this process
// since we don't need to make a new connection.
if (instance || !server) {
if (instance || !uuid) {
return;
}
@ -42,7 +49,7 @@ export default () => {
socket.on('auth success', () => setConnectionState(true));
socket.on('SOCKET_CLOSE', () => setConnectionState(false));
socket.on('SOCKET_ERROR', () => {
setError(true);
setError('connecting');
setConnectionState(false);
});
socket.on('status', (status) => setServerStatus(status));
@ -51,10 +58,20 @@ export default () => {
console.warn('Got error message from daemon socket:', message);
});
socket.on('token expiring', () => updateToken(server.uuid, socket));
socket.on('token expired', () => updateToken(server.uuid, socket));
socket.on('token expiring', () => updateToken(uuid, socket));
socket.on('token expired', () => updateToken(uuid, socket));
socket.on('jwt error', (error: string) => {
setConnectionState(false);
console.warn('JWT validation error from wings:', error);
getWebsocketToken(server.uuid)
if (error === 'jwt: exp claim is invalid') {
updateToken(uuid, socket);
} else {
setError('There was an error validating the credentials provided for the websocket. Please refresh the page.');
}
});
getWebsocketToken(uuid)
.then(data => {
// Connect and then set the authentication token.
socket.setToken(data.token).connect(data.socket);
@ -63,17 +80,25 @@ export default () => {
setInstance(socket);
})
.catch(error => console.error(error));
}, [ server ]);
}, [ uuid ]);
return (
error ?
<CSSTransition timeout={150} in appear classNames={'fade'}>
<div css={tw`bg-red-500 py-2`}>
<ContentContainer css={tw`flex items-center justify-center`}>
<Spinner size={'small'}/>
<p css={tw`ml-2 text-sm text-red-100`}>
We&apos;re having some trouble connecting to your server, please wait...
</p>
{error === 'connecting' ?
<>
<Spinner size={'small'}/>
<p css={tw`ml-2 text-sm text-red-100`}>
We&apos;re having some trouble connecting to your server, please wait...
</p>
</>
:
<p css={tw`ml-2 text-sm text-red-100`}>
{error}
</p>
}
</ContentContainer>
</div>
</CSSTransition>

View file

@ -40,31 +40,35 @@ export default ({ backup, className }: Props) => {
});
return (
<GreyRowBox css={tw`flex items-center`} className={className}>
<div css={tw`mr-4`}>
{backup.completedAt ?
<FontAwesomeIcon icon={faArchive} css={tw`text-neutral-300 hidden md:block`}/>
:
<Spinner size={'small'}/>
}
</div>
<div css={tw`flex-1`}>
<p css={tw`text-sm mb-1`}>
{!backup.isSuccessful &&
<span css={tw`bg-red-500 py-px px-2 rounded-full text-white text-xs uppercase border border-red-600 mr-2`}>
Failed
</span>
<GreyRowBox css={tw`flex-wrap md:flex-no-wrap items-center`} className={className}>
<div css={tw`flex items-center truncate w-full md:flex-1`}>
<div css={tw`mr-4`}>
{backup.completedAt ?
<FontAwesomeIcon icon={faArchive} css={tw`text-neutral-300`}/>
:
<Spinner size={'small'}/>
}
{backup.name}
{(backup.completedAt && backup.isSuccessful) &&
<span css={tw`ml-3 text-neutral-300 text-xs font-thin hidden sm:inline`}>{bytesToHuman(backup.bytes)}</span>
}
</p>
<p css={tw`text-xs text-neutral-400 font-mono hidden md:block`}>
{backup.uuid}
</p>
</div>
<div css={tw`flex flex-col truncate`}>
<div css={tw`flex items-center text-sm mb-1`}>
{!backup.isSuccessful &&
<span css={tw`bg-red-500 py-px px-2 rounded-full text-white text-xs uppercase border border-red-600 mr-2`}>
Failed
</span>
}
<p css={tw`break-words truncate`}>
{backup.name}
</p>
{(backup.completedAt && backup.isSuccessful) &&
<span css={tw`ml-3 text-neutral-300 text-xs font-thin hidden sm:inline`}>{bytesToHuman(backup.bytes)}</span>
}
</div>
<p css={tw`mt-1 md:mt-0 text-xs text-neutral-400 font-mono truncate`}>
{backup.uuid}
</p>
</div>
</div>
<div css={tw`ml-8 text-center`}>
<div css={tw`flex-1 md:flex-none md:w-48 mt-4 md:mt-0 md:ml-8 md:text-center`}>
<p
title={format(backup.createdAt, 'ddd, MMMM do, yyyy HH:mm:ss')}
css={tw`text-sm`}
@ -74,7 +78,7 @@ export default ({ backup, className }: Props) => {
<p css={tw`text-2xs text-neutral-500 uppercase mt-1`}>Created</p>
</div>
<Can action={'backup.download'}>
<div css={tw`ml-6`} style={{ marginRight: '-0.5rem' }}>
<div css={tw`mt-4 md:mt-0 ml-6`} style={{ marginRight: '-0.5rem' }}>
{!backup.completedAt ?
<div css={tw`p-2 invisible`}>
<FontAwesomeIcon icon={faEllipsisH}/>

View file

@ -87,14 +87,14 @@ export default () => {
onSubmit={submit}
initialValues={{ name: '', ignored: '' }}
validationSchema={object().shape({
name: string().max(255),
name: string().max(191),
ignored: string(),
})}
>
<ModalContent appear visible={visible} onDismissed={() => setVisible(false)}/>
</Formik>
}
<Button onClick={() => setVisible(true)}>
<Button css={tw`w-full sm:w-auto`} onClick={() => setVisible(true)}>
Create backup
</Button>
</>

View file

@ -32,6 +32,7 @@ export default () => {
const id = ServerContext.useStoreState(state => state.server.data!.id);
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
const setDirectory = ServerContext.useStoreActions(actions => actions.files.setDirectory);
const { addError, clearFlashes } = useFlash();
let fetchFileContent: null | (() => Promise<string>) = null;
@ -39,8 +40,9 @@ export default () => {
useEffect(() => {
if (action === 'new') return;
setLoading(true);
setError('');
setLoading(true);
setDirectory(hash.replace(/^#/, '').split('/').filter(v => !!v).slice(0, -1).join('/'));
getFileContents(uuid, hash.replace(/^#/, ''))
.then(setContent)
.catch(error => {
@ -116,7 +118,13 @@ export default () => {
fetchContent={value => {
fetchFileContent = value;
}}
onContentSaved={save}
onContentSaved={() => {
if (action !== 'edit') {
setModalVisible(true);
} else {
save();
}
}}
/>
</div>
<div css={tw`flex justify-end mt-4`}>

View file

@ -45,13 +45,15 @@ export default ({ withinFileEditor, isNewFile }: Props) => {
return (
<div css={tw`flex items-center text-sm mb-4 text-neutral-500`}>
{(files && files.length > 0 && !params?.action) &&
<FileActionCheckbox
type={'checkbox'}
css={tw`mx-4`}
checked={selectedFilesLength === (files ? files.length : -1)}
onChange={onSelectAllClick}
/>
{(files && files.length > 0 && !params?.action) ?
<FileActionCheckbox
type={'checkbox'}
css={tw`mx-4`}
checked={selectedFilesLength === (files ? files.length : -1)}
onChange={onSelectAllClick}
/>
:
<div css={tw`w-12`}/>
}
/<span css={tw`px-1 text-neutral-300`}>home</span>/
<NavLink

View file

@ -38,13 +38,13 @@ const Clickable: React.FC<{ file: FileObject }> = memo(({ file, children }) => {
return (
(!canReadContents || (file.isFile && !file.isEditable())) ?
<div css={tw`flex flex-1 text-neutral-300 no-underline p-3 cursor-default`}>
<div css={tw`flex flex-1 text-neutral-300 no-underline p-3 cursor-default overflow-hidden truncate`}>
{children}
</div>
:
<NavLink
to={`${match.url}/${file.isFile ? 'edit/' : ''}#${cleanDirectoryPath(`${directory}/${file.name}`)}`}
css={tw`flex flex-1 text-neutral-300 no-underline p-3`}
css={tw`flex flex-1 text-neutral-300 no-underline p-3 overflow-hidden truncate`}
onClick={onRowClick}
>
{children}
@ -69,7 +69,7 @@ const FileObjectRow = ({ file }: { file: FileObject }) => (
<FontAwesomeIcon icon={faFolder}/>
}
</div>
<div css={tw`flex-1`}>
<div css={tw`flex-1 truncate`}>
{file.name}
</div>
{file.isFile &&
@ -92,4 +92,11 @@ const FileObjectRow = ({ file }: { file: FileObject }) => (
</Row>
);
export default memo(FileObjectRow, isEqual);
export default memo(FileObjectRow, (prevProps, nextProps) => {
/* eslint-disable @typescript-eslint/no-unused-vars */
const { isArchiveType, isEditable, ...prevFile } = prevProps.file;
const { isArchiveType: nextIsArchiveType, isEditable: nextIsEditable, ...nextFile } = nextProps.file;
/* eslint-enable @typescript-eslint/no-unused-vars */
return isEqual(prevFile, nextFile);
});

View file

@ -24,7 +24,7 @@ const schema = object().shape({
const generateDirectoryData = (name: string): FileObject => ({
key: `dir_${name.split('/', 1)[0] ?? name}`,
name: name.split('/', 1)[0] ?? name,
name: name.replace(/^(\/*)/, '').split('/', 1)[0] ?? name,
mode: '0644',
size: 0,
isFile: false,
@ -88,7 +88,7 @@ export default ({ className }: WithClassname) => {
name={'directoryName'}
label={'Directory Name'}
/>
<p css={tw`text-xs mt-2 text-neutral-400`}>
<p css={tw`text-xs mt-2 text-neutral-400 break-all`}>
<span css={tw`text-neutral-200`}>This directory will be created as</span>
&nbsp;/home/container/
<span css={tw`text-cyan-200`}>

View file

@ -15,7 +15,7 @@ import setServerAllocationNotes from '@/api/server/network/setServerAllocationNo
import useFlash from '@/plugins/useFlash';
import { ServerContext } from '@/state/server';
const Code = styled.code`${tw`font-mono py-1 px-2 bg-neutral-900 rounded text-sm block`}`;
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`}`;
interface Props {
@ -40,22 +40,21 @@ const AllocationRow = ({ allocation, onSetPrimary, onNotesChanged }: Props) => {
}, 750);
return (
<GreyRowBox
$hoverable={false}
css={tw`mt-2 overflow-x-auto`}
>
<div css={tw`hidden md:block pl-4 pr-6 text-neutral-400`}>
<FontAwesomeIcon icon={faNetworkWired}/>
<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}/>
</div>
<div css={tw`mr-4 flex-1 md:w-40`}>
<Code>{allocation.alias || allocation.ip}</Code>
<Label>IP Address</Label>
</div>
<div css={tw`w-16 md:w-24 overflow-hidden`}>
<Code>{allocation.port}</Code>
<Label>Port</Label>
</div>
</div>
<div css={tw`mr-4`}>
<Code>{allocation.alias || allocation.ip}</Code>
<Label>IP Address</Label>
</div>
<div>
<Code>{allocation.port}</Code>
<Label>Port</Label>
</div>
<div css={tw`px-8 flex-none sm:flex-1 self-start`}>
<div css={tw`mt-4 w-full md:mt-0 md:flex-1 md:w-auto`}>
<InputSpinner visible={loading}>
<Textarea
css={tw`bg-neutral-800 hover:border-neutral-600 border-transparent`}
@ -65,7 +64,7 @@ const AllocationRow = ({ allocation, onSetPrimary, onNotesChanged }: Props) => {
/>
</InputSpinner>
</div>
<div css={tw`w-32 text-right pr-4 sm:pr-0`}>
<div css={tw`w-full md:flex-none md:w-32 md:text-center mt-4 md:mt-0 text-right ml-4`}>
{allocation.isDefault ?
<span css={tw`bg-green-500 py-1 px-2 rounded text-green-50 text-xs`}>Primary</span>
:

View file

@ -81,7 +81,7 @@ export default ({ schedule, task }: Props) => {
<div css={tw`md:ml-6 mt-2`}>
{task.action === 'backup' &&
<p css={tw`text-xs uppercase text-neutral-400 mb-1`}>Ignoring files & folders:</p>}
<div css={tw`font-mono bg-neutral-800 rounded py-1 px-2 text-sm w-auto whitespace-pre inline-block break-all`}>
<div css={tw`font-mono bg-neutral-800 rounded py-1 px-2 text-sm w-auto inline-block whitespace-pre-wrap break-all`}>
{task.payload}
</div>
</div>

View file

@ -250,7 +250,10 @@ export default ({ subuser, ...props }: Props) => {
permissions: subuser?.permissions || [],
} as Values}
validationSchema={object().shape({
email: string().email('A valid email address must be provided.').required('A valid email address must be provided.'),
email: string()
.max(191, 'Email addresses must not exceed 191 characters.')
.email('A valid email address must be provided.')
.required('A valid email address must be provided.'),
permissions: array().of(string()),
})}
>

View file

@ -30,8 +30,8 @@ export default ({ subuser }: Props) => {
<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>
<div css={tw`ml-4 flex-1`}>
<p css={tw`text-sm break-all`}>{subuser.email}</p>
<div css={tw`ml-4 flex-1 overflow-hidden`}>
<p css={tw`text-sm truncate`}>{subuser.email}</p>
</div>
<div css={tw`ml-4`}>
<p css={tw`font-medium text-center`}>