Merge branch 'develop' into fix/2071
This commit is contained in:
commit
a9bb692112
194 changed files with 5396 additions and 5416 deletions
|
@ -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>
|
||||
|
|
|
@ -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`),
|
||||
]}
|
||||
/>
|
||||
{status}
|
||||
</p>
|
||||
<p className={'text-xs mt-2'}>
|
||||
<FontAwesomeIcon
|
||||
icon={faMicrochip}
|
||||
fixedWidth={true}
|
||||
className={'mr-1'}
|
||||
/>
|
||||
{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'}
|
||||
/>
|
||||
{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'}
|
||||
/>
|
||||
{bytesToHuman(disk)}
|
||||
|
||||
<span className={'text-neutral-500'}> / {disklimit}</span>
|
||||
<p css={tw`text-xs mt-2`}>
|
||||
<FontAwesomeIcon icon={faHdd} fixedWidth css={tw`mr-1`}/> {bytesToHuman(disk)}
|
||||
<span css={tw`text-neutral-500`}> / {disklimit}</span>
|
||||
</p>
|
||||
</TitledGreyBox>
|
||||
{!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/>
|
||||
|
|
|
@ -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>
|
||||
}
|
||||
|
|
30
resources/scripts/components/server/StopOrKillButton.tsx
Normal file
30
resources/scripts/components/server/StopOrKillButton.tsx
Normal 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;
|
|
@ -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're having some trouble connecting to your server, please wait...
|
||||
</p>
|
||||
</ContentContainer>
|
||||
</div>
|
||||
|
|
|
@ -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>
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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'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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -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>
|
||||
/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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
/home/container/{join(directory, values.name).replace(/^(\.\.\/|\/)+/, '')}
|
||||
</p>
|
||||
}
|
||||
|
|
115
resources/scripts/components/server/network/NetworkContainer.tsx
Normal file
115
resources/scripts/components/server/network/NetworkContainer.tsx
Normal 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;
|
|
@ -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>
|
||||
);
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}/>
|
||||
|
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
@ -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.
|
||||
<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>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}/>
|
||||
|
|
|
@ -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`}>
|
||||
|
||||
<FontAwesomeIcon
|
||||
icon={subuser.twoFactorEnabled ? faUserLock : faUnlockAlt}
|
||||
className={classNames('fa-fw', {
|
||||
'text-red-400': !subuser.twoFactorEnabled,
|
||||
})}
|
||||
fixedWidth
|
||||
css={!subuser.twoFactorEnabled ? tw`text-red-400` : undefined}
|
||||
/>
|
||||
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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'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>
|
||||
|
|
Reference in a new issue