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

@ -9,7 +9,6 @@ interface Response {
}
export default (uuid: string, initialData?: Response) => useSWR([ uuid, '/startup' ], async (): Promise<Response> => {
console.log('firing getServerStartup');
const { data } = await http.get(`/api/client/servers/${uuid}/startup`);
const variables = ((data as FractalResponseList).data || []).map(rawDataToServerEggVariable);

View file

@ -94,7 +94,6 @@ export default () => {
}
<div css={tw`mt-6 text-center`}>
<Link
type={'button'}
to={'/auth/login'}
css={tw`text-xs text-neutral-500 tracking-wide uppercase no-underline hover:text-neutral-700`}
>

View file

@ -14,26 +14,8 @@ import { httpErrorToHuman } from '@/api/http';
import { format } from 'date-fns';
import PageContentBlock from '@/components/elements/PageContentBlock';
import tw from 'twin.macro';
import { breakpoint } from '@/theme';
import styled from 'styled-components/macro';
import GreyRowBox from '@/components/elements/GreyRowBox';
const Container = styled.div`
${tw`flex flex-wrap my-10`};
& > div {
${tw`w-full`};
${breakpoint('md')`
width: calc(50% - 1rem);
`}
${breakpoint('xl')`
${tw`w-auto flex-1`};
`}
}
`;
export default () => {
const [ deleteIdentifier, setDeleteIdentifier ] = useState('');
const [ keys, setKeys ] = useState<ApiKey[]>([]);
@ -67,12 +49,12 @@ export default () => {
return (
<PageContentBlock title={'Account API'}>
<FlashMessageRender byKey={'account'} css={tw`mb-4`}/>
<Container>
<ContentBox title={'Create API Key'}>
<FlashMessageRender byKey={'account'}/>
<div css={tw`md:flex flex-no-wrap my-10`}>
<ContentBox title={'Create API Key'} css={tw`flex-none w-full md:w-1/2`}>
<CreateApiKeyForm onKeyCreated={key => setKeys(s => ([ ...s!, key ]))}/>
</ContentBox>
<ContentBox title={'API Keys'} css={tw`mt-8 md:mt-0 md:ml-8`}>
<ContentBox title={'API Keys'} css={tw`flex-1 overflow-hidden mt-8 md:mt-0 md:ml-8`}>
<SpinnerOverlay visible={loading}/>
<ConfirmationModal
visible={!!deleteIdentifier}
@ -99,14 +81,14 @@ export default () => {
css={[ tw`bg-neutral-600 flex items-center`, index > 0 && tw`mt-2` ]}
>
<FontAwesomeIcon icon={faKey} css={tw`text-neutral-300`}/>
<div css={tw`ml-4 flex-1`}>
<p css={tw`text-sm`}>{key.description}</p>
<div css={tw`ml-4 flex-1 overflow-hidden`}>
<p css={tw`text-sm break-words`}>{key.description}</p>
<p css={tw`text-2xs text-neutral-300 uppercase`}>
Last used:&nbsp;
{key.lastUsedAt ? format(key.lastUsedAt, 'MMM do, yyyy HH:mm') : 'Never'}
</p>
</div>
<p css={tw`text-sm ml-4`}>
<p css={tw`text-sm ml-4 hidden md:block`}>
<code css={tw`font-mono py-1 px-2 bg-neutral-900 rounded`}>
{key.identifier}
</code>
@ -124,7 +106,7 @@ export default () => {
))
}
</ContentBox>
</Container>
</div>
</PageContentBlock>
);
};

View file

@ -1,21 +1,43 @@
import React, { useEffect, useRef, useState } from 'react';
import React, { memo, useEffect, useRef, useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faEthernet, faHdd, faMemory, faMicrochip, faServer } from '@fortawesome/free-solid-svg-icons';
import { Link } from 'react-router-dom';
import { Server } from '@/api/server/getServer';
import getServerResourceUsage, { ServerStats } from '@/api/server/getServerResourceUsage';
import getServerResourceUsage, { ServerPowerState, ServerStats } from '@/api/server/getServerResourceUsage';
import { bytesToHuman, megabytesToHuman } from '@/helpers';
import tw from 'twin.macro';
import GreyRowBox from '@/components/elements/GreyRowBox';
import Spinner from '@/components/elements/Spinner';
import styled from 'styled-components/macro';
import isEqual from 'react-fast-compare';
// Determines if the current value is in an alarm threshold so we can show it in red rather
// than the more faded default style.
const isAlarmState = (current: number, limit: number): boolean => {
const limitInBytes = limit * 1024 * 1024;
const isAlarmState = (current: number, limit: number): boolean => limit > 0 && (current / (limit * 1024 * 1024) >= 0.90);
return current / limitInBytes >= 0.90;
};
const Icon = memo(styled(FontAwesomeIcon)<{ $alarm: boolean }>`
${props => props.$alarm ? tw`text-red-400` : tw`text-neutral-500`};
`, isEqual);
const IconDescription = styled.p<{ $alarm: boolean }>`
${tw`text-sm ml-2`};
${props => props.$alarm ? tw`text-white` : tw`text-neutral-400`};
`;
const StatusIndicatorBox = styled(GreyRowBox)<{ $status: ServerPowerState | undefined }>`
${tw`grid grid-cols-12 gap-4 relative`};
& .status-bar {
${tw`w-2 bg-red-500 absolute right-0 z-20 rounded-full m-1 opacity-50 transition-all duration-150`};
height: calc(100% - 0.5rem);
${({ $status }) => (!$status || $status === 'offline') ? tw`bg-red-500` : ($status === 'running' ? tw`bg-green-500` : tw`bg-yellow-500`)};
}
&:hover .status-bar {
${tw`opacity-75`};
}
`;
export default ({ server, className }: { server: Server; className?: string }) => {
const interval = useRef<number>(null);
@ -54,29 +76,31 @@ export default ({ server, className }: { server: Server; className?: string }) =
const memorylimit = server.limits.memory !== 0 ? megabytesToHuman(server.limits.memory) : 'Unlimited';
return (
<GreyRowBox as={Link} to={`/server/${server.id}`} className={className}>
<div className={'icon'} css={tw`hidden md:block`}>
<FontAwesomeIcon icon={faServer}/>
</div>
<div css={tw`flex-1 md:ml-4`}>
<p css={tw`text-lg break-all`}>{server.name}</p>
{!!server.description &&
<p css={tw`text-sm text-neutral-300 break-all`}>{server.description}</p>
}
</div>
<div css={tw`w-48 overflow-hidden self-start hidden lg:block`}>
<div css={tw`flex ml-4 justify-end`}>
<FontAwesomeIcon icon={faEthernet} css={tw`text-neutral-500`}/>
<p css={tw`text-sm text-neutral-400 ml-2`}>
{
server.allocations.filter(alloc => alloc.isDefault).map(allocation => (
<span key={allocation.ip + allocation.port.toString()}>{allocation.alias || allocation.ip}:{allocation.port}</span>
))
}
</p>
<StatusIndicatorBox as={Link} to={`/server/${server.id}`} className={className} $status={stats?.status}>
<div css={tw`flex items-center col-span-12 sm:col-span-5 lg:col-span-6`}>
<div className={'icon'} css={tw`mr-4`}>
<FontAwesomeIcon icon={faServer}/>
</div>
<div>
<p css={tw`text-lg break-words`}>{server.name}</p>
{!!server.description &&
<p css={tw`text-sm text-neutral-300 break-words`}>{server.description}</p>
}
</div>
</div>
<div css={tw`w-1/3 sm:w-1/2 lg:w-1/3 flex items-baseline justify-center relative`}>
<div css={tw`hidden lg:col-span-2 lg:flex ml-4 justify-end h-full`}>
<FontAwesomeIcon icon={faEthernet} css={tw`text-neutral-500`}/>
<p css={tw`text-sm text-neutral-400 ml-2`}>
{
server.allocations.filter(alloc => alloc.isDefault).map(allocation => (
<React.Fragment key={allocation.ip + allocation.port.toString()}>
{allocation.alias || allocation.ip}:{allocation.port}
</React.Fragment>
))
}
</p>
</div>
<div css={tw`hidden col-span-7 lg:col-span-4 sm:flex items-baseline justify-center`}>
{!stats ?
!statsError ?
<Spinner size={'small'}/>
@ -96,80 +120,33 @@ export default ({ server, className }: { server: Server; className?: string }) =
:
<React.Fragment>
<div css={tw`flex-1 flex md:ml-4 sm:flex hidden justify-center`}>
<FontAwesomeIcon
icon={faMicrochip}
css={[
!alarms.cpu && tw`text-neutral-500`,
alarms.cpu && tw`text-red-400`,
]}
/>
<p
css={[
tw`text-sm ml-2`,
!alarms.cpu && tw`text-neutral-400`,
alarms.cpu && tw`text-white`,
]}
>
<Icon icon={faMicrochip} $alarm={alarms.cpu}/>
<IconDescription $alarm={alarms.cpu}>
{stats.cpuUsagePercent} %
</p>
</IconDescription>
</div>
<div css={tw`flex-1 ml-4 sm:block hidden`}>
<div css={tw`flex justify-center`}>
<FontAwesomeIcon
icon={faMemory}
css={[
!alarms.memory && tw`text-neutral-500`,
alarms.memory && tw`text-red-400`,
]}
/>
<p
css={[
tw`text-sm ml-2`,
!alarms.memory && tw`text-neutral-400`,
alarms.memory && tw`text-white`,
]}
>
<Icon icon={faMemory} $alarm={alarms.memory}/>
<IconDescription $alarm={alarms.memory}>
{bytesToHuman(stats.memoryUsageInBytes)}
</p>
</IconDescription>
</div>
<p css={tw`text-xs text-neutral-600 text-center mt-1`}>of {memorylimit}</p>
</div>
<div css={tw`flex-1 ml-4 sm:block hidden`}>
<div css={tw`flex justify-center`}>
<FontAwesomeIcon
icon={faHdd}
css={[
!alarms.disk && tw`text-neutral-500`,
alarms.disk && tw`text-red-400`,
]}
/>
<p
css={[
tw`text-sm ml-2`,
!alarms.disk && tw`text-neutral-400`,
alarms.disk && tw`text-white`,
]}
>
<Icon icon={faHdd} $alarm={alarms.disk}/>
<IconDescription $alarm={alarms.disk}>
{bytesToHuman(stats.diskUsageInBytes)}
</p>
</IconDescription>
</div>
<p css={tw`text-xs text-neutral-600 text-center mt-1`}>of {disklimit}</p>
</div>
<div css={tw`flex-1 flex justify-end sm:hidden`}>
<div css={tw`flex items-end text-right`}>
<div
css={[
tw`w-3 h-3 rounded-full`,
(!stats?.status || stats?.status === 'offline')
? tw`bg-red-500`
: (stats?.status === 'running' ? tw`bg-green-500` : tw`bg-yellow-500`),
]}
/>
</div>
</div>
</React.Fragment>
}
</div>
</GreyRowBox>
<div className={'status-bar'}/>
</StatusIndicatorBox>
);
};

View file

@ -69,6 +69,7 @@ export default ({ onDismissed, ...props }: RequiredModalProps) => {
{({ isSubmitting }) => (
<Modal
{...props}
top={false}
onDismissed={dismiss}
dismissable={!isSubmitting}
showSpinnerOverlay={loading || isSubmitting}

View file

@ -2,11 +2,11 @@ import styled from 'styled-components/macro';
import tw from 'twin.macro';
export default styled.div<{ $hoverable?: boolean }>`
${tw`flex rounded no-underline text-neutral-200 items-center bg-neutral-700 p-4 border border-transparent transition-colors duration-150`};
${tw`flex rounded no-underline text-neutral-200 items-center bg-neutral-700 p-4 border border-transparent transition-colors duration-150 overflow-hidden`};
${props => props.$hoverable !== false && tw`hover:border-neutral-500`};
& > div.icon {
& .icon {
${tw`rounded-full bg-neutral-500 p-3`};
}
`;

View file

@ -35,6 +35,8 @@ const ModalContainer = styled.div<{ alignTop?: boolean }>`
margin-top: 20%;
${breakpoint('md')`margin-top: 10%`};
`};
margin-bottom: auto;
& > .close-icon {
${tw`absolute right-0 p-2 text-white cursor-pointer opacity-50 transition-all duration-150 ease-linear hover:opacity-100`};

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`}>

View file

@ -30,7 +30,7 @@ import StartupContainer from '@/components/server/startup/StartupContainer';
import requireServerPermission from '@/hoc/requireServerPermission';
const ServerRouter = ({ match, location }: RouteComponentProps<{ id: string }>) => {
const { rootAdmin } = useStoreState(state => state.user.data!);
const rootAdmin = useStoreState(state => state.user.data!.rootAdmin);
const [ error, setError ] = useState('');
const [ installing, setInstalling ] = useState(false);

View file

@ -70,7 +70,7 @@
<div class="col-md-12">
<label for="pLongModal" class="form-label">Description</label>
<textarea name="long" id="pLongModal" class="form-control" rows="4"></textarea>
<p class="text-muted small">A longer description of this location. Must be less than 255 characters.</p>
<p class="text-muted small">A longer description of this location. Must be less than 191 characters.</p>
</div>
</div>
</div>

View file

@ -84,7 +84,7 @@
<div class="col-md-12">
<label for="pDescription" class="form-label">Description</label>
<textarea id="pDescription" name="description" class="form-control" rows="4"></textarea>
<p class="text-muted small">A longer description for this mount, must be less than 255 characters.</p>
<p class="text-muted small">A longer description for this mount, must be less than 191 characters.</p>
</div>
<div class="col-md-6">

View file

@ -158,7 +158,7 @@
</div>
<div class="form-group col-xs-6">
<label for="pThreads">CPU Threads</label>
<label for="pThreads">CPU Pinning</label>
<div>
<input type="text" id="pThreads" name="threads" class="form-control" value="{{ old('threads') }}" />
@ -327,7 +327,7 @@
// END Persist 'Service Variables'
</script>
{!! Theme::js('js/admin/new-server.js?v=20200913') !!}
{!! Theme::js('js/admin/new-server.js?v=20201003') !!}
<script type="application/javascript">
$(document).ready(function() {

View file

@ -38,7 +38,7 @@
<p class="text-muted small">Each <em>physical</em> core on the system is considered to be <code>100%</code>. Setting this value to <code>0</code> will allow a server to use CPU time without restrictions.</p>
</div>
<div class="form-group">
<label for="threads" class="control-label">CPU Threads</label>
<label for="threads" class="control-label">CPU Pinning</label>
<div>
<input type="text" name="threads" class="form-control" value="{{ old('threads', $server->threads) }}"/>
</div>

View file

@ -66,6 +66,12 @@
@section('footer-scripts')
@parent
<script>
function escapeHtml(str) {
var div = document.createElement('div');
div.appendChild(document.createTextNode(str));
return div.innerHTML;
}
$('#pUserId').select2({
ajax: {
url: '/admin/users/accounts.json',
@ -85,14 +91,14 @@
escapeMarkup: function (markup) { return markup; },
minimumInputLength: 2,
templateResult: function (data) {
if (data.loading) return data.text;
if (data.loading) return escapeHtml(data.text);
return '<div class="user-block"> \
<img class="img-circle img-bordered-xs" src="https://www.gravatar.com/avatar/' + data.md5 + '?s=120" alt="User Image"> \
<img class="img-circle img-bordered-xs" src="https://www.gravatar.com/avatar/' + escapeHtml(data.md5) + '?s=120" alt="User Image"> \
<span class="username"> \
<a href="#">' + data.name_first + ' ' + data.name_last +'</a> \
<a href="#">' + escapeHtml(data.name_first) + ' ' + escapeHtml(data.name_last) +'</a> \
</span> \
<span class="description"><strong>' + data.email + '</strong> - ' + data.username + '</span> \
<span class="description"><strong>' + escapeHtml(data.email) + '</strong> - ' + escapeHtml(data.username) + '</span> \
</div>';
},
templateSelection: function (data) {
@ -108,10 +114,10 @@
return '<div> \
<span> \
<img class="img-rounded img-bordered-xs" src="https://www.gravatar.com/avatar/' + data.md5 + '?s=120" style="height:28px;margin-top:-4px;" alt="User Image"> \
<img class="img-rounded img-bordered-xs" src="https://www.gravatar.com/avatar/' + escapeHtml(data.md5) + '?s=120" style="height:28px;margin-top:-4px;" alt="User Image"> \
</span> \
<span style="padding-left:5px;"> \
' + data.name_first + ' ' + data.name_last + ' (<strong>' + data.email + '</strong>) \
' + escapeHtml(data.name_first) + ' ' + escapeHtml(data.name_last) + ' (<strong>' + escapeHtml(data.email) + '</strong>) \
</span> \
</div>';
}

View file

@ -68,7 +68,7 @@
</td>
</tr>
<tr>
<td>CPU Threads</td>
<td>CPU Pinning</td>
<td>
@if($server->threads != null)
<code>{{ $server->threads }}</code>