Merge branch 'develop' into develop
This commit is contained in:
commit
ea778e9345
159 changed files with 3400 additions and 3896 deletions
|
@ -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`}>
|
||||
|
|
55
resources/scripts/components/server/PowerControls.tsx
Normal file
55
resources/scripts/components/server/PowerControls.tsx
Normal 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;
|
|
@ -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`),
|
||||
]}
|
||||
/>
|
||||
{!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`}/> {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);
|
||||
|
|
84
resources/scripts/components/server/ServerDetailsBlock.tsx
Normal file
84
resources/scripts/components/server/ServerDetailsBlock.tsx
Normal 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`),
|
||||
]}
|
||||
/>
|
||||
{!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`}/> {bytesToHuman(stats.disk)}
|
||||
<span css={tw`text-neutral-500`}> / {disklimit}</span>
|
||||
</p>
|
||||
</TitledGreyBox>
|
||||
);
|
||||
};
|
||||
|
||||
export default ServerDetailsBlock;
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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'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'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>
|
||||
|
|
|
@ -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}/>
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
|
|
|
@ -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`}>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
/home/container/
|
||||
<span css={tw`text-cyan-200`}>
|
||||
|
|
|
@ -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>
|
||||
:
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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()),
|
||||
})}
|
||||
>
|
||||
|
|
|
@ -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`}>
|
||||
|
|
Reference in a new issue