Merge branch 'develop' into fix/2071

This commit is contained in:
Matthew Penner 2020-07-11 12:29:04 -06:00 committed by GitHub
commit a9bb692112
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
194 changed files with 5396 additions and 5416 deletions

View file

@ -3,10 +3,10 @@ import { ITerminalOptions, Terminal } from 'xterm';
import * as TerminalFit from 'xterm/lib/addons/fit/fit';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import { ServerContext } from '@/state/server';
import styled from 'styled-components';
import Can from '@/components/elements/Can';
import styled from 'styled-components/macro';
import { usePermissions } from '@/plugins/usePermissions';
import classNames from 'classnames';
import tw from 'twin.macro';
import 'xterm/dist/xterm.css';
const theme = {
background: 'transparent',
@ -55,7 +55,7 @@ export default () => {
const useRef = useCallback(node => setTerminalElement(node), []);
const terminal = useMemo(() => new Terminal({ ...terminalProps }), []);
const { connected, instance } = ServerContext.useStoreState(state => state.socket);
const [ canSendCommands ] = usePermissions([ 'control.console']);
const [ canSendCommands ] = usePermissions([ 'control.console' ]);
const handleConsoleOutput = (line: string, prelude = false) => terminal.writeln(
(prelude ? TERMINAL_PRELUDE : '') + line.replace(/(?:\r\n|\r|\n)$/im, '') + '\u001b[0m',
@ -122,12 +122,13 @@ export default () => {
}, [ connected, instance ]);
return (
<div className={'text-xs font-mono relative'}>
<div css={tw`text-xs font-mono relative`}>
<SpinnerOverlay visible={!connected} size={'large'}/>
<div
className={classNames('rounded-t p-2 bg-black w-full', {
'rounded-b': !canSendCommands,
})}
css={[
tw`rounded-t p-2 bg-black w-full`,
!canSendCommands && tw`rounded-b`,
]}
style={{
minHeight: '16rem',
maxHeight: '32rem',
@ -136,13 +137,13 @@ export default () => {
<TerminalDiv id={'terminal'} ref={useRef}/>
</div>
{canSendCommands &&
<div className={'rounded-b bg-neutral-900 text-neutral-100 flex'}>
<div className={'flex-no-shrink p-2 font-bold'}>$</div>
<div className={'w-full'}>
<div css={tw`rounded-b bg-neutral-900 text-neutral-100 flex`}>
<div css={tw`flex-shrink-0 p-2 font-bold`}>$</div>
<div css={tw`w-full`}>
<input
type={'text'}
disabled={!instance || !connected}
className={'bg-transparent text-neutral-100 p-2 pl-0 w-full'}
css={tw`bg-transparent text-neutral-100 p-2 pl-0 w-full`}
onKeyDown={e => handleCommandKeydown(e)}
/>
</div>

View file

@ -1,47 +1,22 @@
import React, { lazy, useEffect, useState } from 'react';
import { ServerContext } from '@/state/server';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faServer } from '@fortawesome/free-solid-svg-icons/faServer';
import { faCircle } from '@fortawesome/free-solid-svg-icons/faCircle';
import classNames from 'classnames';
import { faMemory } from '@fortawesome/free-solid-svg-icons/faMemory';
import { faMicrochip } from '@fortawesome/free-solid-svg-icons/faMicrochip';
import { faHdd } from '@fortawesome/free-solid-svg-icons/faHdd';
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 PageContentBlock from '@/components/elements/PageContentBlock';
import ContentContainer from '@/components/elements/ContentContainer';
import tw from 'twin.macro';
import Button from '@/components/elements/Button';
import StopOrKillButton from '@/components/server/StopOrKillButton';
type PowerAction = 'start' | 'stop' | 'restart' | 'kill';
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'));
const StopOrKillButton = ({ onPress }: { onPress: (action: PowerAction) => void }) => {
const [ clicked, setClicked ] = useState(false);
const status = ServerContext.useStoreState(state => state.status.value);
useEffect(() => {
setClicked(state => [ 'stopping' ].indexOf(status) < 0 ? false : state);
}, [ status ]);
return (
<button
className={'btn btn-red btn-xs'}
disabled={status === 'offline'}
onClick={e => {
e.preventDefault();
onPress(clicked ? 'kill' : 'stop');
setClicked(true);
}}
>
{clicked ? 'Kill' : 'Stop'}
</button>
);
};
export default () => {
const [ memory, setMemory ] = useState(0);
const [ cpu, setCpu ] = useState(0);
@ -81,59 +56,45 @@ export default () => {
};
}, [ instance, connected ]);
const disklimit = server.limits.disk != 0 ? megabytesToHuman(server.limits.disk) : "Unlimited";
const memorylimit = server.limits.memory != 0 ? megabytesToHuman(server.limits.memory) : "Unlimited";
const disklimit = server.limits.disk ? megabytesToHuman(server.limits.disk) : 'Unlimited';
const memorylimit = server.limits.memory ? megabytesToHuman(server.limits.memory) : 'Unlimited';
return (
<PageContentBlock className={'flex'}>
<div className={'w-1/4'}>
<PageContentBlock css={tw`flex`}>
<div css={tw`w-1/4`}>
<TitledGreyBox title={server.name} icon={faServer}>
<p className={'text-xs uppercase'}>
<p css={tw`text-xs uppercase`}>
<FontAwesomeIcon
icon={faCircle}
fixedWidth={true}
className={classNames('mr-1', {
'text-red-500': status === 'offline',
'text-yellow-500': [ 'running', 'offline' ].indexOf(status) < 0,
'text-green-500': status === 'running',
})}
fixedWidth
css={[
tw`mr-1`,
status === 'offline' ? tw`text-red-500` : (status === 'running' ? tw`text-green-500` : tw`text-yellow-500`),
]}
/>
&nbsp;{status}
</p>
<p className={'text-xs mt-2'}>
<FontAwesomeIcon
icon={faMicrochip}
fixedWidth={true}
className={'mr-1'}
/>
&nbsp;{cpu.toFixed(2)} %
<p css={tw`text-xs mt-2`}>
<FontAwesomeIcon icon={faMicrochip} fixedWidth css={tw`mr-1`}/> {cpu.toFixed(2)}%
</p>
<p className={'text-xs mt-2'}>
<FontAwesomeIcon
icon={faMemory}
fixedWidth={true}
className={'mr-1'}
/>
&nbsp;{bytesToHuman(memory)}
<span className={'text-neutral-500'}> / {memorylimit}</span>
<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 className={'text-xs mt-2'}>
<FontAwesomeIcon
icon={faHdd}
fixedWidth={true}
className={'mr-1'}
/>
&nbsp;{bytesToHuman(disk)}
<span className={'text-neutral-500'}> / {disklimit}</span>
<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>
{!server.isInstalling ?
<Can action={[ 'control.start', 'control.stop', 'control.restart' ]} matchAny={true}>
<div className={'grey-box justify-center'}>
<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
className={'btn btn-secondary btn-green btn-xs mr-2'}
<Button
size={'xsmall'}
color={'green'}
isSecondary
css={tw`mr-2`}
disabled={status !== 'offline'}
onClick={e => {
e.preventDefault();
@ -141,18 +102,20 @@ export default () => {
}}
>
Start
</button>
</Button>
</Can>
<Can action={'control.restart'}>
<button
className={'btn btn-secondary btn-primary btn-xs mr-2'}
<Button
size={'xsmall'}
isSecondary
css={tw`mr-2`}
onClick={e => {
e.preventDefault();
sendPowerCommand('restart');
}}
>
Restart
</button>
</Button>
</Can>
<Can action={'control.stop'}>
<StopOrKillButton onPress={action => sendPowerCommand(action)}/>
@ -160,9 +123,9 @@ export default () => {
</div>
</Can>
:
<div className={'mt-4 rounded bg-yellow-500 p-3'}>
<div css={tw`mt-4 rounded bg-yellow-500 p-3`}>
<ContentContainer>
<p className={'text-sm text-yellow-900'}>
<p css={tw`text-sm text-yellow-900`}>
This server is currently running its installation process and most actions are
unavailable.
</p>
@ -170,7 +133,7 @@ export default () => {
</div>
}
</div>
<div className={'flex-1 ml-4'}>
<div css={tw`flex-1 ml-4`}>
<SuspenseSpinner>
<ChunkedConsole/>
<ChunkedStatGraphs/>

View file

@ -2,12 +2,12 @@ import React, { useCallback, useEffect, useState } from 'react';
import Chart, { ChartConfiguration } from 'chart.js';
import { ServerContext } from '@/state/server';
import { bytesToMegabytes } from '@/helpers';
import merge from 'lodash-es/merge';
import merge from 'deepmerge';
import TitledGreyBox from '@/components/elements/TitledGreyBox';
import { faMemory } from '@fortawesome/free-solid-svg-icons/faMemory';
import { faMicrochip } from '@fortawesome/free-solid-svg-icons/faMicrochip';
import { faMemory, faMicrochip } from '@fortawesome/free-solid-svg-icons';
import tw from 'twin.macro';
const chartDefaults: ChartConfiguration = {
const chartDefaults = (ticks?: Chart.TickOptions | undefined): ChartConfiguration => ({
type: 'line',
options: {
legend: {
@ -45,21 +45,17 @@ const chartDefaults: ChartConfiguration = {
zeroLineColor: 'rgba(15, 178, 184, 0.45)',
zeroLineWidth: 3,
},
ticks: {
ticks: merge(ticks || {}, {
fontSize: 10,
fontFamily: '"IBM Plex Mono", monospace',
fontColor: 'rgb(229, 232, 235)',
min: 0,
beginAtZero: true,
maxTicksLimit: 5,
},
}),
} ],
},
},
};
const createDefaultChart = (ctx: CanvasRenderingContext2D, options?: ChartConfiguration): Chart => new Chart(ctx, {
...merge({}, chartDefaults, options),
data: {
labels: Array(20).fill(''),
datasets: [
@ -84,18 +80,12 @@ export default () => {
return;
}
setMemory(createDefaultChart(node.getContext('2d')!, {
options: {
scales: {
yAxes: [ {
ticks: {
callback: (value) => `${value}Mb `,
suggestedMax: limits.memory,
},
} ],
},
},
}));
setMemory(
new Chart(node.getContext('2d')!, chartDefaults({
callback: (value) => `${value}Mb `,
suggestedMax: limits.memory,
}))
);
}, []);
const cpuRef = useCallback<(node: HTMLCanvasElement | null) => void>(node => {
@ -103,17 +93,11 @@ export default () => {
return;
}
setCpu(createDefaultChart(node.getContext('2d')!, {
options: {
scales: {
yAxes: [ {
ticks: {
callback: (value) => `${value}% `,
},
} ],
},
},
}));
setCpu(
new Chart(node.getContext('2d')!, chartDefaults({
callback: (value) => `${value}%`,
})),
);
}, []);
const statsListener = (data: string) => {
@ -157,21 +141,21 @@ export default () => {
}, [ instance, connected, memory, cpu ]);
return (
<div className={'flex mt-4'}>
<TitledGreyBox title={'Memory usage'} icon={faMemory} className={'flex-1 mr-2'}>
<div css={tw`flex mt-4`}>
<TitledGreyBox title={'Memory usage'} icon={faMemory} css={tw`flex-1 mr-2`}>
{status !== 'offline' ?
<canvas id={'memory_chart'} ref={memoryRef} aria-label={'Server Memory Usage Graph'} role={'img'}/>
:
<p className={'text-xs text-neutral-400 text-center p-3'}>
<p css={tw`text-xs text-neutral-400 text-center p-3`}>
Server is offline.
</p>
}
</TitledGreyBox>
<TitledGreyBox title={'CPU usage'} icon={faMicrochip} className={'flex-1 ml-2'}>
<TitledGreyBox title={'CPU usage'} icon={faMicrochip} css={tw`flex-1 ml-2`}>
{status !== 'offline' ?
<canvas id={'cpu_chart'} ref={cpuRef} aria-label={'Server CPU Usage Graph'} role={'img'}/>
:
<p className={'text-xs text-neutral-400 text-center p-3'}>
<p css={tw`text-xs text-neutral-400 text-center p-3`}>
Server is offline.
</p>
}

View file

@ -0,0 +1,30 @@
import React, { useEffect, useState } from 'react';
import { ServerContext } from '@/state/server';
import { PowerAction } from '@/components/server/ServerConsole';
import Button from '@/components/elements/Button';
const StopOrKillButton = ({ onPress }: { onPress: (action: PowerAction) => void }) => {
const [ clicked, setClicked ] = useState(false);
const status = ServerContext.useStoreState(state => state.status.value);
useEffect(() => {
setClicked(state => [ 'stopping' ].indexOf(status) < 0 ? false : state);
}, [ status ]);
return (
<Button
color={'red'}
size={'xsmall'}
disabled={status === 'offline'}
onClick={e => {
e.preventDefault();
onPress(clicked ? 'kill' : 'stop');
setClicked(true);
}}
>
{clicked ? 'Kill' : 'Stop'}
</Button>
);
};
export default StopOrKillButton;

View file

@ -5,6 +5,7 @@ import getWebsocketToken from '@/api/server/getWebsocketToken';
import ContentContainer from '@/components/elements/ContentContainer';
import { CSSTransition } from 'react-transition-group';
import Spinner from '@/components/elements/Spinner';
import tw from 'twin.macro';
export default () => {
const server = ServerContext.useStoreState(state => state.server.data);
@ -66,12 +67,12 @@ export default () => {
return (
error ?
<CSSTransition timeout={250} in={true} appear={true} classNames={'fade'}>
<div className={'bg-red-500 py-2'}>
<ContentContainer className={'flex items-center justify-center'}>
<Spinner size={'tiny'}/>
<p className={'ml-2 text-sm text-red-100'}>
We're having some trouble connecting to your server, please wait...
<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>
</ContentContainer>
</div>

View file

@ -10,6 +10,7 @@ import FlashMessageRender from '@/components/FlashMessageRender';
import BackupRow from '@/components/server/backups/BackupRow';
import { ServerContext } from '@/state/server';
import PageContentBlock from '@/components/elements/PageContentBlock';
import tw from 'twin.macro';
export default () => {
const { uuid, featureLimits } = useServer();
@ -31,14 +32,14 @@ export default () => {
}, []);
if (backups.length === 0 && loading) {
return <Spinner size={'large'} centered={true}/>;
return <Spinner size={'large'} centered/>;
}
return (
<PageContentBlock>
<FlashMessageRender byKey={'backups'} className={'mb-4'}/>
<FlashMessageRender byKey={'backups'} css={tw`mb-4`}/>
{!backups.length ?
<p className="text-center text-sm text-neutral-400">
<p css={tw`text-center text-sm text-neutral-400`}>
There are no backups stored for this server.
</p>
:
@ -46,7 +47,7 @@ export default () => {
{backups.map((backup, index) => <BackupRow
key={backup.uuid}
backup={backup}
className={index !== (backups.length - 1) ? 'mb-2' : undefined}
css={index > 0 ? tw`mt-2` : undefined}
/>)}
</div>
}
@ -57,12 +58,12 @@ export default () => {
}
<Can action={'backup.create'}>
{(featureLimits.backups > 0 && backups.length > 0) &&
<p className="text-center text-xs text-neutral-400 mt-2">
<p css={tw`text-center text-xs text-neutral-400 mt-2`}>
{backups.length} of {featureLimits.backups} backups have been created for this server.
</p>
}
{featureLimits.backups > 0 && featureLimits.backups !== backups.length &&
<div className={'mt-6 flex justify-end'}>
<div css={tw`mt-6 flex justify-end`}>
<CreateBackupButton/>
</div>
}

View file

@ -1,11 +1,8 @@
import React, { useState } from 'react';
import { ServerBackup } from '@/api/server/backups/getServerBackups';
import { faEllipsisH } from '@fortawesome/free-solid-svg-icons/faEllipsisH';
import { faCloudDownloadAlt, faEllipsisH, faLock, faTrashAlt } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import DropdownMenu, { DropdownButtonRow } from '@/components/elements/DropdownMenu';
import { faCloudDownloadAlt } from '@fortawesome/free-solid-svg-icons/faCloudDownloadAlt';
import { faTrashAlt } from '@fortawesome/free-solid-svg-icons/faTrashAlt';
import { faLock } from '@fortawesome/free-solid-svg-icons/faLock';
import getBackupDownloadUrl from '@/api/server/backups/getBackupDownloadUrl';
import { httpErrorToHuman } from '@/api/http';
import useFlash from '@/plugins/useFlash';
@ -16,6 +13,7 @@ import deleteBackup from '@/api/server/backups/deleteBackup';
import { ServerContext } from '@/state/server';
import ConfirmationModal from '@/components/elements/ConfirmationModal';
import Can from '@/components/elements/Can';
import tw from 'twin.macro';
interface Props {
backup: ServerBackup;
@ -61,8 +59,8 @@ export default ({ backup }: Props) => {
<>
{visible &&
<ChecksumModal
appear
visible={visible}
appear={true}
onDismissed={() => setVisible(false)}
checksum={backup.sha256Hash}
/>
@ -79,32 +77,32 @@ export default ({ backup }: Props) => {
be recovered once deleted.
</ConfirmationModal>
}
<SpinnerOverlay visible={loading} fixed={true}/>
<SpinnerOverlay visible={loading} fixed/>
<DropdownMenu
renderToggle={onClick => (
<button
onClick={onClick}
className={'text-neutral-200 transition-color duration-150 hover:text-neutral-100 p-2'}
css={tw`text-neutral-200 transition-colors duration-150 hover:text-neutral-100 p-2`}
>
<FontAwesomeIcon icon={faEllipsisH}/>
</button>
)}
>
<div className={'text-sm'}>
<div css={tw`text-sm`}>
<Can action={'backup.download'}>
<DropdownButtonRow onClick={() => doDownload()}>
<FontAwesomeIcon fixedWidth={true} icon={faCloudDownloadAlt} className={'text-xs'}/>
<span className={'ml-2'}>Download</span>
<FontAwesomeIcon fixedWidth icon={faCloudDownloadAlt} css={tw`text-xs`}/>
<span css={tw`ml-2`}>Download</span>
</DropdownButtonRow>
</Can>
<DropdownButtonRow onClick={() => setVisible(true)}>
<FontAwesomeIcon fixedWidth={true} icon={faLock} className={'text-xs'}/>
<span className={'ml-2'}>Checksum</span>
<FontAwesomeIcon fixedWidth icon={faLock} css={tw`text-xs`}/>
<span css={tw`ml-2`}>Checksum</span>
</DropdownButtonRow>
<Can action={'backup.delete'}>
<DropdownButtonRow danger={true} onClick={() => setDeleteVisible(true)}>
<FontAwesomeIcon fixedWidth={true} icon={faTrashAlt} className={'text-xs'}/>
<span className={'ml-2'}>Delete</span>
<DropdownButtonRow danger onClick={() => setDeleteVisible(true)}>
<FontAwesomeIcon fixedWidth icon={faTrashAlt} css={tw`text-xs`}/>
<span css={tw`ml-2`}>Delete</span>
</DropdownButtonRow>
</Can>
</div>

View file

@ -1,22 +1,16 @@
import React, { useState } from 'react';
import React from 'react';
import { ServerBackup } from '@/api/server/backups/getServerBackups';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faArchive } from '@fortawesome/free-solid-svg-icons/faArchive';
import format from 'date-fns/format';
import distanceInWordsToNow from 'date-fns/distance_in_words_to_now';
import { faArchive, faEllipsisH } from '@fortawesome/free-solid-svg-icons';
import { format, formatDistanceToNow } from 'date-fns';
import Spinner from '@/components/elements/Spinner';
import Modal, { RequiredModalProps } from '@/components/elements/Modal';
import { bytesToHuman } from '@/helpers';
import Can from '@/components/elements/Can';
import useServer from '@/plugins/useServer';
import getBackupDownloadUrl from '@/api/server/backups/getBackupDownloadUrl';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import useFlash from '@/plugins/useFlash';
import { httpErrorToHuman } from '@/api/http';
import useWebsocketEvent from '@/plugins/useWebsocketEvent';
import { ServerContext } from '@/state/server';
import BackupContextMenu from '@/components/server/backups/BackupContextMenu';
import { faEllipsisH } from '@fortawesome/free-solid-svg-icons/faEllipsisH';
import tw from 'twin.macro';
import GreyRowBox from '@/components/elements/GreyRowBox';
interface Props {
backup: ServerBackup;
@ -41,38 +35,38 @@ export default ({ backup, className }: Props) => {
});
return (
<div className={`grey-row-box flex items-center ${className}`}>
<div className={'mr-4'}>
<GreyRowBox css={tw`flex items-center`} className={className}>
<div css={tw`mr-4`}>
{backup.completedAt ?
<FontAwesomeIcon icon={faArchive} className={'text-neutral-300'}/>
<FontAwesomeIcon icon={faArchive} css={tw`text-neutral-300`}/>
:
<Spinner size={'tiny'}/>
<Spinner size={'small'}/>
}
</div>
<div className={'flex-1'}>
<p className={'text-sm mb-1'}>
<div css={tw`flex-1`}>
<p css={tw`text-sm mb-1`}>
{backup.name}
{backup.completedAt &&
<span className={'ml-3 text-neutral-300 text-xs font-thin'}>{bytesToHuman(backup.bytes)}</span>
<span css={tw`ml-3 text-neutral-300 text-xs font-thin`}>{bytesToHuman(backup.bytes)}</span>
}
</p>
<p className={'text-xs text-neutral-400 font-mono'}>
<p css={tw`text-xs text-neutral-400 font-mono`}>
{backup.uuid}
</p>
</div>
<div className={'ml-8 text-center'}>
<div css={tw`ml-8 text-center`}>
<p
title={format(backup.createdAt, 'ddd, MMMM Do, YYYY HH:mm:ss Z')}
className={'text-sm'}
title={format(backup.createdAt, 'ddd, MMMM do, yyyy HH:mm:ss')}
css={tw`text-sm`}
>
{distanceInWordsToNow(backup.createdAt, { includeSeconds: true, addSuffix: true })}
{formatDistanceToNow(backup.createdAt, { includeSeconds: true, addSuffix: true })}
</p>
<p className={'text-2xs text-neutral-500 uppercase mt-1'}>Created</p>
<p css={tw`text-2xs text-neutral-500 uppercase mt-1`}>Created</p>
</div>
<Can action={'backup.download'}>
<div className={'ml-6'} style={{ marginRight: '-0.5rem' }}>
<div css={tw`ml-6`} style={{ marginRight: '-0.5rem' }}>
{!backup.completedAt ?
<div className={'p-2 invisible'}>
<div css={tw`p-2 invisible`}>
<FontAwesomeIcon icon={faEllipsisH}/>
</div>
:
@ -80,6 +74,6 @@ export default ({ backup, className }: Props) => {
}
</div>
</Can>
</div>
</GreyRowBox>
);
};

View file

@ -1,14 +1,15 @@
import React from 'react';
import Modal, { RequiredModalProps } from '@/components/elements/Modal';
import tw from 'twin.macro';
const ChecksumModal = ({ checksum, ...props }: RequiredModalProps & { checksum: string }) => (
<Modal {...props}>
<h3 className={'mb-6'}>Verify file checksum</h3>
<p className={'text-sm'}>
<h3 css={tw`mb-6`}>Verify file checksum</h3>
<p css={tw`text-sm`}>
The SHA256 checksum of this file is:
</p>
<pre className={'mt-2 text-sm p-2 bg-neutral-900 rounded'}>
<code className={'block font-mono'}>{checksum}</code>
<pre css={tw`mt-2 text-sm p-2 bg-neutral-900 rounded`}>
<code css={tw`block font-mono`}>{checksum}</code>
</pre>
</Modal>
);

View file

@ -10,6 +10,9 @@ import createServerBackup from '@/api/server/backups/createServerBackup';
import { httpErrorToHuman } from '@/api/http';
import FlashMessageRender from '@/components/FlashMessageRender';
import { ServerContext } from '@/state/server';
import Button from '@/components/elements/Button';
import tw from 'twin.macro';
import { Textarea } from '@/components/elements/Input';
interface Values {
name: string;
@ -21,17 +24,17 @@ const ModalContent = ({ ...props }: RequiredModalProps) => {
return (
<Modal {...props} showSpinnerOverlay={isSubmitting}>
<Form className={'pb-6'}>
<FlashMessageRender byKey={'backups:create'} className={'mb-4'}/>
<h3 className={'mb-6'}>Create server backup</h3>
<div className={'mb-6'}>
<Form>
<FlashMessageRender byKey={'backups:create'} css={tw`mb-4`}/>
<h2 css={tw`text-2xl mb-6`}>Create server backup</h2>
<div css={tw`mb-6`}>
<Field
name={'name'}
label={'Backup name'}
description={'If provided, the name that should be used to reference this backup.'}
/>
</div>
<div className={'mb-6'}>
<div css={tw`mb-6`}>
<FormikFieldWrapper
name={'ignored'}
label={'Ignored Files & Directories'}
@ -42,20 +45,13 @@ const ModalContent = ({ ...props }: RequiredModalProps) => {
prefixing the path with an exclamation point.
`}
>
<FormikField
name={'ignored'}
component={'textarea'}
className={'input-dark h-32'}
/>
<FormikField as={Textarea} name={'ignored'} css={tw`h-32`}/>
</FormikFieldWrapper>
</div>
<div className={'flex justify-end'}>
<button
type={'submit'}
className={'btn btn-primary btn-sm'}
>
<div css={tw`flex justify-end`}>
<Button type={'submit'}>
Start backup
</button>
</Button>
</div>
</Form>
</Modal>
@ -99,18 +95,15 @@ export default () => {
})}
>
<ModalContent
appear={true}
appear
visible={visible}
onDismissed={() => setVisible(false)}
/>
</Formik>
}
<button
className={'btn btn-primary btn-sm'}
onClick={() => setVisible(true)}
>
<Button onClick={() => setVisible(true)}>
Create backup
</button>
</Button>
</>
);
};

View file

@ -9,6 +9,8 @@ import { httpErrorToHuman } from '@/api/http';
import FlashMessageRender from '@/components/FlashMessageRender';
import useFlash from '@/plugins/useFlash';
import useServer from '@/plugins/useServer';
import Button from '@/components/elements/Button';
import tw from 'twin.macro';
interface Values {
databaseName: string;
@ -48,7 +50,7 @@ export default () => {
};
return (
<React.Fragment>
<>
<Formik
onSubmit={submit}
initialValues={{ databaseName: '', connectionsFrom: '%' }}
@ -65,9 +67,9 @@ export default () => {
setVisible(false);
}}
>
<FlashMessageRender byKey={'database:create'} className={'mb-6'}/>
<h3 className={'mb-6'}>Create new database</h3>
<Form className={'m-0'}>
<FlashMessageRender byKey={'database:create'} css={tw`mb-6`}/>
<h2 css={tw`text-2xl mb-6`}>Create new database</h2>
<Form css={tw`m-0`}>
<Field
type={'string'}
id={'database_name'}
@ -75,7 +77,7 @@ export default () => {
label={'Database Name'}
description={'A descriptive name for your database instance.'}
/>
<div className={'mt-6'}>
<div css={tw`mt-6`}>
<Field
type={'string'}
id={'connections_from'}
@ -84,26 +86,27 @@ export default () => {
description={'Where connections should be allowed from. Use % for wildcards.'}
/>
</div>
<div className={'mt-6 text-right'}>
<button
<div css={tw`mt-6 text-right`}>
<Button
type={'button'}
className={'btn btn-sm btn-secondary mr-2'}
isSecondary
css={tw`mr-2`}
onClick={() => setVisible(false)}
>
Cancel
</button>
<button className={'btn btn-sm btn-primary'} type={'submit'}>
</Button>
<Button type={'submit'}>
Create Database
</button>
</Button>
</div>
</Form>
</Modal>
)
}
</Formik>
<button className={'btn btn-primary btn-sm'} onClick={() => setVisible(true)}>
<Button onClick={() => setVisible(true)}>
New Database
</button>
</React.Fragment>
</Button>
</>
);
};

View file

@ -1,9 +1,6 @@
import React, { useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faDatabase } from '@fortawesome/free-solid-svg-icons/faDatabase';
import { faTrashAlt } from '@fortawesome/free-solid-svg-icons/faTrashAlt';
import { faEye } from '@fortawesome/free-solid-svg-icons/faEye';
import classNames from 'classnames';
import { faDatabase, faTrashAlt, faEye } from '@fortawesome/free-solid-svg-icons';
import Modal from '@/components/elements/Modal';
import { Form, Formik, FormikHelpers } from 'formik';
import Field from '@/components/elements/Field';
@ -17,6 +14,11 @@ import Can from '@/components/elements/Can';
import { ServerDatabase } from '@/api/server/getServerDatabases';
import useServer from '@/plugins/useServer';
import useFlash from '@/plugins/useFlash';
import tw from 'twin.macro';
import Button from '@/components/elements/Button';
import Label from '@/components/elements/Label';
import Input from '@/components/elements/Input';
import GreyRowBox from '@/components/elements/GreyRowBox';
interface Props {
database: ServerDatabase;
@ -51,13 +53,14 @@ export default ({ database, className }: Props) => {
addError({ key: 'database:delete', message: httpErrorToHuman(error) });
});
};
return (
<React.Fragment>
<>
<Formik
onSubmit={submit}
initialValues={{ confirm: '' }}
validationSchema={schema}
isInitialValid={false}
>
{
({ isSubmitting, isValid, resetForm }) => (
@ -70,13 +73,13 @@ export default ({ database, className }: Props) => {
resetForm();
}}
>
<FlashMessageRender byKey={'database:delete'} className={'mb-6'}/>
<h3 className={'mb-6'}>Confirm database deletion</h3>
<p className={'text-sm'}>
<FlashMessageRender byKey={'database:delete'} css={tw`mb-6`}/>
<h2 css={tw`text-2xl mb-6`}>Confirm database deletion</h2>
<p css={tw`text-sm`}>
Deleting a database is a permanent action, it cannot be undone. This will permanetly
delete the <strong>{database.name}</strong> database and remove all associated data.
</p>
<Form className={'m-0 mt-6'}>
<Form css={tw`m-0 mt-6`}>
<Field
type={'text'}
id={'confirm_name'}
@ -84,21 +87,22 @@ export default ({ database, className }: Props) => {
label={'Confirm Database Name'}
description={'Enter the database name to confirm deletion.'}
/>
<div className={'mt-6 text-right'}>
<button
<div css={tw`mt-6 text-right`}>
<Button
type={'button'}
className={'btn btn-sm btn-secondary mr-2'}
isSecondary
css={tw`mr-2`}
onClick={() => setVisible(false)}
>
Cancel
</button>
<button
</Button>
<Button
type={'submit'}
className={'btn btn-sm btn-red'}
color={'red'}
disabled={!isValid}
>
Delete Database
</button>
</Button>
</div>
</Form>
</Modal>
@ -106,62 +110,61 @@ export default ({ database, className }: Props) => {
}
</Formik>
<Modal visible={connectionVisible} onDismissed={() => setConnectionVisible(false)}>
<FlashMessageRender byKey={'database-connection-modal'} className={'mb-6'}/>
<h3 className={'mb-6'}>Database connection details</h3>
<FlashMessageRender byKey={'database-connection-modal'} css={tw`mb-6`}/>
<h3 css={tw`mb-6`}>Database connection details</h3>
<Can action={'database.view_password'}>
<div>
<label className={'input-dark-label'}>Password</label>
<input type={'text'} className={'input-dark'} readOnly={true} value={database.password}/>
<Label>Password</Label>
<Input type={'text'} readOnly value={database.password}/>
</div>
</Can>
<div className={'mt-6'}>
<label className={'input-dark-label'}>JBDC Connection String</label>
<input
<div css={tw`mt-6`}>
<Label>JBDC Connection String</Label>
<Input
type={'text'}
className={'input-dark'}
readOnly={true}
readOnly
value={`jdbc:mysql://${database.username}:${database.password}@${database.connectionString}/${database.name}`}
/>
</div>
<div className={'mt-6 text-right'}>
<div css={tw`mt-6 text-right`}>
<Can action={'database.update'}>
<RotatePasswordButton databaseId={database.id} onUpdate={appendDatabase}/>
</Can>
<button className={'btn btn-sm btn-secondary'} onClick={() => setConnectionVisible(false)}>
<Button isSecondary onClick={() => setConnectionVisible(false)}>
Close
</button>
</Button>
</div>
</Modal>
<div className={classNames('grey-row-box no-hover', className)}>
<div className={'icon'}>
<FontAwesomeIcon icon={faDatabase} fixedWidth={true}/>
<GreyRowBox $hoverable={false} className={className}>
<div>
<FontAwesomeIcon icon={faDatabase} fixedWidth/>
</div>
<div className={'flex-1 ml-4'}>
<p className={'text-lg'}>{database.name}</p>
<div css={tw`flex-1 ml-4`}>
<p css={tw`text-lg`}>{database.name}</p>
</div>
<div className={'ml-8 text-center'}>
<p className={'text-sm'}>{database.connectionString}</p>
<p className={'mt-1 text-2xs text-neutral-500 uppercase select-none'}>Endpoint</p>
<div css={tw`ml-8 text-center`}>
<p css={tw`text-sm`}>{database.connectionString}</p>
<p css={tw`mt-1 text-2xs text-neutral-500 uppercase select-none`}>Endpoint</p>
</div>
<div className={'ml-8 text-center'}>
<p className={'text-sm'}>{database.allowConnectionsFrom}</p>
<p className={'mt-1 text-2xs text-neutral-500 uppercase select-none'}>Connections from</p>
<div css={tw`ml-8 text-center`}>
<p css={tw`text-sm`}>{database.allowConnectionsFrom}</p>
<p css={tw`mt-1 text-2xs text-neutral-500 uppercase select-none`}>Connections from</p>
</div>
<div className={'ml-8 text-center'}>
<p className={'text-sm'}>{database.username}</p>
<p className={'mt-1 text-2xs text-neutral-500 uppercase select-none'}>Username</p>
<div css={tw`ml-8 text-center`}>
<p css={tw`text-sm`}>{database.username}</p>
<p css={tw`mt-1 text-2xs text-neutral-500 uppercase select-none`}>Username</p>
</div>
<div className={'ml-8'}>
<button className={'btn btn-sm btn-secondary mr-2'} onClick={() => setConnectionVisible(true)}>
<FontAwesomeIcon icon={faEye} fixedWidth={true}/>
</button>
<div css={tw`ml-8`}>
<Button isSecondary css={tw`mr-2`} onClick={() => setConnectionVisible(true)}>
<FontAwesomeIcon icon={faEye} fixedWidth/>
</Button>
<Can action={'database.delete'}>
<button className={'btn btn-sm btn-secondary btn-red'} onClick={() => setVisible(true)}>
<FontAwesomeIcon icon={faTrashAlt} fixedWidth={true}/>
</button>
<Button color={'red'} isSecondary onClick={() => setVisible(true)}>
<FontAwesomeIcon icon={faTrashAlt} fixedWidth/>
</Button>
</Can>
</div>
</div>
</React.Fragment>
</GreyRowBox>
</>
);
};

View file

@ -5,12 +5,13 @@ import { httpErrorToHuman } from '@/api/http';
import FlashMessageRender from '@/components/FlashMessageRender';
import DatabaseRow from '@/components/server/databases/DatabaseRow';
import Spinner from '@/components/elements/Spinner';
import { CSSTransition } from 'react-transition-group';
import CreateDatabaseButton from '@/components/server/databases/CreateDatabaseButton';
import Can from '@/components/elements/Can';
import useFlash from '@/plugins/useFlash';
import useServer from '@/plugins/useServer';
import PageContentBlock from '@/components/elements/PageContentBlock';
import tw from 'twin.macro';
import Fade from '@/components/elements/Fade';
export default () => {
const { uuid, featureLimits } = useServer();
@ -35,11 +36,11 @@ export default () => {
return (
<PageContentBlock>
<FlashMessageRender byKey={'databases'} className={'mb-4'}/>
<FlashMessageRender byKey={'databases'} css={tw`mb-4`}/>
{(!databases.length && loading) ?
<Spinner size={'large'} centered={true}/>
<Spinner size={'large'} centered/>
:
<CSSTransition classNames={'fade'} timeout={250}>
<Fade timeout={150}>
<>
{databases.length > 0 ?
databases.map((database, index) => (
@ -50,28 +51,29 @@ export default () => {
/>
))
:
<p className={'text-center text-sm text-neutral-400'}>
<p css={tw`text-center text-sm text-neutral-400`}>
{featureLimits.databases > 0 ?
`It looks like you have no databases.`
'It looks like you have no databases.'
:
`Databases cannot be created for this server.`
'Databases cannot be created for this server.'
}
</p>
}
<Can action={'database.create'}>
{(featureLimits.databases > 0 && databases.length > 0) &&
<p className="text-center text-xs text-neutral-400 mt-2">
{databases.length} of {featureLimits.databases} databases have been allocated to this server.
<p css={tw`text-center text-xs text-neutral-400 mt-2`}>
{databases.length} of {featureLimits.databases} databases have been allocated to this
server.
</p>
}
{featureLimits.databases > 0 && featureLimits.databases !== databases.length &&
<div className={'mt-6 flex justify-end'}>
<div css={tw`mt-6 flex justify-end`}>
<CreateDatabaseButton/>
</div>
}
</Can>
</>
</CSSTransition>
</Fade>
}
</PageContentBlock>
);

View file

@ -6,6 +6,7 @@ import { ServerContext } from '@/state/server';
import { ServerDatabase } from '@/api/server/getServerDatabases';
import { httpErrorToHuman } from '@/api/http';
import Button from '@/components/elements/Button';
import tw from 'twin.macro';
export default ({ databaseId, onUpdate }: {
databaseId: string;
@ -38,7 +39,7 @@ export default ({ databaseId, onUpdate }: {
};
return (
<Button className={'btn-secondary mr-2'} onClick={rotate} isLoading={loading}>
<Button isSecondary color={'primary'} css={tw`mr-2`} onClick={rotate} isLoading={loading}>
Rotate Password
</Button>
);

View file

@ -1,192 +1,134 @@
import React, { createRef, useEffect, useState } from 'react';
import React, { useRef, useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faEllipsisH } from '@fortawesome/free-solid-svg-icons/faEllipsisH';
import { CSSTransition } from 'react-transition-group';
import { faPencilAlt } from '@fortawesome/free-solid-svg-icons/faPencilAlt';
import { faTrashAlt } from '@fortawesome/free-solid-svg-icons/faTrashAlt';
import { faFileDownload } from '@fortawesome/free-solid-svg-icons/faFileDownload';
import { faCopy } from '@fortawesome/free-solid-svg-icons/faCopy';
import { faLevelUpAlt } from '@fortawesome/free-solid-svg-icons/faLevelUpAlt';
import {
faCopy,
faEllipsisH,
faFileDownload,
faLevelUpAlt,
faPencilAlt,
faTrashAlt,
IconDefinition,
} from '@fortawesome/free-solid-svg-icons';
import RenameFileModal from '@/components/server/files/RenameFileModal';
import { ServerContext } from '@/state/server';
import { join } from 'path';
import deleteFile from '@/api/server/files/deleteFile';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import copyFile from '@/api/server/files/copyFile';
import { httpErrorToHuman } from '@/api/http';
import Can from '@/components/elements/Can';
import getFileDownloadUrl from '@/api/server/files/getFileDownloadUrl';
import useServer from '@/plugins/useServer';
import useFlash from '@/plugins/useFlash';
import tw from 'twin.macro';
import { FileObject } from '@/api/server/files/loadDirectory';
import useFileManagerSwr from '@/plugins/useFileManagerSwr';
import DropdownMenu from '@/components/elements/DropdownMenu';
import styled from 'styled-components/macro';
import useEventListener from '@/plugins/useEventListener';
type ModalType = 'rename' | 'move';
export default ({ uuid }: { uuid: string }) => {
const menu = createRef<HTMLDivElement>();
const menuButton = createRef<HTMLDivElement>();
const [ menuVisible, setMenuVisible ] = useState(false);
const StyledRow = styled.div<{ $danger?: boolean }>`
${tw`p-2 flex items-center rounded`};
${props => props.$danger ? tw`hover:bg-red-100 hover:text-red-700` : tw`hover:bg-neutral-100 hover:text-neutral-700`};
`;
interface RowProps extends React.HTMLAttributes<HTMLDivElement> {
icon: IconDefinition;
title: string;
$danger?: boolean;
}
const Row = ({ icon, title, ...props }: RowProps) => (
<StyledRow {...props}>
<FontAwesomeIcon icon={icon} css={tw`text-xs`}/>
<span css={tw`ml-2`}>{title}</span>
</StyledRow>
);
export default ({ file }: { file: FileObject }) => {
const onClickRef = useRef<DropdownMenu>(null);
const [ showSpinner, setShowSpinner ] = useState(false);
const [ modal, setModal ] = useState<ModalType | null>(null);
const [ posX, setPosX ] = useState(0);
const server = useServer();
const { addError, clearFlashes } = useFlash();
const file = ServerContext.useStoreState(state => state.files.contents.find(file => file.uuid === uuid));
const { uuid } = useServer();
const { mutate } = useFileManagerSwr();
const { clearAndAddHttpError, clearFlashes } = useFlash();
const directory = ServerContext.useStoreState(state => state.files.directory);
const { removeFile, getDirectoryContents } = ServerContext.useStoreActions(actions => actions.files);
if (!file) {
return null;
}
const windowListener = (e: MouseEvent) => {
if (e.button === 2 || !menuVisible || !menu.current) {
return;
useEventListener(`pterodactyl:files:ctx:${file.uuid}`, (e: CustomEvent) => {
if (onClickRef.current) {
onClickRef.current.triggerMenu(e.detail);
}
if (e.target === menu.current || menu.current.contains(e.target as Node)) {
return;
}
if (e.target !== menu.current && !menu.current.contains(e.target as Node)) {
setMenuVisible(false);
}
};
});
const doDeletion = () => {
setShowSpinner(true);
clearFlashes('files');
deleteFile(server.uuid, join(directory, file.name))
.then(() => removeFile(uuid))
.catch(error => {
console.error('Error while attempting to delete a file.', error);
addError({ key: 'files', message: httpErrorToHuman(error) });
setShowSpinner(false);
});
// For UI speed, immediately remove the file from the listing before calling the deletion function.
// If the delete actually fails, we'll fetch the current directory contents again automatically.
mutate(files => files.filter(f => f.uuid !== file.uuid), false);
deleteFile(uuid, join(directory, file.name)).catch(error => {
mutate();
clearAndAddHttpError({ key: 'files', error });
});
};
const doCopy = () => {
setShowSpinner(true);
clearFlashes('files');
copyFile(server.uuid, join(directory, file.name))
.then(() => getDirectoryContents(directory))
copyFile(uuid, join(directory, file.name))
.then(() => mutate())
.catch(error => {
console.error('Error while attempting to copy file.', error);
addError({ key: 'files', message: httpErrorToHuman(error) });
setShowSpinner(false);
clearAndAddHttpError({ key: 'files', error });
});
};
const doDownload = () => {
setShowSpinner(true);
clearFlashes('files');
getFileDownloadUrl(server.uuid, join(directory, file.name))
getFileDownloadUrl(uuid, join(directory, file.name))
.then(url => {
// @ts-ignore
window.location = url;
})
.catch(error => {
console.error(error);
addError({ key: 'files', message: httpErrorToHuman(error) });
})
.catch(error => clearAndAddHttpError({ key: 'files', error }))
.then(() => setShowSpinner(false));
};
useEffect(() => {
menuVisible
? document.addEventListener('click', windowListener)
: document.removeEventListener('click', windowListener);
if (menuVisible && menu.current) {
menu.current.setAttribute(
'style', `margin-top: -0.35rem; left: ${Math.round(posX - menu.current.clientWidth)}px`,
);
}
}, [ menuVisible ]);
useEffect(() => () => {
document.removeEventListener('click', windowListener);
}, []);
return (
<div key={`dropdown:${file.uuid}`}>
<div
ref={menuButton}
className={'p-3 hover:text-white'}
onClick={e => {
e.preventDefault();
if (!menuVisible) {
setPosX(e.clientX);
}
setModal(null);
setMenuVisible(!menuVisible);
}}
>
<FontAwesomeIcon icon={faEllipsisH}/>
<RenameFileModal
file={file}
visible={modal === 'rename' || modal === 'move'}
useMoveTerminology={modal === 'move'}
onDismissed={() => {
setModal(null);
setMenuVisible(false);
}}
/>
<SpinnerOverlay visible={showSpinner} fixed={true} size={'large'}/>
</div>
<CSSTransition timeout={250} in={menuVisible} unmountOnExit={true} classNames={'fade'}>
<div
ref={menu}
onClick={e => {
e.stopPropagation();
setMenuVisible(false);
}}
className={'absolute bg-white p-2 rounded border border-neutral-700 shadow-lg text-neutral-500 min-w-48'}
>
<Can action={'file.update'}>
<div
onClick={() => setModal('rename')}
className={'hover:text-neutral-700 p-2 flex items-center hover:bg-neutral-100 rounded'}
>
<FontAwesomeIcon icon={faPencilAlt} className={'text-xs'}/>
<span className={'ml-2'}>Rename</span>
</div>
<div
onClick={() => setModal('move')}
className={'hover:text-neutral-700 p-2 flex items-center hover:bg-neutral-100 rounded'}
>
<FontAwesomeIcon icon={faLevelUpAlt} className={'text-xs'}/>
<span className={'ml-2'}>Move</span>
</div>
</Can>
<Can action={'file.create'}>
<div
onClick={() => doCopy()}
className={'hover:text-neutral-700 p-2 flex items-center hover:bg-neutral-100 rounded'}
>
<FontAwesomeIcon icon={faCopy} className={'text-xs'}/>
<span className={'ml-2'}>Copy</span>
</div>
</Can>
<div
className={'hover:text-neutral-700 p-2 flex items-center hover:bg-neutral-100 rounded'}
onClick={() => doDownload()}
>
<FontAwesomeIcon icon={faFileDownload} className={'text-xs'}/>
<span className={'ml-2'}>Download</span>
</div>
<Can action={'file.delete'}>
<div
onClick={() => doDeletion()}
className={'hover:text-red-700 p-2 flex items-center hover:bg-red-100 rounded'}
>
<FontAwesomeIcon icon={faTrashAlt} className={'text-xs'}/>
<span className={'ml-2'}>Delete</span>
</div>
</Can>
<DropdownMenu
ref={onClickRef}
renderToggle={onClick => (
<div css={tw`p-3 hover:text-white`} onClick={onClick}>
<FontAwesomeIcon icon={faEllipsisH}/>
<RenameFileModal
file={file}
visible={!!modal}
useMoveTerminology={modal === 'move'}
onDismissed={() => setModal(null)}
/>
<SpinnerOverlay visible={showSpinner} fixed size={'large'}/>
</div>
</CSSTransition>
</div>
)}
>
<Can action={'file.update'}>
<Row onClick={() => setModal('rename')} icon={faPencilAlt} title={'Rename'}/>
<Row onClick={() => setModal('move')} icon={faLevelUpAlt} title={'Move'}/>
</Can>
{file.isFile &&
<Can action={'file.create'}>
<Row onClick={doCopy} icon={faCopy} title={'Copy'}/>
</Can>
}
<Row onClick={doDownload} icon={faFileDownload} title={'Download'}/>
<Can action={'file.delete'}>
<Row onClick={doDeletion} icon={faTrashAlt} title={'Delete'} $danger/>
</Can>
</DropdownMenu>
);
};

View file

@ -1,30 +1,33 @@
import React, { lazy, useEffect, useState } from 'react';
import { ServerContext } from '@/state/server';
import getFileContents from '@/api/server/files/getFileContents';
import useRouter from 'use-react-router';
import { Actions, useStoreActions } from 'easy-peasy';
import { ApplicationStore } from '@/state';
import { httpErrorToHuman } from '@/api/http';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import saveFileContents from '@/api/server/files/saveFileContents';
import FileManagerBreadcrumbs from '@/components/server/files/FileManagerBreadcrumbs';
import { useParams } from 'react-router';
import { useHistory, useLocation, useParams } from 'react-router';
import FileNameModal from '@/components/server/files/FileNameModal';
import Can from '@/components/elements/Can';
import FlashMessageRender from '@/components/FlashMessageRender';
import PageContentBlock from '@/components/elements/PageContentBlock';
import ServerError from '@/components/screens/ServerError';
import tw from 'twin.macro';
import Button from '@/components/elements/Button';
const LazyAceEditor = lazy(() => import(/* webpackChunkName: "editor" */'@/components/elements/AceEditor'));
export default () => {
const [ error, setError ] = useState('');
const { action } = useParams();
const { history, location: { hash } } = useRouter();
const [ loading, setLoading ] = useState(action === 'edit');
const [ content, setContent ] = useState('');
const [ modalVisible, setModalVisible ] = useState(false);
const history = useHistory();
const { hash } = useLocation();
const { id, uuid } = ServerContext.useStoreState(state => state.server.data!);
const { addError, clearFlashes } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
@ -81,16 +84,17 @@ export default () => {
return (
<PageContentBlock>
<FlashMessageRender byKey={'files:view'} className={'mb-4'}/>
<FileManagerBreadcrumbs withinFileEditor={true} isNewFile={action !== 'edit'}/>
{(name || hash.replace(/^#/, '')).endsWith('.pteroignore') &&
<div className={'mb-4 p-4 border-l-4 bg-neutral-900 rounded border-cyan-400'}>
<p className={'text-neutral-300 text-sm'}>
You're editing a <code className={'font-mono bg-black rounded py-px px-1'}>.pteroignore</code> file.
<FlashMessageRender byKey={'files:view'} css={tw`mb-4`}/>
<FileManagerBreadcrumbs withinFileEditor isNewFile={action !== 'edit'}/>
{hash.replace(/^#/, '').endsWith('.pteroignore') &&
<div css={tw`mb-4 p-4 border-l-4 bg-neutral-900 rounded border-cyan-400`}>
<p css={tw`text-neutral-300 text-sm`}>
You&apos;re editing
a <code css={tw`font-mono bg-black rounded py-px px-1`}>.pteroignore</code> file.
Any files or directories listed in here will be excluded from backups. Wildcards are supported by
using an asterisk (<code className={'font-mono bg-black rounded py-px px-1'}>*</code>). You can
using an asterisk (<code css={tw`font-mono bg-black rounded py-px px-1`}>*</code>). You can
negate a prior rule by prepending an exclamation point
(<code className={'font-mono bg-black rounded py-px px-1'}>!</code>).
(<code css={tw`font-mono bg-black rounded py-px px-1`}>!</code>).
</p>
</div>
}
@ -102,7 +106,7 @@ export default () => {
save(name);
}}
/>
<div className={'relative'}>
<div css={tw`relative`}>
<SpinnerOverlay visible={loading}/>
<LazyAceEditor
initialModePath={hash.replace(/^#/, '') || 'plain_text'}
@ -113,18 +117,18 @@ export default () => {
onContentSaved={() => save()}
/>
</div>
<div className={'flex justify-end mt-4'}>
<div css={tw`flex justify-end mt-4`}>
{action === 'edit' ?
<Can action={'file.update'}>
<button className={'btn btn-primary btn-sm'} onClick={() => save()}>
<Button onClick={() => save()}>
Save Content
</button>
</Button>
</Can>
:
<Can action={'file.create'}>
<button className={'btn btn-primary btn-sm'} onClick={() => setModalVisible(true)}>
<Button onClick={() => setModalVisible(true)}>
Create File
</button>
</Button>
</Can>
}
</div>

View file

@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react';
import { ServerContext } from '@/state/server';
import { NavLink } from 'react-router-dom';
import { cleanDirectoryPath } from '@/helpers';
import tw from 'twin.macro';
interface Props {
withinFileEditor?: boolean;
@ -32,11 +33,11 @@ export default ({ withinFileEditor, isNewFile }: Props) => {
});
return (
<div className={'flex items-center text-sm mb-4 text-neutral-500'}>
/<span className={'px-1 text-neutral-300'}>home</span>/
<div css={tw`flex items-center text-sm mb-4 text-neutral-500`}>
/<span css={tw`px-1 text-neutral-300`}>home</span>/
<NavLink
to={`/server/${id}/files`}
className={'px-1 text-neutral-200 no-underline hover:text-neutral-100'}
css={tw`px-1 text-neutral-200 no-underline hover:text-neutral-100`}
>
container
</NavLink>/
@ -46,18 +47,18 @@ export default ({ withinFileEditor, isNewFile }: Props) => {
<React.Fragment key={index}>
<NavLink
to={`/server/${id}/files#${crumb.path}`}
className={'px-1 text-neutral-200 no-underline hover:text-neutral-100'}
css={tw`px-1 text-neutral-200 no-underline hover:text-neutral-100`}
>
{crumb.name}
</NavLink>/
</React.Fragment>
:
<span key={index} className={'px-1 text-neutral-300'}>{crumb.name}</span>
<span key={index} css={tw`px-1 text-neutral-300`}>{crumb.name}</span>
))
}
{file &&
<React.Fragment>
<span className={'px-1 text-neutral-300'}>{decodeURIComponent(file)}</span>
<span css={tw`px-1 text-neutral-300`}>{decodeURIComponent(file)}</span>
</React.Fragment>
}
</div>

View file

@ -1,8 +1,4 @@
import React, { useEffect, useState } from 'react';
import FlashMessageRender from '@/components/FlashMessageRender';
import { ServerContext } from '@/state/server';
import { Actions, useStoreActions } from 'easy-peasy';
import { ApplicationStore } from '@/state';
import React, { useEffect } from 'react';
import { httpErrorToHuman } from '@/api/http';
import { CSSTransition } from 'react-transition-group';
import Spinner from '@/components/elements/Spinner';
@ -10,11 +6,15 @@ import FileObjectRow from '@/components/server/files/FileObjectRow';
import FileManagerBreadcrumbs from '@/components/server/files/FileManagerBreadcrumbs';
import { FileObject } from '@/api/server/files/loadDirectory';
import NewDirectoryButton from '@/components/server/files/NewDirectoryButton';
import { Link } from 'react-router-dom';
import { Link, useLocation } from 'react-router-dom';
import Can from '@/components/elements/Can';
import PageContentBlock from '@/components/elements/PageContentBlock';
import ServerError from '@/components/screens/ServerError';
import useRouter from 'use-react-router';
import tw from 'twin.macro';
import Button from '@/components/elements/Button';
import useServer from '@/plugins/useServer';
import { ServerContext } from '@/state/server';
import useFileManagerSwr from '@/plugins/useFileManagerSwr';
const sortFiles = (files: FileObject[]): FileObject[] => {
return files.sort((a, b) => a.name.localeCompare(b.name))
@ -22,93 +22,72 @@ const sortFiles = (files: FileObject[]): FileObject[] => {
};
export default () => {
const [ error, setError ] = useState('');
const [ loading, setLoading ] = useState(true);
const { addError, clearFlashes } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
const { id } = ServerContext.useStoreState(state => state.server.data!);
const { contents: files } = ServerContext.useStoreState(state => state.files);
const { getDirectoryContents } = ServerContext.useStoreActions(actions => actions.files);
const loadContents = () => {
setError('');
clearFlashes();
setLoading(true);
getDirectoryContents(window.location.hash)
.then(() => setLoading(false))
.catch(error => {
console.error(error.message, { error });
setError(httpErrorToHuman(error));
});
};
const { id } = useServer();
const { hash } = useLocation();
const { data: files, error, mutate } = useFileManagerSwr();
const setDirectory = ServerContext.useStoreActions(actions => actions.files.setDirectory);
useEffect(() => {
loadContents();
}, []);
// We won't automatically mutate the store when the component re-mounts, otherwise because of
// my (horrible) programming this fires off way more than we intend it to.
mutate();
setDirectory(hash.length > 0 ? hash : '/');
}, [ hash ]);
if (error) {
return (
<ServerError
message={error}
onRetry={() => loadContents()}
/>
<ServerError message={httpErrorToHuman(error)} onRetry={() => mutate()}/>
);
}
return (
<PageContentBlock>
<FlashMessageRender byKey={'files'} className={'mb-4'}/>
<React.Fragment>
<FileManagerBreadcrumbs/>
{
loading ?
<Spinner size={'large'} centered={true}/>
:
<React.Fragment>
{!files.length ?
<p className={'text-sm text-neutral-400 text-center'}>
This directory seems to be empty.
</p>
:
<CSSTransition classNames={'fade'} timeout={250} appear={true} in={true}>
<React.Fragment>
<div>
{files.length > 250 ?
<React.Fragment>
<div className={'rounded bg-yellow-400 mb-px p-3'}>
<p className={'text-yellow-900 text-sm text-center'}>
This directory is too large to display in the browser,
limiting the output to the first 250 files.
</p>
</div>
{
sortFiles(files.slice(0, 250)).map(file => (
<FileObjectRow key={file.uuid} file={file}/>
))
}
</React.Fragment>
:
sortFiles(files).map(file => (
<FileObjectRow key={file.uuid} file={file}/>
))
}
<PageContentBlock showFlashKey={'files'}>
<FileManagerBreadcrumbs/>
{
!files ?
<Spinner size={'large'} centered/>
:
<>
{!files.length ?
<p css={tw`text-sm text-neutral-400 text-center`}>
This directory seems to be empty.
</p>
:
<CSSTransition classNames={'fade'} timeout={150} appear in>
<React.Fragment>
<div>
{files.length > 250 &&
<div css={tw`rounded bg-yellow-400 mb-px p-3`}>
<p css={tw`text-yellow-900 text-sm text-center`}>
This directory is too large to display in the browser,
limiting the output to the first 250 files.
</p>
</div>
</React.Fragment>
</CSSTransition>
}
<Can action={'file.create'}>
<div className={'flex justify-end mt-8'}>
<NewDirectoryButton/>
<Link
to={`/server/${id}/files/new${window.location.hash}`}
className={'btn btn-sm btn-primary'}
>
New File
</Link>
</div>
</Can>
</React.Fragment>
}
</React.Fragment>
}
{
sortFiles(files.slice(0, 250)).map(file => (
<FileObjectRow key={file.uuid} file={file}/>
))
}
</div>
</React.Fragment>
</CSSTransition>
}
<Can action={'file.create'}>
<div css={tw`flex justify-end mt-8`}>
<NewDirectoryButton/>
<Button
// @ts-ignore
as={Link}
to={`/server/${id}/files/new${window.location.hash}`}
>
New File
</Button>
</div>
</Can>
</>
}
</PageContentBlock>
);
};

View file

@ -5,6 +5,8 @@ import { object, string } from 'yup';
import Field from '@/components/elements/Field';
import { ServerContext } from '@/state/server';
import { join } from 'path';
import tw from 'twin.macro';
import Button from '@/components/elements/Button';
type Props = RequiredModalProps & {
onFileNamed: (name: string) => void;
@ -44,12 +46,10 @@ export default ({ onFileNamed, onDismissed, ...props }: Props) => {
name={'fileName'}
label={'File Name'}
description={'Enter the name that this file should be saved as.'}
autoFocus={true}
autoFocus
/>
<div className={'mt-6 text-right'}>
<button className={'btn btn-primary btn-sm'}>
Create File
</button>
<div css={tw`mt-6 text-right`}>
<Button>Create File</Button>
</div>
</Form>
</Modal>

View file

@ -1,75 +1,83 @@
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faFileImport } from '@fortawesome/free-solid-svg-icons/faFileImport';
import { faFileAlt } from '@fortawesome/free-solid-svg-icons/faFileAlt';
import { faFolder } from '@fortawesome/free-solid-svg-icons/faFolder';
import { faFileAlt, faFileImport, faFolder } from '@fortawesome/free-solid-svg-icons';
import { bytesToHuman, cleanDirectoryPath } from '@/helpers';
import differenceInHours from 'date-fns/difference_in_hours';
import format from 'date-fns/format';
import distanceInWordsToNow from 'date-fns/distance_in_words_to_now';
import React from 'react';
import { differenceInHours, format, formatDistanceToNow } from 'date-fns';
import React, { memo } from 'react';
import { FileObject } from '@/api/server/files/loadDirectory';
import FileDropdownMenu from '@/components/server/files/FileDropdownMenu';
import { ServerContext } from '@/state/server';
import { NavLink } from 'react-router-dom';
import useRouter from 'use-react-router';
import { NavLink, useHistory, useRouteMatch } from 'react-router-dom';
import tw from 'twin.macro';
import isEqual from 'react-fast-compare';
import styled from 'styled-components/macro';
export default ({ file }: { file: FileObject }) => {
const Row = styled.div`
${tw`flex bg-neutral-700 rounded-sm mb-px text-sm hover:text-neutral-100 cursor-pointer items-center no-underline hover:bg-neutral-600`};
`;
const FileObjectRow = ({ file }: { file: FileObject }) => {
const directory = ServerContext.useStoreState(state => state.files.directory);
const setDirectory = ServerContext.useStoreActions(actions => actions.files.setDirectory);
const { match, history } = useRouter();
const history = useHistory();
const match = useRouteMatch();
const onRowClick = (e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => {
// Don't rely on the onClick to work with the generated URL. Because of the way this
// component re-renders you'll get redirected into a nested directory structure since
// it'll cause the directory variable to update right away when you click.
//
// Just trust me future me, leave this be.
if (!file.isFile) {
e.preventDefault();
history.push(`#${cleanDirectoryPath(`${directory}/${file.name}`)}`);
setDirectory(`${directory}/${file.name}`);
}
};
return (
<div
<Row
key={file.name}
className={`
flex bg-neutral-700 rounded-sm mb-px text-sm
hover:text-neutral-100 cursor-pointer items-center no-underline hover:bg-neutral-600
`}
onContextMenu={e => {
e.preventDefault();
window.dispatchEvent(new CustomEvent(`pterodactyl:files:ctx:${file.uuid}`, { detail: e.clientX }));
}}
>
<NavLink
to={`${match.url}/${file.isFile ? 'edit/' : ''}#${cleanDirectoryPath(`${directory}/${file.name}`)}`}
className={'flex flex-1 text-neutral-300 no-underline p-3'}
onClick={e => {
// Don't rely on the onClick to work with the generated URL. Because of the way this
// component re-renders you'll get redirected into a nested directory structure since
// it'll cause the directory variable to update right away when you click.
//
// Just trust me future me, leave this be.
if (!file.isFile) {
e.preventDefault();
history.push(`#${cleanDirectoryPath(`${directory}/${file.name}`)}`);
setDirectory(`${directory}/${file.name}`);
}
}}
css={tw`flex flex-1 text-neutral-300 no-underline p-3`}
onClick={onRowClick}
>
<div className={'flex-none text-neutral-400 mr-4 text-lg pl-3'}>
<div css={tw`flex-none text-neutral-400 mr-4 text-lg pl-3`}>
{file.isFile ?
<FontAwesomeIcon icon={file.isSymlink ? faFileImport : faFileAlt}/>
:
<FontAwesomeIcon icon={faFolder}/>
}
</div>
<div className={'flex-1'}>
<div css={tw`flex-1`}>
{file.name}
</div>
{file.isFile &&
<div className={'w-1/6 text-right mr-4'}>
<div css={tw`w-1/6 text-right mr-4`}>
{bytesToHuman(file.size)}
</div>
}
<div
className={'w-1/5 text-right mr-4'}
css={tw`w-1/5 text-right mr-4`}
title={file.modifiedAt.toString()}
>
{Math.abs(differenceInHours(file.modifiedAt, new Date())) > 48 ?
format(file.modifiedAt, 'MMM Do, YYYY h:mma')
format(file.modifiedAt, 'MMM do, yyyy h:mma')
:
distanceInWordsToNow(file.modifiedAt, { addSuffix: true })
formatDistanceToNow(file.modifiedAt, { addSuffix: true })
}
</div>
</NavLink>
<FileDropdownMenu uuid={file.uuid}/>
</div>
<FileDropdownMenu file={file}/>
</Row>
);
};
export default memo(FileObjectRow, (prevProps, nextProps) => isEqual(prevProps.file, nextProps.file));

View file

@ -7,6 +7,13 @@ import { join } from 'path';
import { object, string } from 'yup';
import createDirectory from '@/api/server/files/createDirectory';
import v4 from 'uuid/v4';
import tw from 'twin.macro';
import Button from '@/components/elements/Button';
import { mutate } from 'swr';
import useServer from '@/plugins/useServer';
import { FileObject } from '@/api/server/files/loadDirectory';
import { useLocation } from 'react-router';
import useFlash from '@/plugins/useFlash';
interface Values {
directoryName: string;
@ -16,37 +23,44 @@ const schema = object().shape({
directoryName: string().required('A valid directory name must be provided.'),
});
export default () => {
const [ visible, setVisible ] = useState(false);
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
const directory = ServerContext.useStoreState(state => state.files.directory);
const pushFile = ServerContext.useStoreActions(actions => actions.files.pushFile);
const generateDirectoryData = (name: string): FileObject => ({
uuid: v4(),
name: name,
mode: '0644',
size: 0,
isFile: false,
isEditable: false,
isSymlink: false,
mimetype: '',
createdAt: new Date(),
modifiedAt: new Date(),
});
const submit = (values: Values, { setSubmitting }: FormikHelpers<Values>) => {
createDirectory(uuid, directory, values.directoryName)
export default () => {
const { uuid } = useServer();
const { hash } = useLocation();
const { clearAndAddHttpError } = useFlash();
const [ visible, setVisible ] = useState(false);
const directory = ServerContext.useStoreState(state => state.files.directory);
const submit = ({ directoryName }: Values, { setSubmitting }: FormikHelpers<Values>) => {
createDirectory(uuid, directory, directoryName)
.then(() => {
pushFile({
uuid: v4(),
name: values.directoryName,
mode: '0644',
size: 0,
isFile: false,
isEditable: false,
isSymlink: false,
mimetype: '',
createdAt: new Date(),
modifiedAt: new Date(),
});
mutate(
`${uuid}:files:${hash}`,
(data: FileObject[]) => [ ...data, generateDirectoryData(directoryName) ],
);
setVisible(false);
})
.catch(error => {
console.error(error);
setSubmitting(false);
clearAndAddHttpError({ key: 'files', error });
});
};
return (
<React.Fragment>
<>
<Formik
onSubmit={submit}
validationSchema={schema}
@ -62,33 +76,33 @@ export default () => {
resetForm();
}}
>
<Form className={'m-0'}>
<Form css={tw`m-0`}>
<Field
id={'directoryName'}
name={'directoryName'}
label={'Directory Name'}
/>
<p className={'text-xs mt-2 text-neutral-400'}>
<span className={'text-neutral-200'}>This directory will be created as</span>
<p css={tw`text-xs mt-2 text-neutral-400`}>
<span css={tw`text-neutral-200`}>This directory will be created as</span>
&nbsp;/home/container/
<span className={'text-cyan-200'}>
<span css={tw`text-cyan-200`}>
{decodeURIComponent(
join(directory, values.directoryName).replace(/^(\.\.\/|\/)+/, ''),
)}
</span>
</p>
<div className={'flex justify-end'}>
<button className={'btn btn-sm btn-primary mt-8'}>
<div css={tw`flex justify-end`}>
<Button css={tw`mt-8`}>
Create Directory
</button>
</Button>
</div>
</Form>
</Modal>
)}
</Formik>
<button className={'btn btn-sm btn-secondary mr-2'} onClick={() => setVisible(true)}>
<Button isSecondary css={tw`mr-2`} onClick={() => setVisible(true)}>
Create Directory
</button>
</React.Fragment>
</Button>
</>
);
};

View file

@ -6,7 +6,11 @@ import { join } from 'path';
import renameFile from '@/api/server/files/renameFile';
import { ServerContext } from '@/state/server';
import { FileObject } from '@/api/server/files/loadDirectory';
import classNames from 'classnames';
import tw from 'twin.macro';
import Button from '@/components/elements/Button';
import useServer from '@/plugins/useServer';
import useFileManagerSwr from '@/plugins/useFileManagerSwr';
import useFlash from '@/plugins/useFlash';
interface FormikValues {
name: string;
@ -15,47 +19,44 @@ interface FormikValues {
type Props = RequiredModalProps & { file: FileObject; useMoveTerminology?: boolean };
export default ({ file, useMoveTerminology, ...props }: Props) => {
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
const { uuid } = useServer();
const { mutate } = useFileManagerSwr();
const { clearAndAddHttpError } = useFlash();
const directory = ServerContext.useStoreState(state => state.files.directory);
const { pushFile, removeFile } = ServerContext.useStoreActions(actions => actions.files);
const submit = (values: FormikValues, { setSubmitting }: FormikHelpers<FormikValues>) => {
const submit = ({ name }: FormikValues, { setSubmitting }: FormikHelpers<FormikValues>) => {
const len = name.split('/').length;
if (!useMoveTerminology && len === 1) {
// Rename the file within this directory.
mutate(files => files.map(f => f.uuid === file.uuid ? { ...f, name } : f), false);
} else if ((useMoveTerminology || len > 1) && file.uuid.length) {
// Remove the file from this directory since they moved it elsewhere.
mutate(files => files.filter(f => f.uuid !== file.uuid), false);
}
const renameFrom = join(directory, file.name);
const renameTo = join(directory, values.name);
const renameTo = join(directory, name);
renameFile(uuid, { renameFrom, renameTo })
.then(() => {
if (!useMoveTerminology && values.name.split('/').length === 1) {
pushFile({ ...file, name: values.name });
}
if ((useMoveTerminology || values.name.split('/').length > 1) && file.uuid.length > 0) {
removeFile(file.uuid);
}
props.onDismissed();
})
.then(() => props.onDismissed())
.catch(error => {
mutate();
setSubmitting(false);
console.error(error);
clearAndAddHttpError({ key: 'files', error });
});
};
return (
<Formik
onSubmit={submit}
initialValues={{ name: file.name }}
>
<Formik onSubmit={submit} initialValues={{ name: file.name }}>
{({ isSubmitting, values }) => (
<Modal {...props} dismissable={!isSubmitting} showSpinnerOverlay={isSubmitting}>
<Form className={'m-0'}>
<Form css={tw`m-0`}>
<div
className={classNames('flex', {
'items-center': useMoveTerminology,
'items-end': !useMoveTerminology,
})}
css={[
tw`flex`,
useMoveTerminology ? tw`items-center` : tw`items-end`,
]}
>
<div className={'flex-1 mr-6'}>
<div css={tw`flex-1 mr-6`}>
<Field
type={'string'}
id={'file_name'}
@ -65,18 +66,16 @@ export default ({ file, useMoveTerminology, ...props }: Props) => {
? 'Enter the new name and directory of this file or folder, relative to the current directory.'
: undefined
}
autoFocus={true}
autoFocus
/>
</div>
<div>
<button className={'btn btn-sm btn-primary'}>
{useMoveTerminology ? 'Move' : 'Rename'}
</button>
<Button>{useMoveTerminology ? 'Move' : 'Rename'}</Button>
</div>
</div>
{useMoveTerminology &&
<p className={'text-xs mt-2 text-neutral-400'}>
<strong className={'text-neutral-200'}>New location:</strong>
<p css={tw`text-xs mt-2 text-neutral-400`}>
<strong css={tw`text-neutral-200`}>New location:</strong>
&nbsp;/home/container/{join(directory, values.name).replace(/^(\.\.\/|\/)+/, '')}
</p>
}

View file

@ -0,0 +1,115 @@
import React, { useEffect, useState } from 'react';
import tw from 'twin.macro';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faNetworkWired } from '@fortawesome/free-solid-svg-icons';
import styled from 'styled-components/macro';
import PageContentBlock from '@/components/elements/PageContentBlock';
import GreyRowBox from '@/components/elements/GreyRowBox';
import Button from '@/components/elements/Button';
import Can from '@/components/elements/Can';
import useServer from '@/plugins/useServer';
import useSWR from 'swr';
import getServerAllocations from '@/api/server/network/getServerAllocations';
import { Allocation } from '@/api/server/getServer';
import Spinner from '@/components/elements/Spinner';
import setPrimaryServerAllocation from '@/api/server/network/setPrimaryServerAllocation';
import useFlash from '@/plugins/useFlash';
import { Textarea } from '@/components/elements/Input';
import setServerAllocationNotes from '@/api/server/network/setServerAllocationNotes';
import { debounce } from 'debounce';
import InputSpinner from '@/components/elements/InputSpinner';
const Code = styled.code`${tw`font-mono py-1 px-2 bg-neutral-900 rounded text-sm block`}`;
const Label = styled.label`${tw`uppercase text-xs mt-1 text-neutral-400 block px-1 select-none transition-colors duration-150`}`;
const NetworkContainer = () => {
const { uuid, allocations } = useServer();
const { clearFlashes, clearAndAddHttpError } = useFlash();
const [ loading, setLoading ] = useState<false | number>(false);
const { data, error, mutate } = useSWR<Allocation[]>(uuid, key => getServerAllocations(key), { initialData: allocations });
const setPrimaryAllocation = (id: number) => {
clearFlashes('server:network');
const initial = data;
mutate(data?.map(a => a.id === id ? { ...a, isDefault: true } : { ...a, isDefault: false }), false);
setPrimaryServerAllocation(uuid, id)
.catch(error => {
clearAndAddHttpError({ key: 'server:network', error });
mutate(initial, false);
});
};
const setAllocationNotes = debounce((id: number, notes: string) => {
setLoading(id);
clearFlashes('server:network');
setServerAllocationNotes(uuid, id, notes)
.then(() => mutate(data?.map(a => a.id === id ? { ...a, notes } : a), false))
.catch(error => {
clearAndAddHttpError({ key: 'server:network', error });
})
.then(() => setLoading(false));
}, 750);
useEffect(() => {
if (error) {
clearAndAddHttpError({ key: 'server:network', error });
}
}, [ error ]);
return (
<PageContentBlock showFlashKey={'server:network'}>
{!data ?
<Spinner size={'large'} centered/>
:
data.map(({ id, ip, port, alias, notes, isDefault }, index) => (
<GreyRowBox key={`${ip}:${port}`} css={index > 0 ? tw`mt-2` : undefined} $hoverable={false}>
<div css={tw`pl-4 pr-6 text-neutral-400`}>
<FontAwesomeIcon icon={faNetworkWired}/>
</div>
<div css={tw`mr-4`}>
<Code>{alias || ip}</Code>
<Label>IP Address</Label>
</div>
<div>
<Code>{port}</Code>
<Label>Port</Label>
</div>
<div css={tw`px-8 flex-1 self-start`}>
<InputSpinner visible={loading === id}>
<Textarea
css={tw`bg-neutral-800 hover:border-neutral-600 border-transparent`}
placeholder={'Notes'}
defaultValue={notes || undefined}
onChange={e => setAllocationNotes(id, e.currentTarget.value)}
/>
</InputSpinner>
</div>
<div css={tw`w-32 text-right`}>
{isDefault ?
<span css={tw`bg-green-500 py-1 px-2 rounded text-green-50 text-xs`}>
Primary
</span>
:
<Can action={'allocations.update'}>
<Button
isSecondary
size={'xsmall'}
color={'primary'}
onClick={() => setPrimaryAllocation(id)}
>
Make Primary
</Button>
</Can>
}
</div>
</GreyRowBox>
))
}
</PageContentBlock>
);
};
export default NetworkContainer;

View file

@ -1,26 +0,0 @@
import React from 'react';
import Modal, { RequiredModalProps } from '@/components/elements/Modal';
type Props = RequiredModalProps & {
onConfirmed: () => void;
}
export default ({ onConfirmed, ...props }: Props) => (
<Modal {...props}>
<h2>Confirm task deletion</h2>
<p className={'text-sm mt-4'}>
Are you sure you want to delete this task? This action cannot be undone.
</p>
<div className={'flex items-center justify-end mt-8'}>
<button className={'btn btn-secondary btn-sm'} onClick={() => props.onDismissed()}>
Cancel
</button>
<button className={'btn btn-red btn-sm ml-4'} onClick={() => {
props.onDismissed();
onConfirmed();
}}>
Delete Task
</button>
</div>
</Modal>
);

View file

@ -1,10 +1,12 @@
import React, { useState } from 'react';
import Modal from '@/components/elements/Modal';
import deleteSchedule from '@/api/server/schedules/deleteSchedule';
import { ServerContext } from '@/state/server';
import { Actions, useStoreActions } from 'easy-peasy';
import { ApplicationStore } from '@/state';
import { httpErrorToHuman } from '@/api/http';
import tw from 'twin.macro';
import Button from '@/components/elements/Button';
import ConfirmationModal from '@/components/elements/ConfirmationModal';
interface Props {
scheduleId: number;
@ -36,34 +38,20 @@ export default ({ scheduleId, onDeleted }: Props) => {
return (
<>
<Modal
<ConfirmationModal
showSpinnerOverlay={isLoading}
title={'Delete schedule?'}
buttonText={'Yes, delete schedule'}
onConfirmed={onDelete}
visible={visible}
onDismissed={() => setVisible(false)}
showSpinnerOverlay={isLoading}
>
<h3 className={'mb-6'}>Delete schedule</h3>
<p className={'text-sm'}>
Are you sure you want to delete this schedule? All tasks will be removed and any running processes
will be terminated.
</p>
<div className={'mt-6 flex justify-end'}>
<button
className={'btn btn-secondary btn-sm mr-4'}
onClick={() => setVisible(false)}
>
Cancel
</button>
<button
className={'btn btn-red btn-sm'}
onClick={() => onDelete()}
>
Yes, delete schedule
</button>
</div>
</Modal>
<button className={'btn btn-red btn-secondary btn-sm mr-4'} onClick={() => setVisible(true)}>
Are you sure you want to delete this schedule? All tasks will be removed and any running processes
will be terminated.
</ConfirmationModal>
<Button css={tw`mr-4`} color={'red'} isSecondary onClick={() => setVisible(true)}>
Delete
</button>
</Button>
</>
);
};

View file

@ -10,6 +10,8 @@ import { httpErrorToHuman } from '@/api/http';
import FlashMessageRender from '@/components/FlashMessageRender';
import useServer from '@/plugins/useServer';
import useFlash from '@/plugins/useFlash';
import tw from 'twin.macro';
import Button from '@/components/elements/Button';
type Props = {
schedule?: Schedule;
@ -29,43 +31,43 @@ const EditScheduleModal = ({ schedule, ...props }: Omit<Props, 'onScheduleUpdate
return (
<Modal {...props} showSpinnerOverlay={isSubmitting}>
<h3 className={'mb-6'}>{schedule ? 'Edit schedule' : 'Create new schedule'}</h3>
<FlashMessageRender byKey={'schedule:edit'} className={'mb-6'}/>
<h3 css={tw`mb-6`}>{schedule ? 'Edit schedule' : 'Create new schedule'}</h3>
<FlashMessageRender byKey={'schedule:edit'} css={tw`mb-6`}/>
<Form>
<Field
name={'name'}
label={'Schedule name'}
description={'A human readable identifer for this schedule.'}
/>
<div className={'flex mt-6'}>
<div className={'flex-1 mr-4'}>
<div css={tw`flex mt-6`}>
<div css={tw`flex-1 mr-4`}>
<Field name={'dayOfWeek'} label={'Day of week'}/>
</div>
<div className={'flex-1 mr-4'}>
<div css={tw`flex-1 mr-4`}>
<Field name={'dayOfMonth'} label={'Day of month'}/>
</div>
<div className={'flex-1 mr-4'}>
<div css={tw`flex-1 mr-4`}>
<Field name={'hour'} label={'Hour'}/>
</div>
<div className={'flex-1'}>
<div css={tw`flex-1`}>
<Field name={'minute'} label={'Minute'}/>
</div>
</div>
<p className={'input-help'}>
<p css={tw`text-neutral-400 text-xs mt-2`}>
The schedule system supports the use of Cronjob syntax when defining when tasks should begin
running. Use the fields above to specify when these tasks should begin running.
</p>
<div className={'mt-6 bg-neutral-700 border border-neutral-800 shadow-inner p-4 rounded'}>
<div css={tw`mt-6 bg-neutral-700 border border-neutral-800 shadow-inner p-4 rounded`}>
<FormikSwitch
name={'enabled'}
description={'If disabled, this schedule and it\'s associated tasks will not run.'}
label={'Enabled'}
/>
</div>
<div className={'mt-6 text-right'}>
<button className={'btn btn-sm btn-primary'} type={'submit'}>
<div css={tw`mt-6 text-right`}>
<Button type={'submit'}>
{schedule ? 'Save changes' : 'Create schedule'}
</button>
</Button>
</div>
</Form>
</Modal>

View file

@ -1,6 +1,7 @@
import React, { useState } from 'react';
import { Schedule } from '@/api/server/schedules/getServerSchedules';
import TaskDetailsModal from '@/components/server/schedules/TaskDetailsModal';
import Button from '@/components/elements/Button';
interface Props {
schedule: Schedule;
@ -17,9 +18,9 @@ export default ({ schedule }: Props) => {
onDismissed={() => setVisible(false)}
/>
}
<button className={'btn btn-primary btn-sm'} onClick={() => setVisible(true)}>
<Button onClick={() => setVisible(true)}>
New Task
</button>
</Button>
</>
);
};

View file

@ -11,6 +11,9 @@ import Can from '@/components/elements/Can';
import useServer from '@/plugins/useServer';
import useFlash from '@/plugins/useFlash';
import PageContentBlock from '@/components/elements/PageContentBlock';
import tw from 'twin.macro';
import GreyRowBox from '@/components/elements/GreyRowBox';
import Button from '@/components/elements/Button';
export default ({ match, history }: RouteComponentProps) => {
const { uuid } = useServer();
@ -34,45 +37,38 @@ export default ({ match, history }: RouteComponentProps) => {
return (
<PageContentBlock>
<FlashMessageRender byKey={'schedules'} className={'mb-4'}/>
<FlashMessageRender byKey={'schedules'} css={tw`mb-4`}/>
{(!schedules.length && loading) ?
<Spinner size={'large'} centered={true}/>
<Spinner size={'large'} centered/>
:
<>
{
schedules.length === 0 ?
<p className={'text-sm text-center text-neutral-400'}>
<p css={tw`text-sm text-center text-neutral-400`}>
There are no schedules configured for this server.
</p>
:
schedules.map(schedule => (
<a
<GreyRowBox
as={'a'}
key={schedule.id}
href={`${match.url}/${schedule.id}`}
className={'grey-row-box cursor-pointer mb-2'}
onClick={e => {
css={tw`cursor-pointer mb-2`}
onClick={(e: any) => {
e.preventDefault();
history.push(`${match.url}/${schedule.id}`, { schedule });
}}
>
<ScheduleRow schedule={schedule}/>
</a>
</GreyRowBox>
))
}
<Can action={'schedule.create'}>
<div className={'mt-8 flex justify-end'}>
{visible && <EditScheduleModal
appear={true}
visible={true}
onDismissed={() => setVisible(false)}
/>}
<button
type={'button'}
className={'btn btn-sm btn-primary'}
onClick={() => setVisible(true)}
>
<div css={tw`mt-8 flex justify-end`}>
{visible && <EditScheduleModal appear visible onDismissed={() => setVisible(false)}/>}
<Button type={'button'} onClick={() => setVisible(true)}>
Create schedule
</button>
</Button>
</div>
</Can>
</>

View file

@ -15,6 +15,9 @@ import useServer from '@/plugins/useServer';
import useFlash from '@/plugins/useFlash';
import { ServerContext } from '@/state/server';
import PageContentBlock from '@/components/elements/PageContentBlock';
import tw from 'twin.macro';
import Button from '@/components/elements/Button';
import GreyRowBox from '@/components/elements/GreyRowBox';
interface Params {
id: string;
@ -24,7 +27,7 @@ interface State {
schedule?: Schedule;
}
export default ({ match, history, location: { state } }: RouteComponentProps<Params, {}, State>) => {
export default ({ match, history, location: { state } }: RouteComponentProps<Params, Record<string, unknown>, State>) => {
const { id, uuid } = useServer();
const { clearFlashes, addError } = useFlash();
const [ isLoading, setIsLoading ] = useState(true);
@ -51,22 +54,22 @@ export default ({ match, history, location: { state } }: RouteComponentProps<Par
return (
<PageContentBlock>
<FlashMessageRender byKey={'schedules'} className={'mb-4'}/>
<FlashMessageRender byKey={'schedules'} css={tw`mb-4`}/>
{!schedule || isLoading ?
<Spinner size={'large'} centered={true}/>
<Spinner size={'large'} centered/>
:
<>
<div className={'grey-row-box'}>
<GreyRowBox>
<ScheduleRow schedule={schedule}/>
</div>
</GreyRowBox>
<EditScheduleModal
visible={showEditModal}
schedule={schedule}
onDismissed={() => setShowEditModal(false)}
/>
<div className={'flex items-center mt-8 mb-4'}>
<div className={'flex-1'}>
<h2>Configured Tasks</h2>
<div css={tw`flex items-center mt-8 mb-4`}>
<div css={tw`flex-1`}>
<h2 css={tw`text-2xl`}>Configured Tasks</h2>
</div>
</div>
{schedule.tasks.length > 0 ?
@ -79,17 +82,17 @@ export default ({ match, history, location: { state } }: RouteComponentProps<Par
))
}
{schedule.tasks.length > 1 &&
<p className={'text-xs text-neutral-400'}>
<p css={tw`text-xs text-neutral-400`}>
Task delays are relative to the previous task in the listing.
</p>
}
</>
:
<p className={'text-sm text-neutral-400'}>
<p css={tw`text-sm text-neutral-400`}>
There are no tasks configured for this schedule.
</p>
}
<div className={'mt-8 flex justify-end'}>
<div css={tw`mt-8 flex justify-end`}>
<Can action={'schedule.delete'}>
<DeleteScheduleButton
scheduleId={schedule.id}
@ -97,9 +100,9 @@ export default ({ match, history, location: { state } }: RouteComponentProps<Par
/>
</Can>
<Can action={'schedule.update'}>
<button className={'btn btn-primary btn-sm mr-4'} onClick={() => setShowEditModal(true)}>
<Button css={tw`mr-4`} onClick={() => setShowEditModal(true)}>
Edit
</button>
</Button>
<NewTaskButton schedule={schedule}/>
</Can>
</div>

View file

@ -1,50 +1,50 @@
import React from 'react';
import { Schedule } from '@/api/server/schedules/getServerSchedules';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faCalendarAlt } from '@fortawesome/free-solid-svg-icons/faCalendarAlt';
import format from 'date-fns/format';
import classNames from 'classnames';
import { faCalendarAlt } from '@fortawesome/free-solid-svg-icons';
import { format } from 'date-fns';
import tw from 'twin.macro';
export default ({ schedule }: { schedule: Schedule }) => (
<>
<div className={'icon'}>
<FontAwesomeIcon icon={faCalendarAlt} fixedWidth={true}/>
<div>
<FontAwesomeIcon icon={faCalendarAlt} fixedWidth/>
</div>
<div className={'flex-1 ml-4'}>
<div css={tw`flex-1 ml-4`}>
<p>{schedule.name}</p>
<p className={'text-xs text-neutral-400'}>
<p css={tw`text-xs text-neutral-400`}>
Last run
at: {schedule.lastRunAt ? format(schedule.lastRunAt, 'MMM Do [at] h:mma') : 'never'}
</p>
</div>
<div className={'flex items-center mx-8'}>
<div css={tw`flex items-center mx-8`}>
<div>
<p className={'font-medium text-center'}>{schedule.cron.minute}</p>
<p className={'text-2xs text-neutral-500 uppercase'}>Minute</p>
<p css={tw`font-medium text-center`}>{schedule.cron.minute}</p>
<p css={tw`text-2xs text-neutral-500 uppercase`}>Minute</p>
</div>
<div className={'ml-4'}>
<p className={'font-medium text-center'}>{schedule.cron.hour}</p>
<p className={'text-2xs text-neutral-500 uppercase'}>Hour</p>
<div css={tw`ml-4`}>
<p css={tw`font-medium text-center`}>{schedule.cron.hour}</p>
<p css={tw`text-2xs text-neutral-500 uppercase`}>Hour</p>
</div>
<div className={'ml-4'}>
<p className={'font-medium text-center'}>{schedule.cron.dayOfMonth}</p>
<p className={'text-2xs text-neutral-500 uppercase'}>Day (Month)</p>
<div css={tw`ml-4`}>
<p css={tw`font-medium text-center`}>{schedule.cron.dayOfMonth}</p>
<p css={tw`text-2xs text-neutral-500 uppercase`}>Day (Month)</p>
</div>
<div className={'ml-4'}>
<p className={'font-medium text-center'}>*</p>
<p className={'text-2xs text-neutral-500 uppercase'}>Month</p>
<div css={tw`ml-4`}>
<p css={tw`font-medium text-center`}>*</p>
<p css={tw`text-2xs text-neutral-500 uppercase`}>Month</p>
</div>
<div className={'ml-4'}>
<p className={'font-medium text-center'}>{schedule.cron.dayOfWeek}</p>
<p className={'text-2xs text-neutral-500 uppercase'}>Day (Week)</p>
<div css={tw`ml-4`}>
<p css={tw`font-medium text-center`}>{schedule.cron.dayOfWeek}</p>
<p css={tw`text-2xs text-neutral-500 uppercase`}>Day (Week)</p>
</div>
</div>
<div>
<p
className={classNames('py-1 px-3 rounded text-xs uppercase', {
'bg-green-600': schedule.isActive,
'bg-neutral-400': !schedule.isActive,
})}
css={[
tw`py-1 px-3 rounded text-xs uppercase text-white`,
schedule.isActive ? tw`bg-green-600` : tw`bg-neutral-400`,
]}
>
{schedule.isActive ? 'Active' : 'Inactive'}
</p>

View file

@ -1,20 +1,17 @@
import React, { useState } from 'react';
import { Schedule, Task } from '@/api/server/schedules/getServerSchedules';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faTrashAlt } from '@fortawesome/free-solid-svg-icons/faTrashAlt';
import { faCode } from '@fortawesome/free-solid-svg-icons/faCode';
import { faToggleOn } from '@fortawesome/free-solid-svg-icons/faToggleOn';
import ConfirmTaskDeletionModal from '@/components/server/schedules/ConfirmTaskDeletionModal';
import { faCode, faFileArchive, faPencilAlt, faToggleOn, faTrashAlt } from '@fortawesome/free-solid-svg-icons';
import deleteScheduleTask from '@/api/server/schedules/deleteScheduleTask';
import { httpErrorToHuman } from '@/api/http';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import TaskDetailsModal from '@/components/server/schedules/TaskDetailsModal';
import { faPencilAlt } from '@fortawesome/free-solid-svg-icons/faPencilAlt';
import Can from '@/components/elements/Can';
import useServer from '@/plugins/useServer';
import useFlash from '@/plugins/useFlash';
import { ServerContext } from '@/state/server';
import { faFileArchive } from '@fortawesome/free-solid-svg-icons/faFileArchive';
import tw from 'twin.macro';
import ConfirmationModal from '@/components/elements/ConfirmationModal';
interface Props {
schedule: Schedule;
@ -23,14 +20,14 @@ interface Props {
const getActionDetails = (action: string): [ string, any ] => {
switch (action) {
case 'command':
return ['Send Command', faCode];
case 'power':
return ['Send Power Action', faToggleOn];
case 'backup':
return ['Create Backup', faFileArchive];
default:
return ['Unknown Action', faCode];
case 'command':
return [ 'Send Command', faCode ];
case 'power':
return [ 'Send Power Action', faToggleOn ];
case 'backup':
return [ 'Create Backup', faFileArchive ];
default:
return [ 'Unknown Action', faCode ];
}
};
@ -60,38 +57,43 @@ export default ({ schedule, task }: Props) => {
const [ title, icon ] = getActionDetails(task.action);
return (
<div className={'flex items-center bg-neutral-700 border border-neutral-600 mb-2 px-6 py-4 rounded'}>
<SpinnerOverlay visible={isLoading} fixed={true} size={'large'}/>
<div css={tw`flex items-center bg-neutral-700 border border-neutral-600 mb-2 px-6 py-4 rounded`}>
<SpinnerOverlay visible={isLoading} fixed size={'large'}/>
{isEditing && <TaskDetailsModal
schedule={schedule}
task={task}
onDismissed={() => setIsEditing(false)}
/>}
<ConfirmTaskDeletionModal
<ConfirmationModal
title={'Confirm task deletion'}
buttonText={'Delete Task'}
onConfirmed={onConfirmDeletion}
visible={visible}
onDismissed={() => setVisible(false)}
onConfirmed={() => onConfirmDeletion()}
/>
<FontAwesomeIcon icon={icon} className={'text-lg text-white'}/>
<div className={'flex-1'}>
<p className={'ml-6 text-neutral-300 uppercase text-xs'}>
>
Are you sure you want to delete this task? This action cannot be undone.
</ConfirmationModal>
<FontAwesomeIcon icon={icon} css={tw`text-lg text-white`}/>
<div css={tw`flex-1`}>
<p css={tw`ml-6 text-neutral-300 uppercase text-xs`}>
{title}
</p>
{task.payload &&
<div className={'ml-6 mt-2'}>
{task.action === 'backup' && <p className={'text-xs uppercase text-neutral-400 mb-1'}>Ignoring files & folders:</p>}
<div className={'font-mono bg-neutral-800 rounded py-1 px-2 text-sm w-auto whitespace-pre inline-block'}>
<div css={tw`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`}>
{task.payload}
</div>
</div>
}
</div>
{task.sequenceId > 1 &&
<div className={'mr-6'}>
<p className={'text-center mb-1'}>
<div css={tw`mr-6`}>
<p css={tw`text-center mb-1`}>
{task.timeOffset}s
</p>
<p className={'text-neutral-300 uppercase text-2xs'}>
<p css={tw`text-neutral-300 uppercase text-2xs`}>
Delay Run By
</p>
</div>
@ -100,7 +102,7 @@ export default ({ schedule, task }: Props) => {
<button
type={'button'}
aria-label={'Edit scheduled task'}
className={'block text-sm p-2 text-neutral-500 hover:text-neutral-100 transition-colors duration-150 mr-4'}
css={tw`block text-sm p-2 text-neutral-500 hover:text-neutral-100 transition-colors duration-150 mr-4`}
onClick={() => setIsEditing(true)}
>
<FontAwesomeIcon icon={faPencilAlt}/>
@ -110,7 +112,7 @@ export default ({ schedule, task }: Props) => {
<button
type={'button'}
aria-label={'Delete scheduled task'}
className={'block text-sm p-2 text-neutral-500 hover:text-red-600 transition-colors duration-150'}
css={tw`block text-sm p-2 text-neutral-500 hover:text-red-600 transition-colors duration-150`}
onClick={() => setVisible(true)}
>
<FontAwesomeIcon icon={faTrashAlt}/>

View file

@ -11,6 +11,11 @@ import { number, object, string } from 'yup';
import useFlash from '@/plugins/useFlash';
import useServer from '@/plugins/useServer';
import FormikFieldWrapper from '@/components/elements/FormikFieldWrapper';
import tw from 'twin.macro';
import Label from '@/components/elements/Label';
import { Textarea } from '@/components/elements/Input';
import Button from '@/components/elements/Button';
import Select from '@/components/elements/Select';
interface Props {
schedule: Schedule;
@ -35,20 +40,20 @@ const TaskDetailsForm = ({ isEditingTask }: { isEditingTask: boolean }) => {
}, [ action ]);
return (
<Form className={'m-0'}>
<h3 className={'mb-6'}>{isEditingTask ? 'Edit Task' : 'Create Task'}</h3>
<div className={'flex'}>
<div className={'mr-2 w-1/3'}>
<label className={'input-dark-label'}>Action</label>
<Form css={tw`m-0`}>
<h2 css={tw`text-2xl mb-6`}>{isEditingTask ? 'Edit Task' : 'Create Task'}</h2>
<div css={tw`flex`}>
<div css={tw`mr-2 w-1/3`}>
<Label>Action</Label>
<FormikFieldWrapper name={'action'}>
<FormikField as={'select'} name={'action'} className={'input-dark'}>
<FormikField as={Select} name={'action'}>
<option value={'command'}>Send command</option>
<option value={'power'}>Send power action</option>
<option value={'backup'}>Create backup</option>
</FormikField>
</FormikFieldWrapper>
</div>
<div className={'flex-1'}>
<div css={tw`flex-1`}>
{action === 'command' ?
<Field
name={'payload'}
@ -58,9 +63,9 @@ const TaskDetailsForm = ({ isEditingTask }: { isEditingTask: boolean }) => {
:
action === 'power' ?
<div>
<label className={'input-dark-label'}>Payload</label>
<Label>Payload</Label>
<FormikFieldWrapper name={'payload'}>
<FormikField as={'select'} name={'payload'} className={'input-dark'}>
<FormikField as={Select} name={'payload'}>
<option value={'start'}>Start the server</option>
<option value={'restart'}>Restart the server</option>
<option value={'stop'}>Stop the server</option>
@ -70,28 +75,28 @@ const TaskDetailsForm = ({ isEditingTask }: { isEditingTask: boolean }) => {
</div>
:
<div>
<label className={'input-dark-label'}>Ignored Files</label>
<Label>Ignored Files</Label>
<FormikFieldWrapper
name={'payload'}
description={'Optional. Include the files and folders to be excluded in this backup. By default, the contents of your .pteroignore file will be used.'}
>
<FormikField as={'textarea'} name={'payload'} className={'input-dark h-32'}/>
<FormikField as={Textarea} name={'payload'} css={tw`h-32`}/>
</FormikFieldWrapper>
</div>
}
</div>
</div>
<div className={'mt-6'}>
<div css={tw`mt-6`}>
<Field
name={'timeOffset'}
label={'Time offset (in seconds)'}
description={'The amount of time to wait after the previous task executes before running this one. If this is the first task on a schedule this will not be applied.'}
/>
</div>
<div className={'flex justify-end mt-6'}>
<button type={'submit'} className={'btn btn-primary btn-sm'}>
<div css={tw`flex justify-end mt-6`}>
<Button type={'submit'}>
{isEditingTask ? 'Save Changes' : 'Create Task'}
</button>
</Button>
</div>
</Form>
);
@ -148,12 +153,12 @@ export default ({ task, schedule, onDismissed }: Props) => {
>
{({ isSubmitting }) => (
<Modal
visible={true}
appear={true}
visible
appear
onDismissed={() => onDismissed()}
showSpinnerOverlay={isSubmitting}
>
<FlashMessageRender byKey={'schedule:task'} className={'mb-4'}/>
<FlashMessageRender byKey={'schedule:task'} css={tw`mb-4`}/>
<TaskDetailsForm isEditingTask={typeof task !== 'undefined'}/>
</Modal>
)}

View file

@ -1,12 +1,13 @@
import React, { useState } from 'react';
import { ServerContext } from '@/state/server';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import TitledGreyBox from '@/components/elements/TitledGreyBox';
import ConfirmationModal from '@/components/elements/ConfirmationModal';
import reinstallServer from '@/api/server/reinstallServer';
import { Actions, useStoreActions } from 'easy-peasy';
import { ApplicationStore } from '@/state';
import { httpErrorToHuman } from '@/api/http';
import tw from 'twin.macro';
import Button from '@/components/elements/Button';
export default () => {
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
@ -19,7 +20,11 @@ export default () => {
setIsSubmitting(true);
reinstallServer(uuid)
.then(() => {
addFlash({ key: 'settings', type: 'success', message: 'Your server has begun the reinstallation process.' });
addFlash({
key: 'settings',
type: 'success',
message: 'Your server has begun the reinstallation process.',
});
})
.catch(error => {
console.error(error);
@ -30,10 +35,10 @@ export default () => {
setIsSubmitting(false);
setModalVisible(false);
});
}
};
return (
<TitledGreyBox title={'Reinstall Server'} className={'relative'}>
<TitledGreyBox title={'Reinstall Server'} css={tw`relative`}>
<ConfirmationModal
title={'Confirm server reinstallation'}
buttonText={'Yes, reinstall server'}
@ -42,21 +47,26 @@ export default () => {
visible={modalVisible}
onDismissed={() => setModalVisible(false)}
>
Your server will be stopped and some files may be deleted or modified during this process, are you sure you wish to continue?
Your server will be stopped and some files may be deleted or modified during this process, are you sure
you wish to continue?
</ConfirmationModal>
<p className={'text-sm'}>
<p css={tw`text-sm`}>
Reinstalling your server will stop it, and then re-run the installation script that initially
set it up. <strong className={'font-bold'}>Some files may be deleted or modified during this process,
please back up your data before continuing.</strong>
set it up.&nbsp;
<strong css={tw`font-medium`}>
Some files may be deleted or modified during this process, please back up your data before
continuing.
</strong>
</p>
<div className={'mt-6 text-right'}>
<button
<div css={tw`mt-6 text-right`}>
<Button
type={'button'}
className={'btn btn-sm btn-secondary btn-red'}
color={'red'}
isSecondary
onClick={() => setModalVisible(true)}
>
Reinstall Server
</button>
</Button>
</div>
</TitledGreyBox>
);

View file

@ -9,6 +9,8 @@ import { object, string } from 'yup';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import { ApplicationStore } from '@/state';
import { httpErrorToHuman } from '@/api/http';
import Button from '@/components/elements/Button';
import tw from 'twin.macro';
interface Values {
name: string;
@ -18,19 +20,19 @@ const RenameServerBox = () => {
const { isSubmitting } = useFormikContext<Values>();
return (
<TitledGreyBox title={'Change Server Name'} className={'relative'}>
<SpinnerOverlay size={'normal'} visible={isSubmitting}/>
<Form className={'mb-0'}>
<TitledGreyBox title={'Change Server Name'} css={tw`relative`}>
<SpinnerOverlay visible={isSubmitting}/>
<Form css={tw`mb-0`}>
<Field
id={'name'}
name={'name'}
label={'Server Name'}
type={'text'}
/>
<div className={'mt-6 text-right'}>
<button type={'submit'} className={'btn btn-sm btn-primary'}>
<div css={tw`mt-6 text-right`}>
<Button type={'submit'}>
Save
</button>
</Button>
</div>
</Form>
</TitledGreyBox>

View file

@ -9,6 +9,10 @@ import FlashMessageRender from '@/components/FlashMessageRender';
import Can from '@/components/elements/Can';
import ReinstallServerBox from '@/components/server/settings/ReinstallServerBox';
import PageContentBlock from '@/components/elements/PageContentBlock';
import tw from 'twin.macro';
import Input from '@/components/elements/Input';
import Label from '@/components/elements/Label';
import { LinkButton } from '@/components/elements/Button';
export default () => {
const user = useStoreState<ApplicationStore, UserData>(state => state.user.data!);
@ -16,52 +20,50 @@ export default () => {
return (
<PageContentBlock>
<FlashMessageRender byKey={'settings'} className={'mb-4'}/>
<div className={'md:flex'}>
<Can action={'file.sftp'}>
<div className={'w-full md:flex-1 md:max-w-1/2 md:mr-10'}>
<TitledGreyBox title={'SFTP Details'}>
<FlashMessageRender byKey={'settings'} css={tw`mb-4`}/>
<div css={tw`md:flex`}>
<div css={tw`w-full md:flex-1 md:mr-10`}>
<Can action={'file.sftp'}>
<TitledGreyBox title={'SFTP Details'} css={tw`mb-6 md:mb-10`}>
<div>
<label className={'input-dark-label'}>Server Address</label>
<input
<Label>Server Address</Label>
<Input
type={'text'}
className={'input-dark'}
value={`sftp://${server.sftpDetails.ip}:${server.sftpDetails.port}`}
readOnly={true}
readOnly
/>
</div>
<div className={'mt-6'}>
<label className={'input-dark-label'}>Username</label>
<input
<div css={tw`mt-6`}>
<Label>Username</Label>
<Input
type={'text'}
className={'input-dark'}
value={`${user.username}.${server.id}`}
readOnly={true}
readOnly
/>
</div>
<div className={'mt-6 flex items-center'}>
<div className={'flex-1'}>
<div className={'border-l-4 border-cyan-500 p-3'}>
<p className={'text-xs text-neutral-200'}>
<div css={tw`mt-6 flex items-center`}>
<div css={tw`flex-1`}>
<div css={tw`border-l-4 border-cyan-500 p-3`}>
<p css={tw`text-xs text-neutral-200`}>
Your SFTP password is the same as the password you use to access this panel.
</p>
</div>
</div>
<div className={'ml-4'}>
<a
<div css={tw`ml-4`}>
<LinkButton
isSecondary
href={`sftp://${user.username}.${server.id}@${server.sftpDetails.ip}:${server.sftpDetails.port}`}
className={'btn btn-sm btn-secondary'}
>
Launch SFTP
</a>
</LinkButton>
</div>
</div>
</TitledGreyBox>
</div>
</Can>
<div className={'w-full mt-6 md:flex-1 md:max-w-1/2 md:mt-0'}>
</Can>
</div>
<div css={tw`w-full mt-6 md:flex-1 md:mt-0`}>
<Can action={'settings.rename'}>
<div className={'mb-6 md:mb-10'}>
<div css={tw`mb-6 md:mb-10`}>
<RenameServerBox/>
</div>
</Can>

View file

@ -1,21 +1,19 @@
import React, { useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faUserPlus } from '@fortawesome/free-solid-svg-icons/faUserPlus';
import { faUserPlus } from '@fortawesome/free-solid-svg-icons';
import EditSubuserModal from '@/components/server/users/EditSubuserModal';
import Button from '@/components/elements/Button';
import tw from 'twin.macro';
export default () => {
const [ visible, setVisible ] = useState(false);
return (
<>
{visible && <EditSubuserModal
appear={true}
visible={true}
onDismissed={() => setVisible(false)}
/>}
<button className={'btn btn-primary btn-sm'} onClick={() => setVisible(true)}>
<FontAwesomeIcon icon={faUserPlus} className={'mr-1'}/> New User
</button>
{visible && <EditSubuserModal appear visible onDismissed={() => setVisible(false)}/>}
<Button onClick={() => setVisible(true)}>
<FontAwesomeIcon icon={faUserPlus} css={tw`mr-1`}/> New User
</Button>
</>
);
};

View file

@ -8,8 +8,7 @@ import { Actions, useStoreActions, useStoreState } from 'easy-peasy';
import { ApplicationStore } from '@/state';
import TitledGreyBox from '@/components/elements/TitledGreyBox';
import Checkbox from '@/components/elements/Checkbox';
import styled from 'styled-components';
import classNames from 'classnames';
import styled from 'styled-components/macro';
import createOrUpdateSubuser from '@/api/server/users/createOrUpdateSubuser';
import { ServerContext } from '@/state/server';
import { httpErrorToHuman } from '@/api/http';
@ -17,6 +16,10 @@ import FlashMessageRender from '@/components/FlashMessageRender';
import Can from '@/components/elements/Can';
import { usePermissions } from '@/plugins/usePermissions';
import { useDeepMemo } from '@/plugins/useDeepMemo';
import tw from 'twin.macro';
import Button from '@/components/elements/Button';
import Label from '@/components/elements/Label';
import Input from '@/components/elements/Input';
type Props = {
subuser?: Subuser;
@ -71,28 +74,28 @@ const EditSubuserModal = forwardRef<HTMLHeadingElement, Props>(({ subuser, ...pr
}
return list.filter(key => loggedInPermissions.indexOf(key) >= 0);
}, [permissions, loggedInPermissions]);
}, [ permissions, loggedInPermissions ]);
return (
<Modal {...props} top={false} showSpinnerOverlay={isSubmitting}>
<h3 ref={ref}>
<h2 css={tw`text-2xl`} ref={ref}>
{subuser ?
`${canEditUser ? 'Modify' : 'View'} permissions for ${subuser.email}`
:
'Create new subuser'
}
</h3>
<FlashMessageRender byKey={'user:edit'} className={'mt-4'}/>
</h2>
<FlashMessageRender byKey={'user:edit'} css={tw`mt-4`}/>
{(!user.rootAdmin && loggedInPermissions[0] !== '*') &&
<div className={'mt-4 pl-4 py-2 border-l-4 border-cyan-400'}>
<p className={'text-sm text-neutral-300'}>
<div css={tw`mt-4 pl-4 py-2 border-l-4 border-cyan-400`}>
<p css={tw`text-sm text-neutral-300`}>
Only permissions which your account is currently assigned may be selected when creating or
modifying other users.
</p>
</div>
}
{!subuser &&
<div className={'mt-6'}>
<div css={tw`mt-6`}>
<Field
name={'email'}
label={'User Email'}
@ -100,15 +103,15 @@ const EditSubuserModal = forwardRef<HTMLHeadingElement, Props>(({ subuser, ...pr
/>
</div>
}
<div className={'my-6'}>
<div css={tw`my-6`}>
{Object.keys(permissions).filter(key => key !== 'websocket').map((key, index) => (
<TitledGreyBox
key={key}
title={
<div className={'flex items-center'}>
<p className={'text-sm uppercase flex-1'}>{key}</p>
{canEditUser && editablePermissions.indexOf(key) >= 0 &&
<input
<div css={tw`flex items-center`}>
<p css={tw`text-sm uppercase flex-1`}>{key}</p>
{canEditUser &&
<Input
type={'checkbox'}
onClick={e => {
if (e.currentTarget.checked) {
@ -132,35 +135,34 @@ const EditSubuserModal = forwardRef<HTMLHeadingElement, Props>(({ subuser, ...pr
}
</div>
}
className={index !== 0 ? 'mt-4' : undefined}
css={index > 0 ? tw`mt-4` : undefined}
>
<p className={'text-sm text-neutral-400 mb-4'}>
<p css={tw`text-sm text-neutral-400 mb-4`}>
{permissions[key].description}
</p>
{Object.keys(permissions[key].keys).map((pkey, index) => (
<PermissionLabel
key={`permission_${key}_${pkey}`}
htmlFor={`permission_${key}_${pkey}`}
className={classNames('transition-colors duration-75', {
'mt-2': index !== 0,
disabled: !canEditUser || editablePermissions.indexOf(`${key}.${pkey}`) < 0,
})}
css={[
tw`transition-colors duration-75`,
index > 0 ? tw`mt-2` : undefined,
]}
className={(!canEditUser || editablePermissions.indexOf(`${key}.${pkey}`) < 0) ? 'disabled' : undefined}
>
<div className={'p-2'}>
<div css={tw`p-2`}>
<Checkbox
id={`permission_${key}_${pkey}`}
name={'permissions'}
value={`${key}.${pkey}`}
className={'w-5 h-5 mr-2'}
css={tw`w-5 h-5 mr-2`}
disabled={!canEditUser || editablePermissions.indexOf(`${key}.${pkey}`) < 0}
/>
</div>
<div className={'flex-1'}>
<span className={'input-dark-label font-medium'}>
{pkey}
</span>
<div css={tw`flex-1`}>
<Label css={tw`font-medium`}>{pkey}</Label>
{permissions[key].keys[pkey].length > 0 &&
<p className={'text-xs text-neutral-400 mt-1'}>
<p css={tw`text-xs text-neutral-400 mt-1`}>
{permissions[key].keys[pkey]}
</p>
}
@ -171,10 +173,10 @@ const EditSubuserModal = forwardRef<HTMLHeadingElement, Props>(({ subuser, ...pr
))}
</div>
<Can action={subuser ? 'user.update' : 'user.create'}>
<div className={'pb-6 flex justify-end'}>
<button className={'btn btn-primary btn-sm'} type={'submit'}>
<div css={tw`pb-6 flex justify-end`}>
<Button type={'submit'}>
{subuser ? 'Save' : 'Invite User'}
</button>
</Button>
</div>
</Can>
</Modal>

View file

@ -2,12 +2,13 @@ import React, { useState } from 'react';
import ConfirmationModal from '@/components/elements/ConfirmationModal';
import { ServerContext } from '@/state/server';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faTrashAlt } from '@fortawesome/free-solid-svg-icons/faTrashAlt';
import { faTrashAlt } from '@fortawesome/free-solid-svg-icons';
import { Subuser } from '@/state/server/subusers';
import deleteSubuser from '@/api/server/users/deleteSubuser';
import { Actions, useStoreActions } from 'easy-peasy';
import { ApplicationStore } from '@/state';
import { httpErrorToHuman } from '@/api/http';
import tw from 'twin.macro';
export default ({ subuser }: { subuser: Subuser }) => {
const [ loading, setLoading ] = useState(false);
@ -30,7 +31,7 @@ export default ({ subuser }: { subuser: Subuser }) => {
addError({ key: 'users', message: httpErrorToHuman(error) });
setShowConfirmation(false);
});
}
};
return (
<>
@ -38,7 +39,7 @@ export default ({ subuser }: { subuser: Subuser }) => {
<ConfirmationModal
title={'Delete this subuser?'}
buttonText={'Yes, remove subuser'}
visible={true}
visible
showSpinnerOverlay={loading}
onConfirmed={() => doDeletion()}
onDismissed={() => setShowConfirmation(false)}
@ -50,7 +51,7 @@ export default ({ subuser }: { subuser: Subuser }) => {
<button
type={'button'}
aria-label={'Delete subuser'}
className={'block text-sm p-2 text-neutral-500 hover:text-red-600 transition-colors duration-150'}
css={tw`block text-sm p-2 text-neutral-500 hover:text-red-600 transition-colors duration-150`}
onClick={() => setShowConfirmation(true)}
>
<FontAwesomeIcon icon={faTrashAlt}/>

View file

@ -1,14 +1,13 @@
import React, { useState } from 'react';
import { Subuser } from '@/state/server/subusers';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPencilAlt } from '@fortawesome/free-solid-svg-icons/faPencilAlt';
import { faPencilAlt, faUnlockAlt, faUserLock } from '@fortawesome/free-solid-svg-icons';
import RemoveSubuserButton from '@/components/server/users/RemoveSubuserButton';
import EditSubuserModal from '@/components/server/users/EditSubuserModal';
import { faUnlockAlt } from '@fortawesome/free-solid-svg-icons/faUnlockAlt';
import { faUserLock } from '@fortawesome/free-solid-svg-icons/faUserLock';
import classNames from 'classnames';
import Can from '@/components/elements/Can';
import { useStoreState } from 'easy-peasy';
import tw from 'twin.macro';
import GreyRowBox from '@/components/elements/GreyRowBox';
interface Props {
subuser: Subuser;
@ -19,46 +18,46 @@ export default ({ subuser }: Props) => {
const [ visible, setVisible ] = useState(false);
return (
<div className={'grey-row-box mb-2'}>
<GreyRowBox css={tw`mb-2`}>
{visible &&
<EditSubuserModal
appear={true}
visible={true}
appear
visible
subuser={subuser}
onDismissed={() => setVisible(false)}
/>
}
<div className={'w-10 h-10 rounded-full bg-white border-2 border-inset border-neutral-800 overflow-hidden'}>
<img className={'f-full h-full'} src={`${subuser.image}?s=400`}/>
<div css={tw`w-10 h-10 rounded-full bg-white border-2 border-neutral-800 overflow-hidden`}>
<img css={tw`w-full h-full`} src={`${subuser.image}?s=400`}/>
</div>
<div className={'ml-4 flex-1'}>
<p className={'text-sm'}>{subuser.email}</p>
<div css={tw`ml-4 flex-1`}>
<p css={tw`text-sm`}>{subuser.email}</p>
</div>
<div className={'ml-4'}>
<p className={'font-medium text-center'}>
<div css={tw`ml-4`}>
<p css={tw`font-medium text-center`}>
&nbsp;
<FontAwesomeIcon
icon={subuser.twoFactorEnabled ? faUserLock : faUnlockAlt}
className={classNames('fa-fw', {
'text-red-400': !subuser.twoFactorEnabled,
})}
fixedWidth
css={!subuser.twoFactorEnabled ? tw`text-red-400` : undefined}
/>
&nbsp;
</p>
<p className={'text-2xs text-neutral-500 uppercase'}>2FA Enabled</p>
<p css={tw`text-2xs text-neutral-500 uppercase`}>2FA Enabled</p>
</div>
<div className={'ml-4'}>
<p className={'font-medium text-center'}>
<div css={tw`ml-4`}>
<p css={tw`font-medium text-center`}>
{subuser.permissions.filter(permission => permission !== 'websocket.connect').length}
</p>
<p className={'text-2xs text-neutral-500 uppercase'}>Permissions</p>
<p css={tw`text-2xs text-neutral-500 uppercase`}>Permissions</p>
</div>
<button
type={'button'}
aria-label={'Edit subuser'}
className={classNames('block text-sm p-2 text-neutral-500 hover:text-neutral-100 transition-colors duration-150 mx-4', {
hidden: subuser.uuid === uuid,
})}
css={[
tw`block text-sm p-2 text-neutral-500 hover:text-neutral-100 transition-colors duration-150 mx-4`,
subuser.uuid === uuid ? tw`hidden` : undefined,
]}
onClick={() => setVisible(true)}
>
<FontAwesomeIcon icon={faPencilAlt}/>
@ -66,6 +65,6 @@ export default ({ subuser }: Props) => {
<Can action={'user.delete'}>
<RemoveSubuserButton subuser={subuser}/>
</Can>
</div>
</GreyRowBox>
);
};

View file

@ -10,6 +10,7 @@ import getServerSubusers from '@/api/server/users/getServerSubusers';
import { httpErrorToHuman } from '@/api/http';
import Can from '@/components/elements/Can';
import PageContentBlock from '@/components/elements/PageContentBlock';
import tw from 'twin.macro';
export default () => {
const [ loading, setLoading ] = useState(true);
@ -43,15 +44,15 @@ export default () => {
}, []);
if (!subusers.length && (loading || !Object.keys(permissions).length)) {
return <Spinner size={'large'} centered={true}/>;
return <Spinner size={'large'} centered/>;
}
return (
<PageContentBlock>
<FlashMessageRender byKey={'users'} className={'mb-4'}/>
<FlashMessageRender byKey={'users'} css={tw`mb-4`}/>
{!subusers.length ?
<p className={'text-center text-sm text-neutral-400'}>
It looks like you don't have any subusers.
<p css={tw`text-center text-sm text-neutral-400`}>
It looks like you don&apos;t have any subusers.
</p>
:
subusers.map(subuser => (
@ -59,7 +60,7 @@ export default () => {
))
}
<Can action={'user.create'}>
<div className={'flex justify-end mt-6'}>
<div css={tw`flex justify-end mt-6`}>
<AddSubuserButton/>
</div>
</Can>