Apply new eslint rules; default to prettier for styling
This commit is contained in:
parent
f22cce8881
commit
dc84af9937
218 changed files with 3876 additions and 3564 deletions
|
@ -6,28 +6,30 @@ import ServerErrorSvg from '@/assets/images/server_error.svg';
|
|||
import ServerRestoreSvg from '@/assets/images/server_restore.svg';
|
||||
|
||||
export default () => {
|
||||
const status = ServerContext.useStoreState(state => state.server.data?.status || null);
|
||||
const isTransferring = ServerContext.useStoreState(state => state.server.data?.isTransferring || false);
|
||||
const status = ServerContext.useStoreState((state) => state.server.data?.status || null);
|
||||
const isTransferring = ServerContext.useStoreState((state) => state.server.data?.isTransferring || false);
|
||||
|
||||
return (
|
||||
status === 'installing' || status === 'install_failed' ?
|
||||
<ScreenBlock
|
||||
title={'Running Installer'}
|
||||
image={ServerInstallSvg}
|
||||
message={'Your server should be ready soon, please try again in a few minutes.'}
|
||||
/>
|
||||
:
|
||||
status === 'suspended' ?
|
||||
<ScreenBlock
|
||||
title={'Server Suspended'}
|
||||
image={ServerErrorSvg}
|
||||
message={'This server is suspended and cannot be accessed.'}
|
||||
/>
|
||||
:
|
||||
<ScreenBlock
|
||||
title={isTransferring ? 'Transferring' : 'Restoring from Backup'}
|
||||
image={ServerRestoreSvg}
|
||||
message={isTransferring ? 'Your server is being transfered to a new node, please check back later.' : 'Your server is currently being restored from a backup, please check back in a few minutes.'}
|
||||
/>
|
||||
return status === 'installing' || status === 'install_failed' ? (
|
||||
<ScreenBlock
|
||||
title={'Running Installer'}
|
||||
image={ServerInstallSvg}
|
||||
message={'Your server should be ready soon, please try again in a few minutes.'}
|
||||
/>
|
||||
) : status === 'suspended' ? (
|
||||
<ScreenBlock
|
||||
title={'Server Suspended'}
|
||||
image={ServerErrorSvg}
|
||||
message={'This server is suspended and cannot be accessed.'}
|
||||
/>
|
||||
) : (
|
||||
<ScreenBlock
|
||||
title={isTransferring ? 'Transferring' : 'Restoring from Backup'}
|
||||
image={ServerRestoreSvg}
|
||||
message={
|
||||
isTransferring
|
||||
? 'Your server is being transfered to a new node, please check back later.'
|
||||
: 'Your server is currently being restored from a backup, please check back in a few minutes.'
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -5,26 +5,26 @@ import { mutate } from 'swr';
|
|||
import { getDirectorySwrKey } from '@/plugins/useFileManagerSwr';
|
||||
|
||||
const InstallListener = () => {
|
||||
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
|
||||
const getServer = ServerContext.useStoreActions(actions => actions.server.getServer);
|
||||
const setServerFromState = ServerContext.useStoreActions(actions => actions.server.setServerFromState);
|
||||
const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid);
|
||||
const getServer = ServerContext.useStoreActions((actions) => actions.server.getServer);
|
||||
const setServerFromState = ServerContext.useStoreActions((actions) => actions.server.setServerFromState);
|
||||
|
||||
useWebsocketEvent(SocketEvent.BACKUP_RESTORE_COMPLETED, () => {
|
||||
mutate(getDirectorySwrKey(uuid, '/'), undefined);
|
||||
setServerFromState(s => ({ ...s, status: null }));
|
||||
setServerFromState((s) => ({ ...s, status: null }));
|
||||
});
|
||||
|
||||
// Listen for the installation completion event and then fire off a request to fetch the updated
|
||||
// server information. This allows the server to automatically become available to the user if they
|
||||
// just sit on the page.
|
||||
useWebsocketEvent(SocketEvent.INSTALL_COMPLETED, () => {
|
||||
getServer(uuid).catch(error => console.error(error));
|
||||
getServer(uuid).catch((error) => console.error(error));
|
||||
});
|
||||
|
||||
// When we see the install started event immediately update the state to indicate such so that the
|
||||
// screens automatically update.
|
||||
useWebsocketEvent(SocketEvent.INSTALL_STARTED, () => {
|
||||
setServerFromState(s => ({ ...s, status: 'installing' }));
|
||||
setServerFromState((s) => ({ ...s, status: 'installing' }));
|
||||
});
|
||||
|
||||
return null;
|
||||
|
|
|
@ -16,7 +16,7 @@ import useLocationHash from '@/plugins/useLocationHash';
|
|||
export default () => {
|
||||
const { hash } = useLocationHash();
|
||||
const { clearAndAddHttpError } = useFlashKey('server:activity');
|
||||
const [ filters, setFilters ] = useState<ActivityLogFilters>({ page: 1, sorts: { timestamp: -1 } });
|
||||
const [filters, setFilters] = useState<ActivityLogFilters>({ page: 1, sorts: { timestamp: -1 } });
|
||||
|
||||
const { data, isValidating, error } = useActivityLogs(filters, {
|
||||
revalidateOnMount: true,
|
||||
|
@ -24,45 +24,46 @@ export default () => {
|
|||
});
|
||||
|
||||
useEffect(() => {
|
||||
setFilters(value => ({ ...value, filters: { ip: hash.ip, event: hash.event } }));
|
||||
}, [ hash ]);
|
||||
setFilters((value) => ({ ...value, filters: { ip: hash.ip, event: hash.event } }));
|
||||
}, [hash]);
|
||||
|
||||
useEffect(() => {
|
||||
clearAndAddHttpError(error);
|
||||
}, [ error ]);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<ServerContentBlock title={'Activity Log'}>
|
||||
<FlashMessageRender byKey={'server:activity'}/>
|
||||
{(filters.filters?.event || filters.filters?.ip) &&
|
||||
<FlashMessageRender byKey={'server:activity'} />
|
||||
{(filters.filters?.event || filters.filters?.ip) && (
|
||||
<div className={'flex justify-end mb-2'}>
|
||||
<Link
|
||||
to={'#'}
|
||||
className={classNames(btnStyles.button, btnStyles.text, 'w-full sm:w-auto')}
|
||||
onClick={() => setFilters(value => ({ ...value, filters: {} }))}
|
||||
onClick={() => setFilters((value) => ({ ...value, filters: {} }))}
|
||||
>
|
||||
Clear Filters <XCircleIcon className={'w-4 h-4 ml-2'}/>
|
||||
Clear Filters <XCircleIcon className={'w-4 h-4 ml-2'} />
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
{!data && isValidating ?
|
||||
<Spinner centered/>
|
||||
:
|
||||
!data?.items.length ?
|
||||
<p className={'text-sm text-center text-gray-400'}>No activity logs available for this server.</p>
|
||||
:
|
||||
<div className={'bg-gray-700'}>
|
||||
{data?.items.map((activity) => (
|
||||
<ActivityLogEntry key={activity.timestamp.toString() + activity.event} activity={activity}>
|
||||
<span/>
|
||||
</ActivityLogEntry>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
{data && <PaginationFooter
|
||||
pagination={data.pagination}
|
||||
onPageSelect={page => setFilters(value => ({ ...value, page }))}
|
||||
/>}
|
||||
)}
|
||||
{!data && isValidating ? (
|
||||
<Spinner centered />
|
||||
) : !data?.items.length ? (
|
||||
<p className={'text-sm text-center text-gray-400'}>No activity logs available for this server.</p>
|
||||
) : (
|
||||
<div className={'bg-gray-700'}>
|
||||
{data?.items.map((activity) => (
|
||||
<ActivityLogEntry key={activity.timestamp.toString() + activity.event} activity={activity}>
|
||||
<span />
|
||||
</ActivityLogEntry>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{data && (
|
||||
<PaginationFooter
|
||||
pagination={data.pagination}
|
||||
onPageSelect={(page) => setFilters((value) => ({ ...value, page }))}
|
||||
/>
|
||||
)}
|
||||
</ServerContentBlock>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -3,19 +3,19 @@ import { ServerContext } from '@/state/server';
|
|||
import { SocketEvent } from '@/components/server/events';
|
||||
|
||||
const TransferListener = () => {
|
||||
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
|
||||
const getServer = ServerContext.useStoreActions(actions => actions.server.getServer);
|
||||
const setServerFromState = ServerContext.useStoreActions(actions => actions.server.setServerFromState);
|
||||
const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid);
|
||||
const getServer = ServerContext.useStoreActions((actions) => actions.server.getServer);
|
||||
const setServerFromState = ServerContext.useStoreActions((actions) => actions.server.setServerFromState);
|
||||
|
||||
// Listen for the transfer status event so we can update the state of the server.
|
||||
useWebsocketEvent(SocketEvent.TRANSFER_STATUS, (status: string) => {
|
||||
if (status === 'starting') {
|
||||
setServerFromState(s => ({ ...s, isTransferring: true }));
|
||||
setServerFromState((s) => ({ ...s, isTransferring: true }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (status === 'failure') {
|
||||
setServerFromState(s => ({ ...s, isTransferring: false }));
|
||||
setServerFromState((s) => ({ ...s, isTransferring: false }));
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -24,7 +24,7 @@ const TransferListener = () => {
|
|||
}
|
||||
|
||||
// Refresh the server's information as it's node and allocations were just updated.
|
||||
getServer(uuid).catch(error => console.error(error));
|
||||
getServer(uuid).catch((error) => console.error(error));
|
||||
});
|
||||
|
||||
return null;
|
||||
|
|
|
@ -2,14 +2,22 @@ import React from 'react';
|
|||
|
||||
export default ({ uptime }: { uptime: number }) => {
|
||||
const days = Math.floor(uptime / (24 * 60 * 60));
|
||||
const hours = Math.floor(Math.floor(uptime) / 60 / 60 % 24);
|
||||
const remainder = Math.floor(uptime - (hours * 60 * 60));
|
||||
const minutes = Math.floor(remainder / 60 % 60);
|
||||
const hours = Math.floor((Math.floor(uptime) / 60 / 60) % 24);
|
||||
const remainder = Math.floor(uptime - hours * 60 * 60);
|
||||
const minutes = Math.floor((remainder / 60) % 60);
|
||||
const seconds = remainder % 60;
|
||||
|
||||
if (days > 0) {
|
||||
return <>{days}d {hours}h {minutes}m</>;
|
||||
return (
|
||||
<>
|
||||
{days}d {hours}h {minutes}m
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{hours}h {minutes}m {seconds}s</>;
|
||||
return (
|
||||
<>
|
||||
{hours}h {minutes}m {seconds}s
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -7,26 +7,23 @@ import { CSSTransition } from 'react-transition-group';
|
|||
import Spinner from '@/components/elements/Spinner';
|
||||
import tw from 'twin.macro';
|
||||
|
||||
const reconnectErrors = [
|
||||
'jwt: exp claim is invalid',
|
||||
'jwt: created too far in past (denylist)',
|
||||
];
|
||||
const reconnectErrors = ['jwt: exp claim is invalid', 'jwt: created too far in past (denylist)'];
|
||||
|
||||
export default () => {
|
||||
let updatingToken = false;
|
||||
const [ error, setError ] = useState<'connecting' | string>('');
|
||||
const { connected, instance } = ServerContext.useStoreState(state => state.socket);
|
||||
const uuid = ServerContext.useStoreState(state => state.server.data?.uuid);
|
||||
const setServerStatus = ServerContext.useStoreActions(actions => actions.status.setServerStatus);
|
||||
const { setInstance, setConnectionState } = ServerContext.useStoreActions(actions => actions.socket);
|
||||
const [error, setError] = useState<'connecting' | string>('');
|
||||
const { connected, instance } = ServerContext.useStoreState((state) => state.socket);
|
||||
const uuid = ServerContext.useStoreState((state) => state.server.data?.uuid);
|
||||
const setServerStatus = ServerContext.useStoreActions((actions) => actions.status.setServerStatus);
|
||||
const { setInstance, setConnectionState } = ServerContext.useStoreActions((actions) => actions.socket);
|
||||
|
||||
const updateToken = (uuid: string, socket: Websocket) => {
|
||||
if (updatingToken) return;
|
||||
|
||||
updatingToken = true;
|
||||
getWebsocketToken(uuid)
|
||||
.then(data => socket.setToken(data.token, true))
|
||||
.catch(error => console.error(error))
|
||||
.then((data) => socket.setToken(data.token, true))
|
||||
.catch((error) => console.error(error))
|
||||
.then(() => {
|
||||
updatingToken = false;
|
||||
});
|
||||
|
@ -43,7 +40,7 @@ export default () => {
|
|||
});
|
||||
socket.on('status', (status) => setServerStatus(status));
|
||||
|
||||
socket.on('daemon error', message => {
|
||||
socket.on('daemon error', (message) => {
|
||||
console.warn('Got error message from daemon socket:', message);
|
||||
});
|
||||
|
||||
|
@ -53,10 +50,12 @@ export default () => {
|
|||
setConnectionState(false);
|
||||
console.warn('JWT validation error from wings:', error);
|
||||
|
||||
if (reconnectErrors.find(v => error.toLowerCase().indexOf(v) >= 0)) {
|
||||
if (reconnectErrors.find((v) => error.toLowerCase().indexOf(v) >= 0)) {
|
||||
updateToken(uuid, socket);
|
||||
} else {
|
||||
setError('There was an error validating the credentials provided for the websocket. Please refresh the page.');
|
||||
setError(
|
||||
'There was an error validating the credentials provided for the websocket. Please refresh the page.'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -75,25 +74,25 @@ export default () => {
|
|||
});
|
||||
|
||||
getWebsocketToken(uuid)
|
||||
.then(data => {
|
||||
.then((data) => {
|
||||
// Connect and then set the authentication token.
|
||||
socket.setToken(data.token).connect(data.socket);
|
||||
|
||||
// Once that is done, set the instance.
|
||||
setInstance(socket);
|
||||
})
|
||||
.catch(error => console.error(error));
|
||||
.catch((error) => console.error(error));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
connected && setError('');
|
||||
}, [ connected ]);
|
||||
}, [connected]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
instance && instance.close();
|
||||
};
|
||||
}, [ instance ]);
|
||||
}, [instance]);
|
||||
|
||||
useEffect(() => {
|
||||
// If there is already an instance or there is no server, just exit out of this process
|
||||
|
@ -103,29 +102,24 @@ export default () => {
|
|||
}
|
||||
|
||||
connect(uuid);
|
||||
}, [ uuid ]);
|
||||
}, [uuid]);
|
||||
|
||||
return (
|
||||
error ?
|
||||
<CSSTransition timeout={150} in appear classNames={'fade'}>
|
||||
<div css={tw`bg-red-500 py-2`}>
|
||||
<ContentContainer css={tw`flex items-center justify-center`}>
|
||||
{error === 'connecting' ?
|
||||
<>
|
||||
<Spinner size={'small'}/>
|
||||
<p css={tw`ml-2 text-sm text-red-100`}>
|
||||
We're having some trouble connecting to your server, please wait...
|
||||
</p>
|
||||
</>
|
||||
:
|
||||
<p css={tw`ml-2 text-sm text-white`}>
|
||||
{error}
|
||||
return error ? (
|
||||
<CSSTransition timeout={150} in appear classNames={'fade'}>
|
||||
<div css={tw`bg-red-500 py-2`}>
|
||||
<ContentContainer css={tw`flex items-center justify-center`}>
|
||||
{error === 'connecting' ? (
|
||||
<>
|
||||
<Spinner size={'small'} />
|
||||
<p css={tw`ml-2 text-sm text-red-100`}>
|
||||
We're having some trouble connecting to your server, please wait...
|
||||
</p>
|
||||
}
|
||||
</ContentContainer>
|
||||
</div>
|
||||
</CSSTransition>
|
||||
:
|
||||
null
|
||||
);
|
||||
</>
|
||||
) : (
|
||||
<p css={tw`ml-2 text-sm text-white`}>{error}</p>
|
||||
)}
|
||||
</ContentContainer>
|
||||
</div>
|
||||
</CSSTransition>
|
||||
) : null;
|
||||
};
|
||||
|
|
|
@ -16,7 +16,7 @@ const BackupContainer = () => {
|
|||
const { clearFlashes, clearAndAddHttpError } = useFlash();
|
||||
const { data: backups, error, isValidating } = getServerBackups();
|
||||
|
||||
const backupLimit = ServerContext.useStoreState(state => state.server.data!.featureLimits.backups);
|
||||
const backupLimit = ServerContext.useStoreState((state) => state.server.data!.featureLimits.backups);
|
||||
|
||||
useEffect(() => {
|
||||
if (!error) {
|
||||
|
@ -26,53 +26,49 @@ const BackupContainer = () => {
|
|||
}
|
||||
|
||||
clearAndAddHttpError({ error, key: 'backups' });
|
||||
}, [ error ]);
|
||||
}, [error]);
|
||||
|
||||
if (!backups || (error && isValidating)) {
|
||||
return <Spinner size={'large'} centered/>;
|
||||
return <Spinner size={'large'} centered />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ServerContentBlock title={'Backups'}>
|
||||
<FlashMessageRender byKey={'backups'} css={tw`mb-4`}/>
|
||||
<FlashMessageRender byKey={'backups'} css={tw`mb-4`} />
|
||||
<Pagination data={backups} onPageSelect={setPage}>
|
||||
{({ items }) => (
|
||||
!items.length ?
|
||||
{({ items }) =>
|
||||
!items.length ? (
|
||||
// Don't show any error messages if the server has no backups and the user cannot
|
||||
// create additional ones for the server.
|
||||
!backupLimit ?
|
||||
null
|
||||
:
|
||||
!backupLimit ? null : (
|
||||
<p css={tw`text-center text-sm text-neutral-300`}>
|
||||
{page > 1 ?
|
||||
'Looks like we\'ve run out of backups to show you, try going back a page.'
|
||||
:
|
||||
'It looks like there are no backups currently stored for this server.'
|
||||
}
|
||||
{page > 1
|
||||
? "Looks like we've run out of backups to show you, try going back a page."
|
||||
: 'It looks like there are no backups currently stored for this server.'}
|
||||
</p>
|
||||
:
|
||||
items.map((backup, index) => <BackupRow
|
||||
key={backup.uuid}
|
||||
backup={backup}
|
||||
css={index > 0 ? tw`mt-2` : undefined}
|
||||
/>)
|
||||
)}
|
||||
)
|
||||
) : (
|
||||
items.map((backup, index) => (
|
||||
<BackupRow key={backup.uuid} backup={backup} css={index > 0 ? tw`mt-2` : undefined} />
|
||||
))
|
||||
)
|
||||
}
|
||||
</Pagination>
|
||||
{backupLimit === 0 &&
|
||||
<p css={tw`text-center text-sm text-neutral-300`}>
|
||||
Backups cannot be created for this server because the backup limit is set to 0.
|
||||
</p>
|
||||
}
|
||||
{backupLimit === 0 && (
|
||||
<p css={tw`text-center text-sm text-neutral-300`}>
|
||||
Backups cannot be created for this server because the backup limit is set to 0.
|
||||
</p>
|
||||
)}
|
||||
<Can action={'backup.create'}>
|
||||
<div css={tw`mt-6 sm:flex items-center justify-end`}>
|
||||
{(backupLimit > 0 && backups.backupCount > 0) &&
|
||||
<p css={tw`text-sm text-neutral-300 mb-4 sm:mr-6 sm:mb-0`}>
|
||||
{backups.backupCount} of {backupLimit} backups have been created for this server.
|
||||
</p>
|
||||
}
|
||||
{backupLimit > 0 && backupLimit > backups.backupCount &&
|
||||
<CreateBackupButton css={tw`w-full sm:w-auto`}/>
|
||||
}
|
||||
{backupLimit > 0 && backups.backupCount > 0 && (
|
||||
<p css={tw`text-sm text-neutral-300 mb-4 sm:mr-6 sm:mb-0`}>
|
||||
{backups.backupCount} of {backupLimit} backups have been created for this server.
|
||||
</p>
|
||||
)}
|
||||
{backupLimit > 0 && backupLimit > backups.backupCount && (
|
||||
<CreateBackupButton css={tw`w-full sm:w-auto`} />
|
||||
)}
|
||||
</div>
|
||||
</Can>
|
||||
</ServerContentBlock>
|
||||
|
@ -80,10 +76,10 @@ const BackupContainer = () => {
|
|||
};
|
||||
|
||||
export default () => {
|
||||
const [ page, setPage ] = useState<number>(1);
|
||||
const [page, setPage] = useState<number>(1);
|
||||
return (
|
||||
<ServerBackupContext.Provider value={{ page, setPage }}>
|
||||
<BackupContainer/>
|
||||
<BackupContainer />
|
||||
</ServerBackupContext.Provider>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -28,11 +28,11 @@ interface Props {
|
|||
}
|
||||
|
||||
export default ({ backup }: Props) => {
|
||||
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
|
||||
const setServerFromState = ServerContext.useStoreActions(actions => actions.server.setServerFromState);
|
||||
const [ modal, setModal ] = useState('');
|
||||
const [ loading, setLoading ] = useState(false);
|
||||
const [ truncate, setTruncate ] = useState(false);
|
||||
const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid);
|
||||
const setServerFromState = ServerContext.useStoreActions((actions) => actions.server.setServerFromState);
|
||||
const [modal, setModal] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [truncate, setTruncate] = useState(false);
|
||||
const { clearFlashes, clearAndAddHttpError } = useFlash();
|
||||
const { mutate } = getServerBackups();
|
||||
|
||||
|
@ -40,11 +40,11 @@ export default ({ backup }: Props) => {
|
|||
setLoading(true);
|
||||
clearFlashes('backups');
|
||||
getBackupDownloadUrl(uuid, backup.uuid)
|
||||
.then(url => {
|
||||
.then((url) => {
|
||||
// @ts-ignore
|
||||
window.location = url;
|
||||
})
|
||||
.catch(error => {
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
clearAndAddHttpError({ key: 'backups', error });
|
||||
})
|
||||
|
@ -55,12 +55,17 @@ export default ({ backup }: Props) => {
|
|||
setLoading(true);
|
||||
clearFlashes('backups');
|
||||
deleteBackup(uuid, backup.uuid)
|
||||
.then(() => mutate(data => ({
|
||||
...data,
|
||||
items: data.items.filter(b => b.uuid !== backup.uuid),
|
||||
backupCount: data.backupCount - 1,
|
||||
}), false))
|
||||
.catch(error => {
|
||||
.then(() =>
|
||||
mutate(
|
||||
(data) => ({
|
||||
...data,
|
||||
items: data.items.filter((b) => b.uuid !== backup.uuid),
|
||||
backupCount: data.backupCount - 1,
|
||||
}),
|
||||
false
|
||||
)
|
||||
)
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
clearAndAddHttpError({ key: 'backups', error });
|
||||
setLoading(false);
|
||||
|
@ -72,11 +77,13 @@ export default ({ backup }: Props) => {
|
|||
setLoading(true);
|
||||
clearFlashes('backups');
|
||||
restoreServerBackup(uuid, backup.uuid, truncate)
|
||||
.then(() => setServerFromState(s => ({
|
||||
...s,
|
||||
status: 'restoring_backup',
|
||||
})))
|
||||
.catch(error => {
|
||||
.then(() =>
|
||||
setServerFromState((s) => ({
|
||||
...s,
|
||||
status: 'restoring_backup',
|
||||
}))
|
||||
)
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
clearAndAddHttpError({ key: 'backups', error });
|
||||
})
|
||||
|
@ -90,14 +97,23 @@ export default ({ backup }: Props) => {
|
|||
}
|
||||
|
||||
http.post(`/api/client/servers/${uuid}/backups/${backup.uuid}/lock`)
|
||||
.then(() => mutate(data => ({
|
||||
...data,
|
||||
items: data.items.map(b => b.uuid !== backup.uuid ? b : {
|
||||
...b,
|
||||
isLocked: !b.isLocked,
|
||||
}),
|
||||
}), false))
|
||||
.catch(error => alert(httpErrorToHuman(error)))
|
||||
.then(() =>
|
||||
mutate(
|
||||
(data) => ({
|
||||
...data,
|
||||
items: data.items.map((b) =>
|
||||
b.uuid !== backup.uuid
|
||||
? b
|
||||
: {
|
||||
...b,
|
||||
isLocked: !b.isLocked,
|
||||
}
|
||||
),
|
||||
}),
|
||||
false
|
||||
)
|
||||
)
|
||||
.catch((error) => alert(httpErrorToHuman(error)))
|
||||
.then(() => setModal(''));
|
||||
};
|
||||
|
||||
|
@ -123,17 +139,14 @@ export default ({ backup }: Props) => {
|
|||
manager, or create additional backups until completed.
|
||||
</p>
|
||||
<p css={tw`mt-4 -mb-2 bg-gray-700 p-3 rounded`}>
|
||||
<label
|
||||
htmlFor={'restore_truncate'}
|
||||
css={tw`text-base flex items-center cursor-pointer`}
|
||||
>
|
||||
<label htmlFor={'restore_truncate'} css={tw`text-base flex items-center cursor-pointer`}>
|
||||
<Input
|
||||
type={'checkbox'}
|
||||
css={tw`text-red-500! w-5! h-5! mr-2`}
|
||||
id={'restore_truncate'}
|
||||
value={'true'}
|
||||
checked={truncate}
|
||||
onChange={() => setTruncate(s => !s)}
|
||||
onChange={() => setTruncate((s) => !s)}
|
||||
/>
|
||||
Delete all files before restoring backup.
|
||||
</label>
|
||||
|
@ -148,28 +161,28 @@ export default ({ backup }: Props) => {
|
|||
>
|
||||
This is a permanent operation. The backup cannot be recovered once deleted.
|
||||
</Dialog.Confirm>
|
||||
<SpinnerOverlay visible={loading} fixed/>
|
||||
{backup.isSuccessful ?
|
||||
<SpinnerOverlay visible={loading} fixed />
|
||||
{backup.isSuccessful ? (
|
||||
<DropdownMenu
|
||||
renderToggle={onClick => (
|
||||
renderToggle={(onClick) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
css={tw`text-gray-200 transition-colors duration-150 hover:text-gray-100 p-2`}
|
||||
>
|
||||
<FontAwesomeIcon icon={faEllipsisH}/>
|
||||
<FontAwesomeIcon icon={faEllipsisH} />
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
<div css={tw`text-sm`}>
|
||||
<Can action={'backup.download'}>
|
||||
<DropdownButtonRow onClick={doDownload}>
|
||||
<FontAwesomeIcon fixedWidth icon={faCloudDownloadAlt} css={tw`text-xs`}/>
|
||||
<FontAwesomeIcon fixedWidth icon={faCloudDownloadAlt} css={tw`text-xs`} />
|
||||
<span css={tw`ml-2`}>Download</span>
|
||||
</DropdownButtonRow>
|
||||
</Can>
|
||||
<Can action={'backup.restore'}>
|
||||
<DropdownButtonRow onClick={() => setModal('restore')}>
|
||||
<FontAwesomeIcon fixedWidth icon={faBoxOpen} css={tw`text-xs`}/>
|
||||
<FontAwesomeIcon fixedWidth icon={faBoxOpen} css={tw`text-xs`} />
|
||||
<span css={tw`ml-2`}>Restore</span>
|
||||
</DropdownButtonRow>
|
||||
</Can>
|
||||
|
@ -183,24 +196,24 @@ export default ({ backup }: Props) => {
|
|||
/>
|
||||
{backup.isLocked ? 'Unlock' : 'Lock'}
|
||||
</DropdownButtonRow>
|
||||
{!backup.isLocked &&
|
||||
<DropdownButtonRow danger onClick={() => setModal('delete')}>
|
||||
<FontAwesomeIcon fixedWidth icon={faTrashAlt} css={tw`text-xs`}/>
|
||||
<span css={tw`ml-2`}>Delete</span>
|
||||
</DropdownButtonRow>
|
||||
}
|
||||
{!backup.isLocked && (
|
||||
<DropdownButtonRow danger onClick={() => setModal('delete')}>
|
||||
<FontAwesomeIcon fixedWidth icon={faTrashAlt} css={tw`text-xs`} />
|
||||
<span css={tw`ml-2`}>Delete</span>
|
||||
</DropdownButtonRow>
|
||||
)}
|
||||
</>
|
||||
</Can>
|
||||
</div>
|
||||
</DropdownMenu>
|
||||
:
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setModal('delete')}
|
||||
css={tw`text-gray-200 transition-colors duration-150 hover:text-gray-100 p-2`}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrashAlt}/>
|
||||
<FontAwesomeIcon icon={faTrashAlt} />
|
||||
</button>
|
||||
}
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -21,20 +21,27 @@ interface Props {
|
|||
export default ({ backup, className }: Props) => {
|
||||
const { mutate } = getServerBackups();
|
||||
|
||||
useWebsocketEvent(`${SocketEvent.BACKUP_COMPLETED}:${backup.uuid}` as SocketEvent, data => {
|
||||
useWebsocketEvent(`${SocketEvent.BACKUP_COMPLETED}:${backup.uuid}` as SocketEvent, (data) => {
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
|
||||
mutate(data => ({
|
||||
...data,
|
||||
items: data.items.map(b => b.uuid !== backup.uuid ? b : ({
|
||||
...b,
|
||||
isSuccessful: parsed.is_successful || true,
|
||||
checksum: (parsed.checksum_type || '') + ':' + (parsed.checksum || ''),
|
||||
bytes: parsed.file_size || 0,
|
||||
completedAt: new Date(),
|
||||
})),
|
||||
}), false);
|
||||
mutate(
|
||||
(data) => ({
|
||||
...data,
|
||||
items: data.items.map((b) =>
|
||||
b.uuid !== backup.uuid
|
||||
? b
|
||||
: {
|
||||
...b,
|
||||
isSuccessful: parsed.is_successful || true,
|
||||
checksum: (parsed.checksum_type || '') + ':' + (parsed.checksum || ''),
|
||||
bytes: parsed.file_size || 0,
|
||||
completedAt: new Date(),
|
||||
}
|
||||
),
|
||||
}),
|
||||
false
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
}
|
||||
|
@ -44,52 +51,50 @@ export default ({ backup, className }: Props) => {
|
|||
<GreyRowBox css={tw`flex-wrap md:flex-nowrap items-center`} className={className}>
|
||||
<div css={tw`flex items-center truncate w-full md:flex-1`}>
|
||||
<div css={tw`mr-4`}>
|
||||
{backup.completedAt !== null ?
|
||||
backup.isLocked ?
|
||||
<FontAwesomeIcon icon={faLock} css={tw`text-yellow-500`}/>
|
||||
:
|
||||
<FontAwesomeIcon icon={faArchive} css={tw`text-neutral-300`}/>
|
||||
:
|
||||
<Spinner size={'small'}/>
|
||||
}
|
||||
{backup.completedAt !== null ? (
|
||||
backup.isLocked ? (
|
||||
<FontAwesomeIcon icon={faLock} css={tw`text-yellow-500`} />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faArchive} css={tw`text-neutral-300`} />
|
||||
)
|
||||
) : (
|
||||
<Spinner size={'small'} />
|
||||
)}
|
||||
</div>
|
||||
<div css={tw`flex flex-col truncate`}>
|
||||
<div css={tw`flex items-center text-sm mb-1`}>
|
||||
{backup.completedAt !== null && !backup.isSuccessful &&
|
||||
<span css={tw`bg-red-500 py-px px-2 rounded-full text-white text-xs uppercase border border-red-600 mr-2`}>
|
||||
Failed
|
||||
</span>
|
||||
}
|
||||
<p css={tw`break-words truncate`}>
|
||||
{backup.name}
|
||||
</p>
|
||||
{(backup.completedAt !== null && backup.isSuccessful) &&
|
||||
<span css={tw`ml-3 text-neutral-300 text-xs font-extralight hidden sm:inline`}>{bytesToString(backup.bytes)}</span>
|
||||
}
|
||||
{backup.completedAt !== null && !backup.isSuccessful && (
|
||||
<span
|
||||
css={tw`bg-red-500 py-px px-2 rounded-full text-white text-xs uppercase border border-red-600 mr-2`}
|
||||
>
|
||||
Failed
|
||||
</span>
|
||||
)}
|
||||
<p css={tw`break-words truncate`}>{backup.name}</p>
|
||||
{backup.completedAt !== null && backup.isSuccessful && (
|
||||
<span css={tw`ml-3 text-neutral-300 text-xs font-extralight hidden sm:inline`}>
|
||||
{bytesToString(backup.bytes)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p css={tw`mt-1 md:mt-0 text-xs text-neutral-400 font-mono truncate`}>
|
||||
{backup.checksum}
|
||||
</p>
|
||||
<p css={tw`mt-1 md:mt-0 text-xs text-neutral-400 font-mono truncate`}>{backup.checksum}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div css={tw`flex-1 md:flex-none md:w-48 mt-4 md:mt-0 md:ml-8 md:text-center`}>
|
||||
<p
|
||||
title={format(backup.createdAt, 'ddd, MMMM do, yyyy HH:mm:ss')}
|
||||
css={tw`text-sm`}
|
||||
>
|
||||
<p title={format(backup.createdAt, 'ddd, MMMM do, yyyy HH:mm:ss')} css={tw`text-sm`}>
|
||||
{formatDistanceToNow(backup.createdAt, { includeSeconds: true, addSuffix: true })}
|
||||
</p>
|
||||
<p css={tw`text-2xs text-neutral-500 uppercase mt-1`}>Created</p>
|
||||
</div>
|
||||
<Can action={[ 'backup.download', 'backup.restore', 'backup.delete' ]} matchAny>
|
||||
<Can action={['backup.download', 'backup.restore', 'backup.delete']} matchAny>
|
||||
<div css={tw`mt-4 md:mt-0 ml-6`} style={{ marginRight: '-0.5rem' }}>
|
||||
{!backup.completedAt ?
|
||||
{!backup.completedAt ? (
|
||||
<div css={tw`p-2 invisible`}>
|
||||
<FontAwesomeIcon icon={faEllipsisH}/>
|
||||
<FontAwesomeIcon icon={faEllipsisH} />
|
||||
</div>
|
||||
:
|
||||
<BackupContextMenu backup={backup}/>
|
||||
}
|
||||
) : (
|
||||
<BackupContextMenu backup={backup} />
|
||||
)}
|
||||
</div>
|
||||
</Can>
|
||||
</GreyRowBox>
|
||||
|
|
|
@ -27,7 +27,7 @@ const ModalContent = ({ ...props }: RequiredModalProps) => {
|
|||
return (
|
||||
<Modal {...props} showSpinnerOverlay={isSubmitting}>
|
||||
<Form>
|
||||
<FlashMessageRender byKey={'backups:create'} css={tw`mb-4`}/>
|
||||
<FlashMessageRender byKey={'backups:create'} css={tw`mb-4`} />
|
||||
<h2 css={tw`text-2xl mb-6`}>Create server backup</h2>
|
||||
<Field
|
||||
name={'name'}
|
||||
|
@ -45,7 +45,7 @@ const ModalContent = ({ ...props }: RequiredModalProps) => {
|
|||
prefixing the path with an exclamation point.
|
||||
`}
|
||||
>
|
||||
<FormikField as={Textarea} name={'ignored'} rows={6}/>
|
||||
<FormikField as={Textarea} name={'ignored'} rows={6} />
|
||||
</FormikFieldWrapper>
|
||||
</div>
|
||||
<Can action={'backup.delete'}>
|
||||
|
@ -68,23 +68,26 @@ const ModalContent = ({ ...props }: RequiredModalProps) => {
|
|||
};
|
||||
|
||||
export default () => {
|
||||
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
|
||||
const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid);
|
||||
const { clearFlashes, clearAndAddHttpError } = useFlash();
|
||||
const [ visible, setVisible ] = useState(false);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const { mutate } = getServerBackups();
|
||||
|
||||
useEffect(() => {
|
||||
clearFlashes('backups:create');
|
||||
}, [ visible ]);
|
||||
}, [visible]);
|
||||
|
||||
const submit = (values: Values, { setSubmitting }: FormikHelpers<Values>) => {
|
||||
clearFlashes('backups:create');
|
||||
createServerBackup(uuid, values)
|
||||
.then(backup => {
|
||||
mutate(data => ({ ...data, items: data.items.concat(backup), backupCount: data.backupCount + 1 }), false);
|
||||
.then((backup) => {
|
||||
mutate(
|
||||
(data) => ({ ...data, items: data.items.concat(backup), backupCount: data.backupCount + 1 }),
|
||||
false
|
||||
);
|
||||
setVisible(false);
|
||||
})
|
||||
.catch(error => {
|
||||
.catch((error) => {
|
||||
clearAndAddHttpError({ key: 'backups:create', error });
|
||||
setSubmitting(false);
|
||||
});
|
||||
|
@ -92,19 +95,19 @@ export default () => {
|
|||
|
||||
return (
|
||||
<>
|
||||
{visible &&
|
||||
<Formik
|
||||
onSubmit={submit}
|
||||
initialValues={{ name: '', ignored: '', isLocked: false }}
|
||||
validationSchema={object().shape({
|
||||
name: string().max(191),
|
||||
ignored: string(),
|
||||
isLocked: boolean(),
|
||||
})}
|
||||
>
|
||||
<ModalContent appear visible={visible} onDismissed={() => setVisible(false)}/>
|
||||
</Formik>
|
||||
}
|
||||
{visible && (
|
||||
<Formik
|
||||
onSubmit={submit}
|
||||
initialValues={{ name: '', ignored: '', isLocked: false }}
|
||||
validationSchema={object().shape({
|
||||
name: string().max(191),
|
||||
ignored: string(),
|
||||
isLocked: boolean(),
|
||||
})}
|
||||
>
|
||||
<ModalContent appear visible={visible} onDismissed={() => setVisible(false)} />
|
||||
</Formik>
|
||||
)}
|
||||
<Button css={tw`w-full sm:w-auto`} onClick={() => setVisible(true)}>
|
||||
Create backup
|
||||
</Button>
|
||||
|
|
|
@ -11,17 +11,9 @@ interface ChartBlockProps {
|
|||
export default ({ title, legend, children }: ChartBlockProps) => (
|
||||
<div className={classNames(styles.chart_container, 'group')}>
|
||||
<div className={'flex items-center justify-between px-4 py-2'}>
|
||||
<h3 className={'font-header transition-colors duration-100 group-hover:text-gray-50'}>
|
||||
{title}
|
||||
</h3>
|
||||
{legend &&
|
||||
<p className={'text-sm flex items-center'}>
|
||||
{legend}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
<div className={'z-10 ml-2'}>
|
||||
{children}
|
||||
<h3 className={'font-header transition-colors duration-100 group-hover:text-gray-50'}>{title}</h3>
|
||||
{legend && <p className={'text-sm flex items-center'}>{legend}</p>}
|
||||
</div>
|
||||
<div className={'z-10 ml-2'}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -59,16 +59,15 @@ export default () => {
|
|||
const searchBar = new SearchBarAddon({ searchAddon });
|
||||
const webLinksAddon = new WebLinksAddon();
|
||||
const scrollDownHelperAddon = new ScrollDownHelperAddon();
|
||||
const { connected, instance } = ServerContext.useStoreState(state => state.socket);
|
||||
const [ canSendCommands ] = usePermissions([ 'control.console' ]);
|
||||
const serverId = ServerContext.useStoreState(state => state.server.data!.id);
|
||||
const isTransferring = ServerContext.useStoreState(state => state.server.data!.isTransferring);
|
||||
const [ history, setHistory ] = usePersistedState<string[]>(`${serverId}:command_history`, []);
|
||||
const [ historyIndex, setHistoryIndex ] = useState(-1);
|
||||
const { connected, instance } = ServerContext.useStoreState((state) => state.socket);
|
||||
const [canSendCommands] = usePermissions(['control.console']);
|
||||
const serverId = ServerContext.useStoreState((state) => state.server.data!.id);
|
||||
const isTransferring = ServerContext.useStoreState((state) => state.server.data!.isTransferring);
|
||||
const [history, setHistory] = usePersistedState<string[]>(`${serverId}:command_history`, []);
|
||||
const [historyIndex, setHistoryIndex] = useState(-1);
|
||||
|
||||
const handleConsoleOutput = (line: string, prelude = false) => terminal.writeln(
|
||||
(prelude ? TERMINAL_PRELUDE : '') + line.replace(/(?:\r\n|\r|\n)$/im, '') + '\u001b[0m',
|
||||
);
|
||||
const handleConsoleOutput = (line: string, prelude = false) =>
|
||||
terminal.writeln((prelude ? TERMINAL_PRELUDE : '') + line.replace(/(?:\r\n|\r|\n)$/im, '') + '\u001b[0m');
|
||||
|
||||
const handleTransferStatus = (status: string) => {
|
||||
switch (status) {
|
||||
|
@ -79,17 +78,20 @@ export default () => {
|
|||
|
||||
// Sent by the source node whenever the server was archived successfully.
|
||||
case 'archive':
|
||||
terminal.writeln(TERMINAL_PRELUDE + 'Server has been archived successfully, attempting connection to target node..\u001b[0m');
|
||||
terminal.writeln(
|
||||
TERMINAL_PRELUDE +
|
||||
'Server has been archived successfully, attempting connection to target node..\u001b[0m'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDaemonErrorOutput = (line: string) => terminal.writeln(
|
||||
TERMINAL_PRELUDE + '\u001b[1m\u001b[41m' + line.replace(/(?:\r\n|\r|\n)$/im, '') + '\u001b[0m',
|
||||
);
|
||||
const handleDaemonErrorOutput = (line: string) =>
|
||||
terminal.writeln(
|
||||
TERMINAL_PRELUDE + '\u001b[1m\u001b[41m' + line.replace(/(?:\r\n|\r|\n)$/im, '') + '\u001b[0m'
|
||||
);
|
||||
|
||||
const handlePowerChangeEvent = (state: string) => terminal.writeln(
|
||||
TERMINAL_PRELUDE + 'Server marked as ' + state + '...\u001b[0m',
|
||||
);
|
||||
const handlePowerChangeEvent = (state: string) =>
|
||||
terminal.writeln(TERMINAL_PRELUDE + 'Server marked as ' + state + '...\u001b[0m');
|
||||
|
||||
const handleCommandKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'ArrowUp') {
|
||||
|
@ -112,7 +114,7 @@ export default () => {
|
|||
|
||||
const command = e.currentTarget.value;
|
||||
if (e.key === 'Enter' && command.length > 0) {
|
||||
setHistory(prevHistory => [ command, ...prevHistory! ].slice(0, 32));
|
||||
setHistory((prevHistory) => [command, ...prevHistory!].slice(0, 32));
|
||||
setHistoryIndex(-1);
|
||||
|
||||
instance && instance.send('send command', command);
|
||||
|
@ -146,13 +148,16 @@ export default () => {
|
|||
return true;
|
||||
});
|
||||
}
|
||||
}, [ terminal, connected ]);
|
||||
}, [terminal, connected]);
|
||||
|
||||
useEventListener('resize', debounce(() => {
|
||||
if (terminal.element) {
|
||||
fitAddon.fit();
|
||||
}
|
||||
}, 100));
|
||||
useEventListener(
|
||||
'resize',
|
||||
debounce(() => {
|
||||
if (terminal.element) {
|
||||
fitAddon.fit();
|
||||
}
|
||||
}, 100)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const listeners: Record<string, (s: string) => void> = {
|
||||
|
@ -161,7 +166,7 @@ export default () => {
|
|||
[SocketEvent.INSTALL_OUTPUT]: handleConsoleOutput,
|
||||
[SocketEvent.TRANSFER_LOGS]: handleConsoleOutput,
|
||||
[SocketEvent.TRANSFER_STATUS]: handleTransferStatus,
|
||||
[SocketEvent.DAEMON_MESSAGE]: line => handleConsoleOutput(line, true),
|
||||
[SocketEvent.DAEMON_MESSAGE]: (line) => handleConsoleOutput(line, true),
|
||||
[SocketEvent.DAEMON_ERROR]: handleDaemonErrorOutput,
|
||||
};
|
||||
|
||||
|
@ -184,15 +189,17 @@ export default () => {
|
|||
});
|
||||
}
|
||||
};
|
||||
}, [ connected, instance ]);
|
||||
}, [connected, instance]);
|
||||
|
||||
return (
|
||||
<div className={styles.terminal}>
|
||||
<SpinnerOverlay visible={!connected} size={'large'}/>
|
||||
<div className={classNames(styles.container, styles.overflows_container, { 'rounded-b': !canSendCommands })}>
|
||||
<div id={styles.terminal} ref={ref}/>
|
||||
<SpinnerOverlay visible={!connected} size={'large'} />
|
||||
<div
|
||||
className={classNames(styles.container, styles.overflows_container, { 'rounded-b': !canSendCommands })}
|
||||
>
|
||||
<div id={styles.terminal} ref={ref} />
|
||||
</div>
|
||||
{canSendCommands &&
|
||||
{canSendCommands && (
|
||||
<div className={classNames('relative', styles.overflows_container)}>
|
||||
<input
|
||||
className={classNames('peer', styles.command_input)}
|
||||
|
@ -204,11 +211,16 @@ export default () => {
|
|||
autoCorrect={'off'}
|
||||
autoCapitalize={'none'}
|
||||
/>
|
||||
<div className={classNames('text-gray-100 peer-focus:text-gray-50 peer-focus:animate-pulse', styles.command_icon)}>
|
||||
<ChevronDoubleRightIcon className={'w-4 h-4'}/>
|
||||
<div
|
||||
className={classNames(
|
||||
'text-gray-100 peer-focus:text-gray-50 peer-focus:animate-pulse',
|
||||
styles.command_icon
|
||||
)}
|
||||
>
|
||||
<ChevronDoubleRightIcon className={'w-4 h-4'} />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -10,12 +10,15 @@ interface PowerButtonProps {
|
|||
}
|
||||
|
||||
export default ({ className }: PowerButtonProps) => {
|
||||
const [ open, setOpen ] = useState(false);
|
||||
const status = ServerContext.useStoreState(state => state.status.value);
|
||||
const instance = ServerContext.useStoreState(state => state.socket.instance);
|
||||
const [open, setOpen] = useState(false);
|
||||
const status = ServerContext.useStoreState((state) => state.status.value);
|
||||
const instance = ServerContext.useStoreState((state) => state.socket.instance);
|
||||
|
||||
const killable = status === 'stopping';
|
||||
const onButtonClick = (action: PowerAction | 'kill-confirmed', e: React.MouseEvent<HTMLButtonElement, MouseEvent>): void => {
|
||||
const onButtonClick = (
|
||||
action: PowerAction | 'kill-confirmed',
|
||||
e: React.MouseEvent<HTMLButtonElement, MouseEvent>
|
||||
): void => {
|
||||
e.preventDefault();
|
||||
if (action === 'kill') {
|
||||
return setOpen(true);
|
||||
|
@ -31,7 +34,7 @@ export default ({ className }: PowerButtonProps) => {
|
|||
if (status === 'offline') {
|
||||
setOpen(false);
|
||||
}
|
||||
}, [ status ]);
|
||||
}, [status]);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
|
|
|
@ -15,11 +15,11 @@ import ServerDetailsBlock from '@/components/server/console/ServerDetailsBlock';
|
|||
export type PowerAction = 'start' | 'stop' | 'restart' | 'kill';
|
||||
|
||||
const ServerConsoleContainer = () => {
|
||||
const name = ServerContext.useStoreState(state => state.server.data!.name);
|
||||
const description = ServerContext.useStoreState(state => state.server.data!.description);
|
||||
const isInstalling = ServerContext.useStoreState(state => state.server.data!.isInstalling);
|
||||
const isTransferring = ServerContext.useStoreState(state => state.server.data!.isTransferring);
|
||||
const eggFeatures = ServerContext.useStoreState(state => state.server.data!.eggFeatures, isEqual);
|
||||
const name = ServerContext.useStoreState((state) => state.server.data!.name);
|
||||
const description = ServerContext.useStoreState((state) => state.server.data!.description);
|
||||
const isInstalling = ServerContext.useStoreState((state) => state.server.data!.isInstalling);
|
||||
const isTransferring = ServerContext.useStoreState((state) => state.server.data!.isTransferring);
|
||||
const eggFeatures = ServerContext.useStoreState((state) => state.server.data!.eggFeatures, isEqual);
|
||||
|
||||
return (
|
||||
<ServerContentBlock title={'Console'} className={'flex flex-col gap-2 sm:gap-4'}>
|
||||
|
@ -29,19 +29,19 @@ const ServerConsoleContainer = () => {
|
|||
<p className={'text-sm line-clamp-2'}>{description}</p>
|
||||
</div>
|
||||
<div className={'flex-1'}>
|
||||
<Can action={[ 'control.start', 'control.stop', 'control.restart' ]} matchAny>
|
||||
<PowerButtons className={'flex sm:justify-end space-x-2'}/>
|
||||
<Can action={['control.start', 'control.stop', 'control.restart']} matchAny>
|
||||
<PowerButtons className={'flex sm:justify-end space-x-2'} />
|
||||
</Can>
|
||||
</div>
|
||||
</div>
|
||||
<div className={'grid grid-cols-4 gap-2 sm:gap-4'}>
|
||||
<ServerDetailsBlock className={'col-span-4 lg:col-span-1 order-last lg:order-none'}/>
|
||||
<ServerDetailsBlock className={'col-span-4 lg:col-span-1 order-last lg:order-none'} />
|
||||
<div className={'col-span-4 lg:col-span-3'}>
|
||||
<Spinner.Suspense>
|
||||
<Console/>
|
||||
<Console />
|
||||
</Spinner.Suspense>
|
||||
</div>
|
||||
{isInstalling ?
|
||||
{isInstalling ? (
|
||||
<div css={tw`mt-4 rounded bg-yellow-500 p-3`}>
|
||||
<ContentContainer>
|
||||
<p css={tw`text-sm text-yellow-900`}>
|
||||
|
@ -50,26 +50,23 @@ const ServerConsoleContainer = () => {
|
|||
</p>
|
||||
</ContentContainer>
|
||||
</div>
|
||||
:
|
||||
isTransferring ?
|
||||
<div css={tw`mt-4 rounded bg-yellow-500 p-3`}>
|
||||
<ContentContainer>
|
||||
<p css={tw`text-sm text-yellow-900`}>
|
||||
This server is currently being transferred to another node and all actions
|
||||
are unavailable.
|
||||
</p>
|
||||
</ContentContainer>
|
||||
</div>
|
||||
:
|
||||
null
|
||||
}
|
||||
) : isTransferring ? (
|
||||
<div css={tw`mt-4 rounded bg-yellow-500 p-3`}>
|
||||
<ContentContainer>
|
||||
<p css={tw`text-sm text-yellow-900`}>
|
||||
This server is currently being transferred to another node and all actions are
|
||||
unavailable.
|
||||
</p>
|
||||
</ContentContainer>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={'grid grid-cols-1 md:grid-cols-3 gap-2 sm:gap-4'}>
|
||||
<Spinner.Suspense>
|
||||
<StatGraphs/>
|
||||
<StatGraphs />
|
||||
</Spinner.Suspense>
|
||||
</div>
|
||||
<Features enabled={eggFeatures}/>
|
||||
<Features enabled={eggFeatures} />
|
||||
</ServerContentBlock>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -19,7 +19,7 @@ import classNames from 'classnames';
|
|||
type Stats = Record<'memory' | 'cpu' | 'disk' | 'uptime' | 'rx' | 'tx', number>;
|
||||
|
||||
const getBackgroundColor = (value: number, max: number | null): string | undefined => {
|
||||
const delta = !max ? 0 : (value / max);
|
||||
const delta = !max ? 0 : value / max;
|
||||
|
||||
if (delta > 0.8) {
|
||||
if (delta > 0.9) {
|
||||
|
@ -32,14 +32,14 @@ const getBackgroundColor = (value: number, max: number | null): string | undefin
|
|||
};
|
||||
|
||||
const ServerDetailsBlock = ({ className }: { className?: string }) => {
|
||||
const [ stats, setStats ] = useState<Stats>({ memory: 0, cpu: 0, disk: 0, uptime: 0, tx: 0, rx: 0 });
|
||||
const [stats, setStats] = useState<Stats>({ memory: 0, cpu: 0, disk: 0, uptime: 0, tx: 0, rx: 0 });
|
||||
|
||||
const status = ServerContext.useStoreState(state => state.status.value);
|
||||
const connected = ServerContext.useStoreState(state => state.socket.connected);
|
||||
const instance = ServerContext.useStoreState(state => state.socket.instance);
|
||||
const limits = ServerContext.useStoreState(state => state.server.data!.limits);
|
||||
const allocation = ServerContext.useStoreState(state => {
|
||||
const match = state.server.data!.allocations.find(allocation => allocation.isDefault);
|
||||
const status = ServerContext.useStoreState((state) => state.status.value);
|
||||
const connected = ServerContext.useStoreState((state) => state.socket.connected);
|
||||
const instance = ServerContext.useStoreState((state) => state.socket.instance);
|
||||
const limits = ServerContext.useStoreState((state) => state.server.data!.limits);
|
||||
const allocation = ServerContext.useStoreState((state) => {
|
||||
const match = state.server.data!.allocations.find((allocation) => allocation.isDefault);
|
||||
|
||||
return !match ? 'n/a' : `${match.alias || ip(match.ip)}:${match.port}`;
|
||||
});
|
||||
|
@ -50,7 +50,7 @@ const ServerDetailsBlock = ({ className }: { className?: string }) => {
|
|||
}
|
||||
|
||||
instance.send(SocketRequest.SEND_STATS);
|
||||
}, [ instance, connected ]);
|
||||
}, [instance, connected]);
|
||||
|
||||
useWebsocketEvent(SocketEvent.STATS, (data) => {
|
||||
let stats: any = {};
|
||||
|
@ -78,51 +78,42 @@ const ServerDetailsBlock = ({ className }: { className?: string }) => {
|
|||
<StatBlock
|
||||
icon={faClock}
|
||||
title={'Uptime'}
|
||||
color={getBackgroundColor(status === 'running' ? 0 : (status !== 'offline' ? 9 : 10), 10)}
|
||||
color={getBackgroundColor(status === 'running' ? 0 : status !== 'offline' ? 9 : 10, 10)}
|
||||
>
|
||||
{stats.uptime > 0 ?
|
||||
<UptimeDuration uptime={stats.uptime / 1000}/>
|
||||
:
|
||||
'Offline'
|
||||
}
|
||||
{stats.uptime > 0 ? <UptimeDuration uptime={stats.uptime / 1000} /> : 'Offline'}
|
||||
</StatBlock>
|
||||
<StatBlock
|
||||
icon={faMicrochip}
|
||||
title={'CPU Load'}
|
||||
color={getBackgroundColor(stats.cpu, limits.cpu)}
|
||||
description={limits.cpu
|
||||
? `This server is allowed to use up to ${limits.cpu}% of the host's available CPU resources.`
|
||||
: 'No CPU limit has been configured for this server.'
|
||||
description={
|
||||
limits.cpu
|
||||
? `This server is allowed to use up to ${limits.cpu}% of the host's available CPU resources.`
|
||||
: 'No CPU limit has been configured for this server.'
|
||||
}
|
||||
>
|
||||
{status === 'offline' ?
|
||||
<span className={'text-gray-400'}>Offline</span>
|
||||
:
|
||||
`${stats.cpu.toFixed(2)}%`
|
||||
}
|
||||
{status === 'offline' ? <span className={'text-gray-400'}>Offline</span> : `${stats.cpu.toFixed(2)}%`}
|
||||
</StatBlock>
|
||||
<StatBlock
|
||||
icon={faMemory}
|
||||
title={'Memory'}
|
||||
color={getBackgroundColor(stats.memory / 1024, limits.memory * 1024)}
|
||||
description={limits.memory
|
||||
? `This server is allowed to use up to ${bytesToString(mbToBytes(limits.memory))} of memory.`
|
||||
: 'No memory limit has been configured for this server.'
|
||||
description={
|
||||
limits.memory
|
||||
? `This server is allowed to use up to ${bytesToString(mbToBytes(limits.memory))} of memory.`
|
||||
: 'No memory limit has been configured for this server.'
|
||||
}
|
||||
>
|
||||
{status === 'offline' ?
|
||||
<span className={'text-gray-400'}>Offline</span>
|
||||
:
|
||||
bytesToString(stats.memory)
|
||||
}
|
||||
{status === 'offline' ? <span className={'text-gray-400'}>Offline</span> : bytesToString(stats.memory)}
|
||||
</StatBlock>
|
||||
<StatBlock
|
||||
icon={faHdd}
|
||||
title={'Disk'}
|
||||
color={getBackgroundColor(stats.disk / 1024, limits.disk * 1024)}
|
||||
description={limits.disk
|
||||
? `This server is allowed to use up to ${bytesToString(mbToBytes(limits.disk))} of disk space.`
|
||||
: 'No disk space limit has been configured for this server.'
|
||||
description={
|
||||
limits.disk
|
||||
? `This server is allowed to use up to ${bytesToString(mbToBytes(limits.disk))} of disk space.`
|
||||
: 'No disk space limit has been configured for this server.'
|
||||
}
|
||||
>
|
||||
{bytesToString(stats.disk)}
|
||||
|
@ -132,22 +123,16 @@ const ServerDetailsBlock = ({ className }: { className?: string }) => {
|
|||
title={'Network (Inbound)'}
|
||||
description={'The total amount of network traffic that your server has recieved since it was started.'}
|
||||
>
|
||||
{status === 'offline' ?
|
||||
<span className={'text-gray-400'}>Offline</span>
|
||||
:
|
||||
bytesToString(stats.tx)
|
||||
}
|
||||
{status === 'offline' ? <span className={'text-gray-400'}>Offline</span> : bytesToString(stats.tx)}
|
||||
</StatBlock>
|
||||
<StatBlock
|
||||
icon={faCloudUploadAlt}
|
||||
title={'Network (Outbound)'}
|
||||
description={'The total amount of traffic your server has sent across the internet since it was started.'}
|
||||
>
|
||||
{status === 'offline' ?
|
||||
<span className={'text-gray-400'}>Offline</span>
|
||||
:
|
||||
bytesToString(stats.rx)
|
||||
description={
|
||||
'The total amount of traffic your server has sent across the internet since it was started.'
|
||||
}
|
||||
>
|
||||
{status === 'offline' ? <span className={'text-gray-400'}>Offline</span> : bytesToString(stats.rx)}
|
||||
</StatBlock>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -21,7 +21,7 @@ export default ({ title, icon, color, description, className, children }: StatBl
|
|||
return (
|
||||
<Tooltip arrow placement={'top'} disabled={!description} content={description || ''}>
|
||||
<div className={classNames(styles.stat_block, 'bg-gray-600', className)}>
|
||||
<div className={classNames(styles.status_bar, color || 'bg-gray-700')}/>
|
||||
<div className={classNames(styles.status_bar, color || 'bg-gray-700')} />
|
||||
<div className={classNames(styles.icon, color || 'bg-gray-700')}>
|
||||
<Icon
|
||||
icon={icon}
|
||||
|
|
|
@ -12,8 +12,8 @@ import ChartBlock from '@/components/server/console/ChartBlock';
|
|||
import Tooltip from '@/components/elements/tooltip/Tooltip';
|
||||
|
||||
export default () => {
|
||||
const status = ServerContext.useStoreState(state => state.status.value);
|
||||
const limits = ServerContext.useStoreState(state => state.server.data!.limits);
|
||||
const status = ServerContext.useStoreState((state) => state.status.value);
|
||||
const limits = ServerContext.useStoreState((state) => state.server.data!.limits);
|
||||
const previous = useRef<Record<'tx' | 'rx', number>>({ tx: -1, rx: -1 });
|
||||
|
||||
const cpu = useChartTickLabel('CPU', limits.cpu, '%');
|
||||
|
@ -24,14 +24,14 @@ export default () => {
|
|||
scales: {
|
||||
y: {
|
||||
ticks: {
|
||||
callback (value) {
|
||||
callback(value) {
|
||||
return bytesToString(typeof value === 'string' ? parseInt(value, 10) : value);
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
callback (opts, index) {
|
||||
callback(opts, index) {
|
||||
return {
|
||||
...opts,
|
||||
label: !index ? 'Network In' : 'Network Out',
|
||||
|
@ -47,7 +47,7 @@ export default () => {
|
|||
memory.clear();
|
||||
network.clear();
|
||||
}
|
||||
}, [ status ]);
|
||||
}, [status]);
|
||||
|
||||
useWebsocketEvent(SocketEvent.STATS, (data: string) => {
|
||||
let values: any = {};
|
||||
|
@ -70,25 +70,25 @@ export default () => {
|
|||
return (
|
||||
<>
|
||||
<ChartBlock title={'CPU Load'}>
|
||||
<Line {...cpu.props}/>
|
||||
<Line {...cpu.props} />
|
||||
</ChartBlock>
|
||||
<ChartBlock title={'Memory'}>
|
||||
<Line {...memory.props}/>
|
||||
<Line {...memory.props} />
|
||||
</ChartBlock>
|
||||
<ChartBlock
|
||||
title={'Network'}
|
||||
legend={
|
||||
<>
|
||||
<Tooltip arrow content={'Inbound'}>
|
||||
<CloudDownloadIcon className={'mr-2 w-4 h-4 text-yellow-400'}/>
|
||||
<CloudDownloadIcon className={'mr-2 w-4 h-4 text-yellow-400'} />
|
||||
</Tooltip>
|
||||
<Tooltip arrow content={'Outbound'}>
|
||||
<CloudUploadIcon className={'w-4 h-4 text-cyan-400'}/>
|
||||
<CloudUploadIcon className={'w-4 h-4 text-cyan-400'} />
|
||||
</Tooltip>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Line {...network.props}/>
|
||||
<Line {...network.props} />
|
||||
</ChartBlock>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -70,24 +70,33 @@ const options: ChartOptions<'line'> = {
|
|||
},
|
||||
};
|
||||
|
||||
function getOptions (opts?: DeepPartial<ChartOptions<'line'>> | undefined): ChartOptions<'line'> {
|
||||
function getOptions(opts?: DeepPartial<ChartOptions<'line'>> | undefined): ChartOptions<'line'> {
|
||||
return deepmerge(options, opts || {});
|
||||
}
|
||||
|
||||
type ChartDatasetCallback = (value: ChartDataset<'line'>, index: number) => ChartDataset<'line'>;
|
||||
|
||||
function getEmptyData (label: string, sets = 1, callback?: ChartDatasetCallback | undefined): ChartData<'line'> {
|
||||
const next = callback || (value => value);
|
||||
function getEmptyData(label: string, sets = 1, callback?: ChartDatasetCallback | undefined): ChartData<'line'> {
|
||||
const next = callback || ((value) => value);
|
||||
|
||||
return {
|
||||
labels: Array(20).fill(0).map((_, index) => index),
|
||||
datasets: Array(sets).fill(0).map((_, index) => next({
|
||||
fill: true,
|
||||
label,
|
||||
data: Array(20).fill(0),
|
||||
borderColor: theme('colors.cyan.400'),
|
||||
backgroundColor: hexToRgba(theme('colors.cyan.700'), 0.5),
|
||||
}, index)),
|
||||
labels: Array(20)
|
||||
.fill(0)
|
||||
.map((_, index) => index),
|
||||
datasets: Array(sets)
|
||||
.fill(0)
|
||||
.map((_, index) =>
|
||||
next(
|
||||
{
|
||||
fill: true,
|
||||
label,
|
||||
data: Array(20).fill(0),
|
||||
borderColor: theme('colors.cyan.400'),
|
||||
backgroundColor: hexToRgba(theme('colors.cyan.700'), 0.5),
|
||||
},
|
||||
index
|
||||
)
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -99,28 +108,36 @@ interface UseChartOptions {
|
|||
callback?: ChartDatasetCallback | undefined;
|
||||
}
|
||||
|
||||
function useChart (label: string, opts?: UseChartOptions) {
|
||||
const options = getOptions(typeof opts?.options === 'number' ? { scales: { y: { min: 0, suggestedMax: opts.options } } } : opts?.options);
|
||||
const [ data, setData ] = useState(getEmptyData(label, opts?.sets || 1, opts?.callback));
|
||||
function useChart(label: string, opts?: UseChartOptions) {
|
||||
const options = getOptions(
|
||||
typeof opts?.options === 'number' ? { scales: { y: { min: 0, suggestedMax: opts.options } } } : opts?.options
|
||||
);
|
||||
const [data, setData] = useState(getEmptyData(label, opts?.sets || 1, opts?.callback));
|
||||
|
||||
const push = (items: number | null | ((number | null)[])) => setData(state => merge(state, {
|
||||
datasets: (Array.isArray(items) ? items : [ items ]).map((item, index) => ({
|
||||
...state.datasets[index],
|
||||
data: state.datasets[index].data.slice(1).concat(item),
|
||||
})),
|
||||
}));
|
||||
const push = (items: number | null | (number | null)[]) =>
|
||||
setData((state) =>
|
||||
merge(state, {
|
||||
datasets: (Array.isArray(items) ? items : [items]).map((item, index) => ({
|
||||
...state.datasets[index],
|
||||
data: state.datasets[index].data.slice(1).concat(item),
|
||||
})),
|
||||
})
|
||||
);
|
||||
|
||||
const clear = () => setData(state => merge(state, {
|
||||
datasets: state.datasets.map(value => ({
|
||||
...value,
|
||||
data: Array(20).fill(0),
|
||||
})),
|
||||
}));
|
||||
const clear = () =>
|
||||
setData((state) =>
|
||||
merge(state, {
|
||||
datasets: state.datasets.map((value) => ({
|
||||
...value,
|
||||
data: Array(20).fill(0),
|
||||
})),
|
||||
})
|
||||
);
|
||||
|
||||
return { props: { data, options }, push, clear };
|
||||
}
|
||||
|
||||
function useChartTickLabel (label: string, max: number, tickLabel: string) {
|
||||
function useChartTickLabel(label: string, max: number, tickLabel: string) {
|
||||
return useChart(label, {
|
||||
sets: 1,
|
||||
options: {
|
||||
|
@ -128,7 +145,7 @@ function useChartTickLabel (label: string, max: number, tickLabel: string) {
|
|||
y: {
|
||||
suggestedMax: max,
|
||||
ticks: {
|
||||
callback (value) {
|
||||
callback(value) {
|
||||
return `${value}${tickLabel}`;
|
||||
},
|
||||
},
|
||||
|
|
|
@ -21,16 +21,19 @@ const schema = object().shape({
|
|||
.required('A database name must be provided.')
|
||||
.min(3, 'Database name must be at least 3 characters.')
|
||||
.max(48, 'Database name must not exceed 48 characters.')
|
||||
.matches(/^[\w\-.]{3,48}$/, 'Database name should only contain alphanumeric characters, underscores, dashes, and/or periods.'),
|
||||
.matches(
|
||||
/^[\w\-.]{3,48}$/,
|
||||
'Database name should only contain alphanumeric characters, underscores, dashes, and/or periods.'
|
||||
),
|
||||
connectionsFrom: string().matches(/^[\w\-/.%:]+$/, 'A valid host address must be provided.'),
|
||||
});
|
||||
|
||||
export default () => {
|
||||
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
|
||||
const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid);
|
||||
const { addError, clearFlashes } = useFlash();
|
||||
const [ visible, setVisible ] = useState(false);
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
const appendDatabase = ServerContext.useStoreActions(actions => actions.databases.appendDatabase);
|
||||
const appendDatabase = ServerContext.useStoreActions((actions) => actions.databases.appendDatabase);
|
||||
|
||||
const submit = (values: Values, { setSubmitting }: FormikHelpers<Values>) => {
|
||||
clearFlashes('database:create');
|
||||
|
@ -38,11 +41,11 @@ export default () => {
|
|||
databaseName: values.databaseName,
|
||||
connectionsFrom: values.connectionsFrom || '%',
|
||||
})
|
||||
.then(database => {
|
||||
.then((database) => {
|
||||
appendDatabase(database);
|
||||
setVisible(false);
|
||||
})
|
||||
.catch(error => {
|
||||
.catch((error) => {
|
||||
addError({ key: 'database:create', message: httpErrorToHuman(error) });
|
||||
setSubmitting(false);
|
||||
});
|
||||
|
@ -55,57 +58,55 @@ export default () => {
|
|||
initialValues={{ databaseName: '', connectionsFrom: '' }}
|
||||
validationSchema={schema}
|
||||
>
|
||||
{
|
||||
({ isSubmitting, resetForm }) => (
|
||||
<Modal
|
||||
visible={visible}
|
||||
dismissable={!isSubmitting}
|
||||
showSpinnerOverlay={isSubmitting}
|
||||
onDismissed={() => {
|
||||
resetForm();
|
||||
setVisible(false);
|
||||
}}
|
||||
>
|
||||
<FlashMessageRender byKey={'database:create'} css={tw`mb-6`}/>
|
||||
<h2 css={tw`text-2xl mb-6`}>Create new database</h2>
|
||||
<Form css={tw`m-0`}>
|
||||
{({ isSubmitting, resetForm }) => (
|
||||
<Modal
|
||||
visible={visible}
|
||||
dismissable={!isSubmitting}
|
||||
showSpinnerOverlay={isSubmitting}
|
||||
onDismissed={() => {
|
||||
resetForm();
|
||||
setVisible(false);
|
||||
}}
|
||||
>
|
||||
<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'}
|
||||
name={'databaseName'}
|
||||
label={'Database Name'}
|
||||
description={'A descriptive name for your database instance.'}
|
||||
/>
|
||||
<div css={tw`mt-6`}>
|
||||
<Field
|
||||
type={'string'}
|
||||
id={'database_name'}
|
||||
name={'databaseName'}
|
||||
label={'Database Name'}
|
||||
description={'A descriptive name for your database instance.'}
|
||||
id={'connections_from'}
|
||||
name={'connectionsFrom'}
|
||||
label={'Connections From'}
|
||||
description={
|
||||
'Where connections should be allowed from. Leave blank to allow connections from anywhere.'
|
||||
}
|
||||
/>
|
||||
<div css={tw`mt-6`}>
|
||||
<Field
|
||||
type={'string'}
|
||||
id={'connections_from'}
|
||||
name={'connectionsFrom'}
|
||||
label={'Connections From'}
|
||||
description={'Where connections should be allowed from. Leave blank to allow connections from anywhere.'}
|
||||
/>
|
||||
</div>
|
||||
<div css={tw`flex flex-wrap justify-end mt-6`}>
|
||||
<Button
|
||||
type={'button'}
|
||||
isSecondary
|
||||
css={tw`w-full sm:w-auto sm:mr-2`}
|
||||
onClick={() => setVisible(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button css={tw`w-full mt-4 sm:w-auto sm:mt-0`} type={'submit'}>
|
||||
Create Database
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<div css={tw`flex flex-wrap justify-end mt-6`}>
|
||||
<Button
|
||||
type={'button'}
|
||||
isSecondary
|
||||
css={tw`w-full sm:w-auto sm:mr-2`}
|
||||
onClick={() => setVisible(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button css={tw`w-full mt-4 sm:w-auto sm:mt-0`} type={'submit'}>
|
||||
Create Database
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
)}
|
||||
</Formik>
|
||||
<Button onClick={() => setVisible(true)}>
|
||||
New Database
|
||||
</Button>
|
||||
<Button onClick={() => setVisible(true)}>New Database</Button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -26,18 +26,18 @@ interface Props {
|
|||
}
|
||||
|
||||
export default ({ database, className }: Props) => {
|
||||
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
|
||||
const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid);
|
||||
const { addError, clearFlashes } = useFlash();
|
||||
const [ visible, setVisible ] = useState(false);
|
||||
const [ connectionVisible, setConnectionVisible ] = useState(false);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [connectionVisible, setConnectionVisible] = useState(false);
|
||||
|
||||
const appendDatabase = ServerContext.useStoreActions(actions => actions.databases.appendDatabase);
|
||||
const removeDatabase = ServerContext.useStoreActions(actions => actions.databases.removeDatabase);
|
||||
const appendDatabase = ServerContext.useStoreActions((actions) => actions.databases.appendDatabase);
|
||||
const removeDatabase = ServerContext.useStoreActions((actions) => actions.databases.removeDatabase);
|
||||
|
||||
const schema = object().shape({
|
||||
confirm: string()
|
||||
.required('The database name must be provided.')
|
||||
.oneOf([ database.name.split('_', 2)[1], database.name ], 'The database name must be provided.'),
|
||||
.oneOf([database.name.split('_', 2)[1], database.name], 'The database name must be provided.'),
|
||||
});
|
||||
|
||||
const submit = (values: { confirm: string }, { setSubmitting }: FormikHelpers<{ confirm: string }>) => {
|
||||
|
@ -47,7 +47,7 @@ export default ({ database, className }: Props) => {
|
|||
setVisible(false);
|
||||
setTimeout(() => removeDatabase(database.id), 150);
|
||||
})
|
||||
.catch(error => {
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
setSubmitting(false);
|
||||
addError({ key: 'database:delete', message: httpErrorToHuman(error) });
|
||||
|
@ -56,65 +56,51 @@ export default ({ database, className }: Props) => {
|
|||
|
||||
return (
|
||||
<>
|
||||
<Formik
|
||||
onSubmit={submit}
|
||||
initialValues={{ confirm: '' }}
|
||||
validationSchema={schema}
|
||||
isInitialValid={false}
|
||||
>
|
||||
{
|
||||
({ isSubmitting, isValid, resetForm }) => (
|
||||
<Modal
|
||||
visible={visible}
|
||||
dismissable={!isSubmitting}
|
||||
showSpinnerOverlay={isSubmitting}
|
||||
onDismissed={() => {
|
||||
setVisible(false);
|
||||
resetForm();
|
||||
}}
|
||||
>
|
||||
<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 permanently
|
||||
delete the <strong>{database.name}</strong> database and remove all associated data.
|
||||
</p>
|
||||
<Form css={tw`m-0 mt-6`}>
|
||||
<Field
|
||||
type={'text'}
|
||||
id={'confirm_name'}
|
||||
name={'confirm'}
|
||||
label={'Confirm Database Name'}
|
||||
description={'Enter the database name to confirm deletion.'}
|
||||
/>
|
||||
<div css={tw`mt-6 text-right`}>
|
||||
<Button
|
||||
type={'button'}
|
||||
isSecondary
|
||||
css={tw`mr-2`}
|
||||
onClick={() => setVisible(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type={'submit'}
|
||||
color={'red'}
|
||||
disabled={!isValid}
|
||||
>
|
||||
Delete Database
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
<Formik onSubmit={submit} initialValues={{ confirm: '' }} validationSchema={schema} isInitialValid={false}>
|
||||
{({ isSubmitting, isValid, resetForm }) => (
|
||||
<Modal
|
||||
visible={visible}
|
||||
dismissable={!isSubmitting}
|
||||
showSpinnerOverlay={isSubmitting}
|
||||
onDismissed={() => {
|
||||
setVisible(false);
|
||||
resetForm();
|
||||
}}
|
||||
>
|
||||
<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 permanently delete
|
||||
the <strong>{database.name}</strong> database and remove all associated data.
|
||||
</p>
|
||||
<Form css={tw`m-0 mt-6`}>
|
||||
<Field
|
||||
type={'text'}
|
||||
id={'confirm_name'}
|
||||
name={'confirm'}
|
||||
label={'Confirm Database Name'}
|
||||
description={'Enter the database name to confirm deletion.'}
|
||||
/>
|
||||
<div css={tw`mt-6 text-right`}>
|
||||
<Button type={'button'} isSecondary css={tw`mr-2`} onClick={() => setVisible(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type={'submit'} color={'red'} disabled={!isValid}>
|
||||
Delete Database
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
)}
|
||||
</Formik>
|
||||
<Modal visible={connectionVisible} onDismissed={() => setConnectionVisible(false)}>
|
||||
<FlashMessageRender byKey={'database-connection-modal'} css={tw`mb-6`}/>
|
||||
<FlashMessageRender byKey={'database-connection-modal'} css={tw`mb-6`} />
|
||||
<h3 css={tw`mb-6 text-2xl`}>Database connection details</h3>
|
||||
<div>
|
||||
<Label>Endpoint</Label>
|
||||
<CopyOnClick text={database.connectionString}><Input type={'text'} readOnly value={database.connectionString} /></CopyOnClick>
|
||||
<CopyOnClick text={database.connectionString}>
|
||||
<Input type={'text'} readOnly value={database.connectionString} />
|
||||
</CopyOnClick>
|
||||
</div>
|
||||
<div css={tw`mt-6`}>
|
||||
<Label>Connections from</Label>
|
||||
|
@ -122,17 +108,23 @@ export default ({ database, className }: Props) => {
|
|||
</div>
|
||||
<div css={tw`mt-6`}>
|
||||
<Label>Username</Label>
|
||||
<CopyOnClick text={database.username}><Input type={'text'} readOnly value={database.username} /></CopyOnClick>
|
||||
<CopyOnClick text={database.username}>
|
||||
<Input type={'text'} readOnly value={database.username} />
|
||||
</CopyOnClick>
|
||||
</div>
|
||||
<Can action={'database.view_password'}>
|
||||
<div css={tw`mt-6`}>
|
||||
<Label>Password</Label>
|
||||
<CopyOnClick text={database.password}><Input type={'text'} readOnly value={database.password}/></CopyOnClick>
|
||||
<CopyOnClick text={database.password}>
|
||||
<Input type={'text'} readOnly value={database.password} />
|
||||
</CopyOnClick>
|
||||
</div>
|
||||
</Can>
|
||||
<div css={tw`mt-6`}>
|
||||
<Label>JDBC Connection String</Label>
|
||||
<CopyOnClick text={`jdbc:mysql://${database.username}:${database.password}@${database.connectionString}/${database.name}`}>
|
||||
<CopyOnClick
|
||||
text={`jdbc:mysql://${database.username}:${database.password}@${database.connectionString}/${database.name}`}
|
||||
>
|
||||
<Input
|
||||
type={'text'}
|
||||
readOnly
|
||||
|
@ -142,7 +134,7 @@ export default ({ database, className }: Props) => {
|
|||
</div>
|
||||
<div css={tw`mt-6 text-right`}>
|
||||
<Can action={'database.update'}>
|
||||
<RotatePasswordButton databaseId={database.id} onUpdate={appendDatabase}/>
|
||||
<RotatePasswordButton databaseId={database.id} onUpdate={appendDatabase} />
|
||||
</Can>
|
||||
<Button isSecondary onClick={() => setConnectionVisible(false)}>
|
||||
Close
|
||||
|
@ -151,13 +143,17 @@ export default ({ database, className }: Props) => {
|
|||
</Modal>
|
||||
<GreyRowBox $hoverable={false} className={className} css={tw`mb-2`}>
|
||||
<div css={tw`hidden md:block`}>
|
||||
<FontAwesomeIcon icon={faDatabase} fixedWidth/>
|
||||
<FontAwesomeIcon icon={faDatabase} fixedWidth />
|
||||
</div>
|
||||
<div css={tw`flex-1 ml-4`}>
|
||||
<CopyOnClick text={database.name}><p css={tw`text-lg`}>{database.name}</p></CopyOnClick>
|
||||
<CopyOnClick text={database.name}>
|
||||
<p css={tw`text-lg`}>{database.name}</p>
|
||||
</CopyOnClick>
|
||||
</div>
|
||||
<div css={tw`ml-8 text-center hidden md:block`}>
|
||||
<CopyOnClick text={database.connectionString}><p css={tw`text-sm`}>{database.connectionString}</p></CopyOnClick>
|
||||
<CopyOnClick text={database.connectionString}>
|
||||
<p css={tw`text-sm`}>{database.connectionString}</p>
|
||||
</CopyOnClick>
|
||||
<p css={tw`mt-1 text-2xs text-neutral-500 uppercase select-none`}>Endpoint</p>
|
||||
</div>
|
||||
<div css={tw`ml-8 text-center hidden md:block`}>
|
||||
|
@ -165,16 +161,18 @@ export default ({ database, className }: Props) => {
|
|||
<p css={tw`mt-1 text-2xs text-neutral-500 uppercase select-none`}>Connections from</p>
|
||||
</div>
|
||||
<div css={tw`ml-8 text-center hidden md:block`}>
|
||||
<CopyOnClick text={database.username}><p css={tw`text-sm`}>{database.username}</p></CopyOnClick>
|
||||
<CopyOnClick text={database.username}>
|
||||
<p css={tw`text-sm`}>{database.username}</p>
|
||||
</CopyOnClick>
|
||||
<p css={tw`mt-1 text-2xs text-neutral-500 uppercase select-none`}>Username</p>
|
||||
</div>
|
||||
<div css={tw`ml-8`}>
|
||||
<Button isSecondary css={tw`mr-2`} onClick={() => setConnectionVisible(true)}>
|
||||
<FontAwesomeIcon icon={faEye} fixedWidth/>
|
||||
<FontAwesomeIcon icon={faEye} fixedWidth />
|
||||
</Button>
|
||||
<Can action={'database.delete'}>
|
||||
<Button color={'red'} isSecondary onClick={() => setVisible(true)}>
|
||||
<FontAwesomeIcon icon={faTrashAlt} fixedWidth/>
|
||||
<FontAwesomeIcon icon={faTrashAlt} fixedWidth />
|
||||
</Button>
|
||||
</Can>
|
||||
</div>
|
||||
|
|
|
@ -14,22 +14,22 @@ import ServerContentBlock from '@/components/elements/ServerContentBlock';
|
|||
import { useDeepMemoize } from '@/plugins/useDeepMemoize';
|
||||
|
||||
export default () => {
|
||||
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
|
||||
const databaseLimit = ServerContext.useStoreState(state => state.server.data!.featureLimits.databases);
|
||||
const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid);
|
||||
const databaseLimit = ServerContext.useStoreState((state) => state.server.data!.featureLimits.databases);
|
||||
|
||||
const { addError, clearFlashes } = useFlash();
|
||||
const [ loading, setLoading ] = useState(true);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const databases = useDeepMemoize(ServerContext.useStoreState(state => state.databases.data));
|
||||
const setDatabases = ServerContext.useStoreActions(state => state.databases.setDatabases);
|
||||
const databases = useDeepMemoize(ServerContext.useStoreState((state) => state.databases.data));
|
||||
const setDatabases = ServerContext.useStoreActions((state) => state.databases.setDatabases);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(!databases.length);
|
||||
clearFlashes('databases');
|
||||
|
||||
getServerDatabases(uuid)
|
||||
.then(databases => setDatabases(databases))
|
||||
.catch(error => {
|
||||
.then((databases) => setDatabases(databases))
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
addError({ key: 'databases', message: httpErrorToHuman(error) });
|
||||
})
|
||||
|
@ -38,13 +38,13 @@ export default () => {
|
|||
|
||||
return (
|
||||
<ServerContentBlock title={'Databases'}>
|
||||
<FlashMessageRender byKey={'databases'} css={tw`mb-4`}/>
|
||||
{(!databases.length && loading) ?
|
||||
<Spinner size={'large'} centered/>
|
||||
:
|
||||
<FlashMessageRender byKey={'databases'} css={tw`mb-4`} />
|
||||
{!databases.length && loading ? (
|
||||
<Spinner size={'large'} centered />
|
||||
) : (
|
||||
<Fade timeout={150}>
|
||||
<>
|
||||
{databases.length > 0 ?
|
||||
{databases.length > 0 ? (
|
||||
databases.map((database, index) => (
|
||||
<DatabaseRow
|
||||
key={database.id}
|
||||
|
@ -52,31 +52,29 @@ export default () => {
|
|||
className={index > 0 ? 'mt-1' : undefined}
|
||||
/>
|
||||
))
|
||||
:
|
||||
) : (
|
||||
<p css={tw`text-center text-sm text-neutral-300`}>
|
||||
{databaseLimit > 0 ?
|
||||
'It looks like you have no databases.'
|
||||
:
|
||||
'Databases cannot be created for this server.'
|
||||
}
|
||||
{databaseLimit > 0
|
||||
? 'It looks like you have no databases.'
|
||||
: 'Databases cannot be created for this server.'}
|
||||
</p>
|
||||
}
|
||||
)}
|
||||
<Can action={'database.create'}>
|
||||
<div css={tw`mt-6 flex items-center justify-end`}>
|
||||
{(databaseLimit > 0 && databases.length > 0) &&
|
||||
<p css={tw`text-sm text-neutral-300 mb-4 sm:mr-6 sm:mb-0`}>
|
||||
{databases.length} of {databaseLimit} databases have been allocated to this
|
||||
server.
|
||||
</p>
|
||||
}
|
||||
{databaseLimit > 0 && databaseLimit !== databases.length &&
|
||||
<CreateDatabaseButton css={tw`flex justify-end mt-6`}/>
|
||||
}
|
||||
{databaseLimit > 0 && databases.length > 0 && (
|
||||
<p css={tw`text-sm text-neutral-300 mb-4 sm:mr-6 sm:mb-0`}>
|
||||
{databases.length} of {databaseLimit} databases have been allocated to this
|
||||
server.
|
||||
</p>
|
||||
)}
|
||||
{databaseLimit > 0 && databaseLimit !== databases.length && (
|
||||
<CreateDatabaseButton css={tw`flex justify-end mt-6`} />
|
||||
)}
|
||||
</div>
|
||||
</Can>
|
||||
</>
|
||||
</Fade>
|
||||
}
|
||||
)}
|
||||
</ServerContentBlock>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -8,13 +8,10 @@ import { httpErrorToHuman } from '@/api/http';
|
|||
import Button from '@/components/elements/Button';
|
||||
import tw from 'twin.macro';
|
||||
|
||||
export default ({ databaseId, onUpdate }: {
|
||||
databaseId: string;
|
||||
onUpdate: (database: ServerDatabase) => void;
|
||||
}) => {
|
||||
const [ loading, setLoading ] = useState(false);
|
||||
export default ({ databaseId, onUpdate }: { databaseId: string; onUpdate: (database: ServerDatabase) => void }) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { addFlash, clearFlashes } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
|
||||
const server = ServerContext.useStoreState(state => state.server.data!);
|
||||
const server = ServerContext.useStoreState((state) => state.server.data!);
|
||||
|
||||
if (!databaseId) {
|
||||
return null;
|
||||
|
@ -25,8 +22,8 @@ export default ({ databaseId, onUpdate }: {
|
|||
clearFlashes();
|
||||
|
||||
rotateDatabasePassword(server.uuid, databaseId)
|
||||
.then(database => onUpdate(database))
|
||||
.catch(error => {
|
||||
.then((database) => onUpdate(database))
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
addFlash({
|
||||
type: 'error',
|
||||
|
|
|
@ -16,5 +16,5 @@ export enum SocketEvent {
|
|||
export enum SocketRequest {
|
||||
SEND_LOGS = 'send logs',
|
||||
SEND_STATS = 'send stats',
|
||||
SET_STATE = 'set state'
|
||||
SET_STATE = 'set state',
|
||||
}
|
||||
|
|
|
@ -2,18 +2,20 @@ import React, { useMemo } from 'react';
|
|||
import features from './index';
|
||||
import { getObjectKeys } from '@/lib/objects';
|
||||
|
||||
type ListItems = [ string, React.ComponentType ][];
|
||||
type ListItems = [string, React.ComponentType][];
|
||||
|
||||
export default ({ enabled }: { enabled: string[] }) => {
|
||||
const mapped: ListItems = useMemo(() => {
|
||||
return getObjectKeys(features)
|
||||
.filter(key => enabled.map((v) => v.toLowerCase()).includes(key.toLowerCase()))
|
||||
.reduce((arr, key) => [ ...arr, [ key, features[key] ] ], [] as ListItems);
|
||||
}, [ enabled ]);
|
||||
.filter((key) => enabled.map((v) => v.toLowerCase()).includes(key.toLowerCase()))
|
||||
.reduce((arr, key) => [...arr, [key, features[key]]], [] as ListItems);
|
||||
}, [enabled]);
|
||||
|
||||
return (
|
||||
<React.Suspense fallback={null}>
|
||||
{mapped.map(([ key, Component ]) => <Component key={key}/>)}
|
||||
{mapped.map(([key, Component]) => (
|
||||
<Component key={key} />
|
||||
))}
|
||||
</React.Suspense>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -15,24 +15,21 @@ interface Values {
|
|||
}
|
||||
|
||||
const GSLTokenModalFeature = () => {
|
||||
const [ visible, setVisible ] = useState(false);
|
||||
const [ loading, setLoading ] = useState(false);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
|
||||
const status = ServerContext.useStoreState(state => state.status.value);
|
||||
const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid);
|
||||
const status = ServerContext.useStoreState((state) => state.status.value);
|
||||
const { clearFlashes, clearAndAddHttpError } = useFlash();
|
||||
const { connected, instance } = ServerContext.useStoreState(state => state.socket);
|
||||
const { connected, instance } = ServerContext.useStoreState((state) => state.socket);
|
||||
|
||||
useEffect(() => {
|
||||
if (!connected || !instance || status === 'running') return;
|
||||
|
||||
const errors = [
|
||||
'(gsl token expired)',
|
||||
'(account not found)',
|
||||
];
|
||||
const errors = ['(gsl token expired)', '(account not found)'];
|
||||
|
||||
const listener = (line: string) => {
|
||||
if (errors.some(p => line.toLowerCase().includes(p))) {
|
||||
if (errors.some((p) => line.toLowerCase().includes(p))) {
|
||||
setVisible(true);
|
||||
}
|
||||
};
|
||||
|
@ -42,7 +39,7 @@ const GSLTokenModalFeature = () => {
|
|||
return () => {
|
||||
instance.removeListener(SocketEvent.CONSOLE_OUTPUT, listener);
|
||||
};
|
||||
}, [ connected, instance, status ]);
|
||||
}, [connected, instance, status]);
|
||||
|
||||
const updateGSLToken = (values: Values) => {
|
||||
setLoading(true);
|
||||
|
@ -57,7 +54,7 @@ const GSLTokenModalFeature = () => {
|
|||
setLoading(false);
|
||||
setVisible(false);
|
||||
})
|
||||
.catch(error => {
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
clearAndAddHttpError({ key: 'feature:gslToken', error });
|
||||
})
|
||||
|
@ -69,16 +66,23 @@ const GSLTokenModalFeature = () => {
|
|||
}, []);
|
||||
|
||||
return (
|
||||
<Formik
|
||||
onSubmit={updateGSLToken}
|
||||
initialValues={{ gslToken: '' }}
|
||||
>
|
||||
<Modal visible={visible} onDismissed={() => setVisible(false)} closeOnBackground={false} showSpinnerOverlay={loading}>
|
||||
<FlashMessageRender key={'feature:gslToken'} css={tw`mb-4`}/>
|
||||
<Formik onSubmit={updateGSLToken} initialValues={{ gslToken: '' }}>
|
||||
<Modal
|
||||
visible={visible}
|
||||
onDismissed={() => setVisible(false)}
|
||||
closeOnBackground={false}
|
||||
showSpinnerOverlay={loading}
|
||||
>
|
||||
<FlashMessageRender key={'feature:gslToken'} css={tw`mb-4`} />
|
||||
<Form>
|
||||
<h2 css={tw`text-2xl mb-4 text-neutral-100`}>Invalid GSL token!</h2>
|
||||
<p css={tw`mt-4`}>It seems like your Gameserver Login Token (GSL token) is invalid or has expired.</p>
|
||||
<p css={tw`mt-4`}>You can either generate a new one and enter it below or leave the field blank to remove it completely.</p>
|
||||
<p css={tw`mt-4`}>
|
||||
It seems like your Gameserver Login Token (GSL token) is invalid or has expired.
|
||||
</p>
|
||||
<p css={tw`mt-4`}>
|
||||
You can either generate a new one and enter it below or leave the field blank to remove it
|
||||
completely.
|
||||
</p>
|
||||
<div css={tw`sm:flex items-center mt-4`}>
|
||||
<Field
|
||||
name={'gslToken'}
|
||||
|
|
|
@ -22,14 +22,14 @@ const MATCH_ERRORS = [
|
|||
];
|
||||
|
||||
const JavaVersionModalFeature = () => {
|
||||
const [ visible, setVisible ] = useState(false);
|
||||
const [ loading, setLoading ] = useState(false);
|
||||
const [ selectedVersion, setSelectedVersion ] = useState('');
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedVersion, setSelectedVersion] = useState('');
|
||||
|
||||
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
|
||||
const status = ServerContext.useStoreState(state => state.status.value);
|
||||
const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid);
|
||||
const status = ServerContext.useStoreState((state) => state.status.value);
|
||||
const { clearFlashes, clearAndAddHttpError } = useFlash();
|
||||
const { instance } = ServerContext.useStoreState(state => state.socket);
|
||||
const { instance } = ServerContext.useStoreState((state) => state.socket);
|
||||
|
||||
const { data, isValidating, mutate } = getServerStartup(uuid, null, { revalidateOnMount: false });
|
||||
|
||||
|
@ -39,12 +39,12 @@ const JavaVersionModalFeature = () => {
|
|||
mutate().then((value) => {
|
||||
setSelectedVersion(Object.keys(value?.dockerImages || [])[0] || '');
|
||||
});
|
||||
}, [ visible ]);
|
||||
}, [visible]);
|
||||
|
||||
useWebsocketEvent(SocketEvent.CONSOLE_OUTPUT, (data) => {
|
||||
if (status === 'running') return;
|
||||
|
||||
if (MATCH_ERRORS.some(p => data.toLowerCase().includes(p.toLowerCase()))) {
|
||||
if (MATCH_ERRORS.some((p) => data.toLowerCase().includes(p.toLowerCase()))) {
|
||||
setVisible(true);
|
||||
}
|
||||
});
|
||||
|
@ -60,7 +60,7 @@ const JavaVersionModalFeature = () => {
|
|||
}
|
||||
setVisible(false);
|
||||
})
|
||||
.catch(error => clearAndAddHttpError({ key: 'feature:javaVersion', error }))
|
||||
.catch((error) => clearAndAddHttpError({ key: 'feature:javaVersion', error }))
|
||||
.then(() => setLoading(false));
|
||||
};
|
||||
|
||||
|
@ -75,7 +75,7 @@ const JavaVersionModalFeature = () => {
|
|||
closeOnBackground={false}
|
||||
showSpinnerOverlay={loading}
|
||||
>
|
||||
<FlashMessageRender key={'feature:javaVersion'} css={tw`mb-4`}/>
|
||||
<FlashMessageRender key={'feature:javaVersion'} css={tw`mb-4`} />
|
||||
<h2 css={tw`text-2xl mb-4 text-neutral-100`}>Unsupported Java Version</h2>
|
||||
<p css={tw`mt-4`}>
|
||||
This server is currently running an unsupported version of Java and cannot be started.
|
||||
|
@ -86,13 +86,16 @@ const JavaVersionModalFeature = () => {
|
|||
<Can action={'startup.docker-image'}>
|
||||
<div css={tw`mt-4`}>
|
||||
<InputSpinner visible={!data || isValidating}>
|
||||
<Select disabled={!data} onChange={e => setSelectedVersion(e.target.value)}>
|
||||
{!data
|
||||
? <option disabled/>
|
||||
: Object.keys((data.dockerImages)).map((key) => (
|
||||
<option key={key} value={data.dockerImages[key]}>{key}</option>
|
||||
<Select disabled={!data} onChange={(e) => setSelectedVersion(e.target.value)}>
|
||||
{!data ? (
|
||||
<option disabled />
|
||||
) : (
|
||||
Object.keys(data.dockerImages).map((key) => (
|
||||
<option key={key} value={data.dockerImages[key]}>
|
||||
{key}
|
||||
</option>
|
||||
))
|
||||
}
|
||||
)}
|
||||
</Select>
|
||||
</InputSpinner>
|
||||
</div>
|
||||
|
|
|
@ -11,13 +11,13 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|||
import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons';
|
||||
|
||||
const PIDLimitModalFeature = () => {
|
||||
const [ visible, setVisible ] = useState(false);
|
||||
const [ loading ] = useState(false);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [loading] = useState(false);
|
||||
|
||||
const status = ServerContext.useStoreState(state => state.status.value);
|
||||
const status = ServerContext.useStoreState((state) => state.status.value);
|
||||
const { clearFlashes } = useFlash();
|
||||
const { connected, instance } = ServerContext.useStoreState(state => state.socket);
|
||||
const isAdmin = useStoreState(state => state.user.data!.rootAdmin);
|
||||
const { connected, instance } = ServerContext.useStoreState((state) => state.socket);
|
||||
const isAdmin = useStoreState((state) => state.user.data!.rootAdmin);
|
||||
|
||||
useEffect(() => {
|
||||
if (!connected || !instance || status === 'running') return;
|
||||
|
@ -32,7 +32,7 @@ const PIDLimitModalFeature = () => {
|
|||
];
|
||||
|
||||
const listener = (line: string) => {
|
||||
if (errors.some(p => line.toLowerCase().includes(p))) {
|
||||
if (errors.some((p) => line.toLowerCase().includes(p))) {
|
||||
setVisible(true);
|
||||
}
|
||||
};
|
||||
|
@ -42,45 +42,63 @@ const PIDLimitModalFeature = () => {
|
|||
return () => {
|
||||
instance.removeListener(SocketEvent.CONSOLE_OUTPUT, listener);
|
||||
};
|
||||
}, [ connected, instance, status ]);
|
||||
}, [connected, instance, status]);
|
||||
|
||||
useEffect(() => {
|
||||
clearFlashes('feature:pidLimit');
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Modal visible={visible} onDismissed={() => setVisible(false)} closeOnBackground={false} showSpinnerOverlay={loading}>
|
||||
<Modal
|
||||
visible={visible}
|
||||
onDismissed={() => setVisible(false)}
|
||||
closeOnBackground={false}
|
||||
showSpinnerOverlay={loading}
|
||||
>
|
||||
<FlashMessageRender key={'feature:pidLimit'} css={tw`mb-4`} />
|
||||
{isAdmin ?
|
||||
{isAdmin ? (
|
||||
<>
|
||||
<div css={tw`mt-4 sm:flex items-center`}>
|
||||
<FontAwesomeIcon css={tw`pr-4`} icon={faExclamationTriangle} color={'orange'} size={'4x'}/>
|
||||
<FontAwesomeIcon css={tw`pr-4`} icon={faExclamationTriangle} color={'orange'} size={'4x'} />
|
||||
<h2 css={tw`text-2xl mb-4 text-neutral-100 `}>Memory or process limit reached...</h2>
|
||||
</div>
|
||||
<p css={tw`mt-4`}>This server has reached the maximum process or memory limit.</p>
|
||||
<p css={tw`mt-4`}>Increasing <code css={tw`font-mono bg-neutral-900`}>container_pid_limit</code> in the wings configuration, <code css={tw`font-mono bg-neutral-900`}>config.yml</code>, might help resolve this issue.</p>
|
||||
<p css={tw`mt-4`}><b>Note: Wings must be restarted for the configuration file changes to take effect</b></p>
|
||||
<p css={tw`mt-4`}>
|
||||
Increasing <code css={tw`font-mono bg-neutral-900`}>container_pid_limit</code> in the wings
|
||||
configuration, <code css={tw`font-mono bg-neutral-900`}>config.yml</code>, might help resolve
|
||||
this issue.
|
||||
</p>
|
||||
<p css={tw`mt-4`}>
|
||||
<b>Note: Wings must be restarted for the configuration file changes to take effect</b>
|
||||
</p>
|
||||
<div css={tw`mt-8 sm:flex items-center justify-end`}>
|
||||
<Button onClick={() => setVisible(false)} css={tw`w-full sm:w-auto border-transparent`}>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
:
|
||||
) : (
|
||||
<>
|
||||
<div css={tw`mt-4 sm:flex items-center`}>
|
||||
<FontAwesomeIcon css={tw`pr-4`} icon={faExclamationTriangle} color={'orange'} size={'4x'}/>
|
||||
<FontAwesomeIcon css={tw`pr-4`} icon={faExclamationTriangle} color={'orange'} size={'4x'} />
|
||||
<h2 css={tw`text-2xl mb-4 text-neutral-100`}>Possible resource limit reached...</h2>
|
||||
</div>
|
||||
<p css={tw`mt-4`}>This server is attempting to use more resources than allocated. Please contact the administrator and give them the error below.</p>
|
||||
<p css={tw`mt-4`}><code css={tw`font-mono bg-neutral-900`}>pthread_create failed, Possibly out of memory or process/resource limits reached</code></p>
|
||||
<p css={tw`mt-4`}>
|
||||
This server is attempting to use more resources than allocated. Please contact the administrator
|
||||
and give them the error below.
|
||||
</p>
|
||||
<p css={tw`mt-4`}>
|
||||
<code css={tw`font-mono bg-neutral-900`}>
|
||||
pthread_create failed, Possibly out of memory or process/resource limits reached
|
||||
</code>
|
||||
</p>
|
||||
<div css={tw`mt-8 sm:flex items-center justify-end`}>
|
||||
<Button onClick={() => setVisible(false)} css={tw`w-full sm:w-auto border-transparent`}>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -9,24 +9,21 @@ import { SocketEvent } from '@/components/server/events';
|
|||
import { useStoreState } from 'easy-peasy';
|
||||
|
||||
const SteamDiskSpaceFeature = () => {
|
||||
const [ visible, setVisible ] = useState(false);
|
||||
const [ loading ] = useState(false);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [loading] = useState(false);
|
||||
|
||||
const status = ServerContext.useStoreState(state => state.status.value);
|
||||
const status = ServerContext.useStoreState((state) => state.status.value);
|
||||
const { clearFlashes } = useFlash();
|
||||
const { connected, instance } = ServerContext.useStoreState(state => state.socket);
|
||||
const isAdmin = useStoreState(state => state.user.data!.rootAdmin);
|
||||
const { connected, instance } = ServerContext.useStoreState((state) => state.socket);
|
||||
const isAdmin = useStoreState((state) => state.user.data!.rootAdmin);
|
||||
|
||||
useEffect(() => {
|
||||
if (!connected || !instance || status === 'running') return;
|
||||
|
||||
const errors = [
|
||||
'steamcmd needs 250mb of free disk space to update',
|
||||
'0x202 after update job',
|
||||
];
|
||||
const errors = ['steamcmd needs 250mb of free disk space to update', '0x202 after update job'];
|
||||
|
||||
const listener = (line: string) => {
|
||||
if (errors.some(p => line.toLowerCase().includes(p))) {
|
||||
if (errors.some((p) => line.toLowerCase().includes(p))) {
|
||||
setVisible(true);
|
||||
}
|
||||
};
|
||||
|
@ -36,41 +33,56 @@ const SteamDiskSpaceFeature = () => {
|
|||
return () => {
|
||||
instance.removeListener(SocketEvent.CONSOLE_OUTPUT, listener);
|
||||
};
|
||||
}, [ connected, instance, status ]);
|
||||
}, [connected, instance, status]);
|
||||
|
||||
useEffect(() => {
|
||||
clearFlashes('feature:steamDiskSpace');
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Modal visible={visible} onDismissed={() => setVisible(false)} closeOnBackground={false} showSpinnerOverlay={loading}>
|
||||
<Modal
|
||||
visible={visible}
|
||||
onDismissed={() => setVisible(false)}
|
||||
closeOnBackground={false}
|
||||
showSpinnerOverlay={loading}
|
||||
>
|
||||
<FlashMessageRender key={'feature:steamDiskSpace'} css={tw`mb-4`} />
|
||||
{isAdmin ?
|
||||
{isAdmin ? (
|
||||
<>
|
||||
<div css={tw`mt-4 sm:flex items-center`}>
|
||||
<h2 css={tw`text-2xl mb-4 text-neutral-100 `}>Out of available disk space...</h2>
|
||||
</div>
|
||||
<p css={tw`mt-4`}>This server has run out of available disk space and cannot complete the install or update process.</p>
|
||||
<p css={tw`mt-4`}>Ensure the machine has enough disk space by typing <code css={tw`font-mono bg-neutral-900 rounded py-1 px-2`}>df -h</code> on the machine hosting this server. Delete files or increase the available disk space to resolve the issue.</p>
|
||||
<p css={tw`mt-4`}>
|
||||
This server has run out of available disk space and cannot complete the install or update
|
||||
process.
|
||||
</p>
|
||||
<p css={tw`mt-4`}>
|
||||
Ensure the machine has enough disk space by typing{' '}
|
||||
<code css={tw`font-mono bg-neutral-900 rounded py-1 px-2`}>df -h</code> on the machine hosting
|
||||
this server. Delete files or increase the available disk space to resolve the issue.
|
||||
</p>
|
||||
<div css={tw`mt-8 sm:flex items-center justify-end`}>
|
||||
<Button onClick={() => setVisible(false)} css={tw`w-full sm:w-auto border-transparent`}>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
:
|
||||
) : (
|
||||
<>
|
||||
<div css={tw`mt-4 sm:flex items-center`}>
|
||||
<h2 css={tw`text-2xl mb-4 text-neutral-100`}>Out of available disk space...</h2>
|
||||
</div>
|
||||
<p css={tw`mt-4`}>This server has run out of available disk space and cannot complete the install or update process. Please get in touch with the administrator(s) and inform them of disk space issues.</p>
|
||||
<p css={tw`mt-4`}>
|
||||
This server has run out of available disk space and cannot complete the install or update
|
||||
process. Please get in touch with the administrator(s) and inform them of disk space issues.
|
||||
</p>
|
||||
<div css={tw`mt-8 sm:flex items-center justify-end`}>
|
||||
<Button onClick={() => setVisible(false)} css={tw`w-full sm:w-auto border-transparent`}>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -9,13 +9,13 @@ import useFlash from '@/plugins/useFlash';
|
|||
import { SocketEvent, SocketRequest } from '@/components/server/events';
|
||||
|
||||
const EulaModalFeature = () => {
|
||||
const [ visible, setVisible ] = useState(false);
|
||||
const [ loading, setLoading ] = useState(false);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
|
||||
const status = ServerContext.useStoreState(state => state.status.value);
|
||||
const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid);
|
||||
const status = ServerContext.useStoreState((state) => state.status.value);
|
||||
const { clearFlashes, clearAndAddHttpError } = useFlash();
|
||||
const { connected, instance } = ServerContext.useStoreState(state => state.socket);
|
||||
const { connected, instance } = ServerContext.useStoreState((state) => state.socket);
|
||||
|
||||
useEffect(() => {
|
||||
if (!connected || !instance || status === 'running') return;
|
||||
|
@ -31,7 +31,7 @@ const EulaModalFeature = () => {
|
|||
return () => {
|
||||
instance.removeListener(SocketEvent.CONSOLE_OUTPUT, listener);
|
||||
};
|
||||
}, [ connected, instance, status ]);
|
||||
}, [connected, instance, status]);
|
||||
|
||||
const onAcceptEULA = () => {
|
||||
setLoading(true);
|
||||
|
@ -46,7 +46,7 @@ const EulaModalFeature = () => {
|
|||
setLoading(false);
|
||||
setVisible(false);
|
||||
})
|
||||
.catch(error => {
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
clearAndAddHttpError({ key: 'feature:eula', error });
|
||||
})
|
||||
|
@ -58,26 +58,32 @@ const EulaModalFeature = () => {
|
|||
}, []);
|
||||
|
||||
return (
|
||||
<Modal visible={visible} onDismissed={() => setVisible(false)} closeOnBackground={false} showSpinnerOverlay={loading}>
|
||||
<FlashMessageRender key={'feature:eula'} css={tw`mb-4`}/>
|
||||
<Modal
|
||||
visible={visible}
|
||||
onDismissed={() => setVisible(false)}
|
||||
closeOnBackground={false}
|
||||
showSpinnerOverlay={loading}
|
||||
>
|
||||
<FlashMessageRender key={'feature:eula'} css={tw`mb-4`} />
|
||||
<h2 css={tw`text-2xl mb-4 text-neutral-100`}>Accept Minecraft® EULA</h2>
|
||||
<p css={tw`text-neutral-200`}>
|
||||
By pressing {'"I Accept"'} below you are indicating your agreement to the
|
||||
By pressing {'"I Accept"'} below you are indicating your agreement to the
|
||||
<a
|
||||
target={'_blank'}
|
||||
css={tw`text-primary-300 underline transition-colors duration-150 hover:text-primary-400`}
|
||||
rel={'noreferrer noopener'}
|
||||
href="https://account.mojang.com/documents/minecraft_eula"
|
||||
href='https://account.mojang.com/documents/minecraft_eula'
|
||||
>
|
||||
Minecraft® EULA
|
||||
</a>.
|
||||
Minecraft® EULA
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
<div css={tw`mt-8 sm:flex items-center justify-end`}>
|
||||
<Button isSecondary onClick={() => setVisible(false)} css={tw`w-full sm:w-auto border-transparent`}>
|
||||
Cancel
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={onAcceptEULA} css={tw`mt-4 sm:mt-0 sm:ml-4 w-full sm:w-auto`}>
|
||||
I Accept
|
||||
I Accept
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
|
|
@ -15,30 +15,36 @@ interface FormikValues {
|
|||
}
|
||||
|
||||
interface File {
|
||||
file: string,
|
||||
mode: string,
|
||||
file: string;
|
||||
mode: string;
|
||||
}
|
||||
|
||||
type OwnProps = RequiredModalProps & { files: File[] };
|
||||
|
||||
const ChmodFileModal = ({ files, ...props }: OwnProps) => {
|
||||
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
|
||||
const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid);
|
||||
const { mutate } = useFileManagerSwr();
|
||||
const { clearFlashes, clearAndAddHttpError } = useFlash();
|
||||
const directory = ServerContext.useStoreState(state => state.files.directory);
|
||||
const setSelectedFiles = ServerContext.useStoreActions(actions => actions.files.setSelectedFiles);
|
||||
const directory = ServerContext.useStoreState((state) => state.files.directory);
|
||||
const setSelectedFiles = ServerContext.useStoreActions((actions) => actions.files.setSelectedFiles);
|
||||
|
||||
const submit = ({ mode }: FormikValues, { setSubmitting }: FormikHelpers<FormikValues>) => {
|
||||
clearFlashes('files');
|
||||
|
||||
mutate(data => data.map(f => f.name === files[0].file ? { ...f, mode: fileBitsToString(mode, !f.isFile), modeBits: mode } : f), false);
|
||||
mutate(
|
||||
(data) =>
|
||||
data.map((f) =>
|
||||
f.name === files[0].file ? { ...f, mode: fileBitsToString(mode, !f.isFile), modeBits: mode } : f
|
||||
),
|
||||
false
|
||||
);
|
||||
|
||||
const data = files.map(f => ({ file: f.file, mode: mode }));
|
||||
const data = files.map((f) => ({ file: f.file, mode: mode }));
|
||||
|
||||
chmodFiles(uuid, directory, data)
|
||||
.then((): Promise<any> => files.length > 0 ? mutate() : Promise.resolve())
|
||||
.then((): Promise<any> => (files.length > 0 ? mutate() : Promise.resolve()))
|
||||
.then(() => setSelectedFiles([]))
|
||||
.catch(error => {
|
||||
.catch((error) => {
|
||||
mutate();
|
||||
setSubmitting(false);
|
||||
clearAndAddHttpError({ key: 'files', error });
|
||||
|
@ -47,19 +53,13 @@ const ChmodFileModal = ({ files, ...props }: OwnProps) => {
|
|||
};
|
||||
|
||||
return (
|
||||
<Formik onSubmit={submit} initialValues={{ mode: files.length > 1 ? '' : (files[0].mode || '') }}>
|
||||
<Formik onSubmit={submit} initialValues={{ mode: files.length > 1 ? '' : files[0].mode || '' }}>
|
||||
{({ isSubmitting }) => (
|
||||
<Modal {...props} dismissable={!isSubmitting} showSpinnerOverlay={isSubmitting}>
|
||||
<Form css={tw`m-0`}>
|
||||
<div css={tw`flex flex-wrap items-end`}>
|
||||
<div css={tw`w-full sm:flex-1 sm:mr-4`}>
|
||||
<Field
|
||||
type={'string'}
|
||||
id={'file_mode'}
|
||||
name={'mode'}
|
||||
label={'File Mode'}
|
||||
autoFocus
|
||||
/>
|
||||
<Field type={'string'} id={'file_mode'} name={'mode'} label={'File Mode'} autoFocus />
|
||||
</div>
|
||||
<div css={tw`w-full sm:w-auto mt-4 sm:mt-0`}>
|
||||
<Button css={tw`w-full`}>Update</Button>
|
||||
|
|
|
@ -37,7 +37,8 @@ type ModalType = 'rename' | 'move' | 'chmod';
|
|||
|
||||
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`};
|
||||
${(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> {
|
||||
|
@ -48,21 +49,21 @@ interface RowProps extends React.HTMLAttributes<HTMLDivElement> {
|
|||
|
||||
const Row = ({ icon, title, ...props }: RowProps) => (
|
||||
<StyledRow {...props}>
|
||||
<FontAwesomeIcon icon={icon} css={tw`text-xs`} fixedWidth/>
|
||||
<FontAwesomeIcon icon={icon} css={tw`text-xs`} fixedWidth />
|
||||
<span css={tw`ml-2`}>{title}</span>
|
||||
</StyledRow>
|
||||
);
|
||||
|
||||
const FileDropdownMenu = ({ file }: { file: FileObject }) => {
|
||||
const onClickRef = useRef<DropdownMenu>(null);
|
||||
const [ showSpinner, setShowSpinner ] = useState(false);
|
||||
const [ modal, setModal ] = useState<ModalType | null>(null);
|
||||
const [ showConfirmation, setShowConfirmation ] = useState(false);
|
||||
const [showSpinner, setShowSpinner] = useState(false);
|
||||
const [modal, setModal] = useState<ModalType | null>(null);
|
||||
const [showConfirmation, setShowConfirmation] = useState(false);
|
||||
|
||||
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
|
||||
const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid);
|
||||
const { mutate } = useFileManagerSwr();
|
||||
const { clearAndAddHttpError, clearFlashes } = useFlash();
|
||||
const directory = ServerContext.useStoreState(state => state.files.directory);
|
||||
const directory = ServerContext.useStoreState((state) => state.files.directory);
|
||||
|
||||
useEventListener(`pterodactyl:files:ctx:${file.key}`, (e: CustomEvent) => {
|
||||
if (onClickRef.current) {
|
||||
|
@ -75,9 +76,9 @@ const FileDropdownMenu = ({ file }: { file: FileObject }) => {
|
|||
|
||||
// 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.key !== file.key), false);
|
||||
mutate((files) => files.filter((f) => f.key !== file.key), false);
|
||||
|
||||
deleteFiles(uuid, directory, [ file.name ]).catch(error => {
|
||||
deleteFiles(uuid, directory, [file.name]).catch((error) => {
|
||||
mutate();
|
||||
clearAndAddHttpError({ key: 'files', error });
|
||||
});
|
||||
|
@ -89,7 +90,7 @@ const FileDropdownMenu = ({ file }: { file: FileObject }) => {
|
|||
|
||||
copyFile(uuid, join(directory, file.name))
|
||||
.then(() => mutate())
|
||||
.catch(error => clearAndAddHttpError({ key: 'files', error }))
|
||||
.catch((error) => clearAndAddHttpError({ key: 'files', error }))
|
||||
.then(() => setShowSpinner(false));
|
||||
};
|
||||
|
||||
|
@ -98,11 +99,11 @@ const FileDropdownMenu = ({ file }: { file: FileObject }) => {
|
|||
clearFlashes('files');
|
||||
|
||||
getFileDownloadUrl(uuid, join(directory, file.name))
|
||||
.then(url => {
|
||||
.then((url) => {
|
||||
// @ts-ignore
|
||||
window.location = url;
|
||||
})
|
||||
.catch(error => clearAndAddHttpError({ key: 'files', error }))
|
||||
.catch((error) => clearAndAddHttpError({ key: 'files', error }))
|
||||
.then(() => setShowSpinner(false));
|
||||
};
|
||||
|
||||
|
@ -110,9 +111,9 @@ const FileDropdownMenu = ({ file }: { file: FileObject }) => {
|
|||
setShowSpinner(true);
|
||||
clearFlashes('files');
|
||||
|
||||
compressFiles(uuid, directory, [ file.name ])
|
||||
compressFiles(uuid, directory, [file.name])
|
||||
.then(() => mutate())
|
||||
.catch(error => clearAndAddHttpError({ key: 'files', error }))
|
||||
.catch((error) => clearAndAddHttpError({ key: 'files', error }))
|
||||
.then(() => setShowSpinner(false));
|
||||
};
|
||||
|
||||
|
@ -122,7 +123,7 @@ const FileDropdownMenu = ({ file }: { file: FileObject }) => {
|
|||
|
||||
decompressFiles(uuid, directory, file.name)
|
||||
.then(() => mutate())
|
||||
.catch(error => clearAndAddHttpError({ key: 'files', error }))
|
||||
.catch((error) => clearAndAddHttpError({ key: 'files', error }))
|
||||
.then(() => setShowSpinner(false));
|
||||
};
|
||||
|
||||
|
@ -140,55 +141,53 @@ const FileDropdownMenu = ({ file }: { file: FileObject }) => {
|
|||
</Dialog.Confirm>
|
||||
<DropdownMenu
|
||||
ref={onClickRef}
|
||||
renderToggle={onClick => (
|
||||
renderToggle={(onClick) => (
|
||||
<div css={tw`p-3 hover:text-white`} onClick={onClick}>
|
||||
<FontAwesomeIcon icon={faEllipsisH}/>
|
||||
{modal ?
|
||||
modal === 'chmod' ?
|
||||
<FontAwesomeIcon icon={faEllipsisH} />
|
||||
{modal ? (
|
||||
modal === 'chmod' ? (
|
||||
<ChmodFileModal
|
||||
visible
|
||||
appear
|
||||
files={[ { file: file.name, mode: file.modeBits } ]}
|
||||
files={[{ file: file.name, mode: file.modeBits }]}
|
||||
onDismissed={() => setModal(null)}
|
||||
/>
|
||||
:
|
||||
) : (
|
||||
<RenameFileModal
|
||||
visible
|
||||
appear
|
||||
files={[ file.name ]}
|
||||
files={[file.name]}
|
||||
useMoveTerminology={modal === 'move'}
|
||||
onDismissed={() => setModal(null)}
|
||||
/>
|
||||
: null
|
||||
}
|
||||
<SpinnerOverlay visible={showSpinner} fixed size={'large'}/>
|
||||
)
|
||||
) : null}
|
||||
<SpinnerOverlay visible={showSpinner} fixed size={'large'} />
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Can action={'file.update'}>
|
||||
<Row onClick={() => setModal('rename')} icon={faPencilAlt} title={'Rename'}/>
|
||||
<Row onClick={() => setModal('move')} icon={faLevelUpAlt} title={'Move'}/>
|
||||
<Row onClick={() => setModal('chmod')} icon={faFileCode} title={'Permissions'}/>
|
||||
<Row onClick={() => setModal('rename')} icon={faPencilAlt} title={'Rename'} />
|
||||
<Row onClick={() => setModal('move')} icon={faLevelUpAlt} title={'Move'} />
|
||||
<Row onClick={() => setModal('chmod')} icon={faFileCode} title={'Permissions'} />
|
||||
</Can>
|
||||
{file.isFile &&
|
||||
<Can action={'file.create'}>
|
||||
<Row onClick={doCopy} icon={faCopy} title={'Copy'}/>
|
||||
</Can>
|
||||
}
|
||||
{file.isArchiveType() ?
|
||||
{file.isFile && (
|
||||
<Can action={'file.create'}>
|
||||
<Row onClick={doUnarchive} icon={faBoxOpen} title={'Unarchive'}/>
|
||||
<Row onClick={doCopy} icon={faCopy} title={'Copy'} />
|
||||
</Can>
|
||||
:
|
||||
)}
|
||||
{file.isArchiveType() ? (
|
||||
<Can action={'file.create'}>
|
||||
<Row onClick={doUnarchive} icon={faBoxOpen} title={'Unarchive'} />
|
||||
</Can>
|
||||
) : (
|
||||
<Can action={'file.archive'}>
|
||||
<Row onClick={doArchive} icon={faFileArchive} title={'Archive'}/>
|
||||
<Row onClick={doArchive} icon={faFileArchive} title={'Archive'} />
|
||||
</Can>
|
||||
}
|
||||
{file.isFile &&
|
||||
<Row onClick={doDownload} icon={faFileDownload} title={'Download'}/>
|
||||
}
|
||||
)}
|
||||
{file.isFile && <Row onClick={doDownload} icon={faFileDownload} title={'Download'} />}
|
||||
<Can action={'file.delete'}>
|
||||
<Row onClick={() => setShowConfirmation(true)} icon={faTrashAlt} title={'Delete'} $danger/>
|
||||
<Row onClick={() => setShowConfirmation(true)} icon={faTrashAlt} title={'Delete'} $danger />
|
||||
</Can>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
|
|
|
@ -22,19 +22,19 @@ import { dirname } from 'path';
|
|||
import CodemirrorEditor from '@/components/elements/CodemirrorEditor';
|
||||
|
||||
export default () => {
|
||||
const [ error, setError ] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const { action } = useParams<{ action: 'new' | string }>();
|
||||
const [ loading, setLoading ] = useState(action === 'edit');
|
||||
const [ content, setContent ] = useState('');
|
||||
const [ modalVisible, setModalVisible ] = useState(false);
|
||||
const [ mode, setMode ] = useState('text/plain');
|
||||
const [loading, setLoading] = useState(action === 'edit');
|
||||
const [content, setContent] = useState('');
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [mode, setMode] = useState('text/plain');
|
||||
|
||||
const history = useHistory();
|
||||
const { hash } = useLocation();
|
||||
|
||||
const id = ServerContext.useStoreState(state => state.server.data!.id);
|
||||
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
|
||||
const setDirectory = ServerContext.useStoreActions(actions => actions.files.setDirectory);
|
||||
const id = ServerContext.useStoreState((state) => state.server.data!.id);
|
||||
const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid);
|
||||
const setDirectory = ServerContext.useStoreActions((actions) => actions.files.setDirectory);
|
||||
const { addError, clearFlashes } = useFlash();
|
||||
|
||||
let fetchFileContent: null | (() => Promise<string>) = null;
|
||||
|
@ -48,12 +48,12 @@ export default () => {
|
|||
setDirectory(dirname(path));
|
||||
getFileContents(uuid, path)
|
||||
.then(setContent)
|
||||
.catch(error => {
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
setError(httpErrorToHuman(error));
|
||||
})
|
||||
.then(() => setLoading(false));
|
||||
}, [ action, uuid, hash ]);
|
||||
}, [action, uuid, hash]);
|
||||
|
||||
const save = (name?: string) => {
|
||||
if (!fetchFileContent) {
|
||||
|
@ -63,7 +63,7 @@ export default () => {
|
|||
setLoading(true);
|
||||
clearFlashes('files:view');
|
||||
fetchFileContent()
|
||||
.then(content => saveFileContents(uuid, name || hashToPath(hash), content))
|
||||
.then((content) => saveFileContents(uuid, name || hashToPath(hash), content))
|
||||
.then(() => {
|
||||
if (name) {
|
||||
history.push(`/server/${id}/files/edit#/${encodePathSegments(name)}`);
|
||||
|
@ -72,7 +72,7 @@ export default () => {
|
|||
|
||||
return Promise.resolve();
|
||||
})
|
||||
.catch(error => {
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
addError({ message: httpErrorToHuman(error), key: 'files:view' });
|
||||
})
|
||||
|
@ -80,31 +80,28 @@ export default () => {
|
|||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<ServerError message={error} onBack={() => history.goBack()}/>
|
||||
);
|
||||
return <ServerError message={error} onBack={() => history.goBack()} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContentBlock>
|
||||
<FlashMessageRender byKey={'files:view'} css={tw`mb-4`}/>
|
||||
<FlashMessageRender byKey={'files:view'} css={tw`mb-4`} />
|
||||
<ErrorBoundary>
|
||||
<div css={tw`mb-4`}>
|
||||
<FileManagerBreadcrumbs withinFileEditor isNewFile={action !== 'edit'}/>
|
||||
<FileManagerBreadcrumbs withinFileEditor isNewFile={action !== 'edit'} />
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
{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 css={tw`font-mono bg-black rounded py-px px-1`}>*</code>). You can
|
||||
negate a prior rule by prepending an exclamation point
|
||||
(<code css={tw`font-mono bg-black rounded py-px px-1`}>!</code>).
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
{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 css={tw`font-mono bg-black rounded py-px px-1`}>*</code>).
|
||||
You can negate a prior rule by prepending an exclamation point (
|
||||
<code css={tw`font-mono bg-black rounded py-px px-1`}>!</code>).
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<FileNameModal
|
||||
visible={modalVisible}
|
||||
onDismissed={() => setModalVisible(false)}
|
||||
|
@ -114,13 +111,13 @@ export default () => {
|
|||
}}
|
||||
/>
|
||||
<div css={tw`relative`}>
|
||||
<SpinnerOverlay visible={loading}/>
|
||||
<SpinnerOverlay visible={loading} />
|
||||
<CodemirrorEditor
|
||||
mode={mode}
|
||||
filename={hash.replace(/^#/, '')}
|
||||
onModeChanged={setMode}
|
||||
initialContent={content}
|
||||
fetchContent={value => {
|
||||
fetchContent={(value) => {
|
||||
fetchFileContent = value;
|
||||
}}
|
||||
onContentSaved={() => {
|
||||
|
@ -134,27 +131,27 @@ export default () => {
|
|||
</div>
|
||||
<div css={tw`flex justify-end mt-4`}>
|
||||
<div css={tw`flex-1 sm:flex-none rounded bg-neutral-900 mr-4`}>
|
||||
<Select value={mode} onChange={e => setMode(e.currentTarget.value)}>
|
||||
{modes.map(mode => (
|
||||
<Select value={mode} onChange={(e) => setMode(e.currentTarget.value)}>
|
||||
{modes.map((mode) => (
|
||||
<option key={`${mode.name}_${mode.mime}`} value={mode.mime}>
|
||||
{mode.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
{action === 'edit' ?
|
||||
{action === 'edit' ? (
|
||||
<Can action={'file.update'}>
|
||||
<Button css={tw`flex-1 sm:flex-none`} onClick={() => save()}>
|
||||
Save Content
|
||||
</Button>
|
||||
</Can>
|
||||
:
|
||||
) : (
|
||||
<Can action={'file.create'}>
|
||||
<Button css={tw`flex-1 sm:flex-none`} onClick={() => setModalVisible(true)}>
|
||||
Create File
|
||||
</Button>
|
||||
</Can>
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
</PageContentBlock>
|
||||
);
|
||||
|
|
|
@ -11,9 +11,9 @@ interface Props {
|
|||
}
|
||||
|
||||
export default ({ renderLeft, withinFileEditor, isNewFile }: Props) => {
|
||||
const [ file, setFile ] = useState<string | null>(null);
|
||||
const id = ServerContext.useStoreState(state => state.server.data!.id);
|
||||
const directory = ServerContext.useStoreState(state => state.files.directory);
|
||||
const [file, setFile] = useState<string | null>(null);
|
||||
const id = ServerContext.useStoreState((state) => state.server.data!.id);
|
||||
const directory = ServerContext.useStoreState((state) => state.files.directory);
|
||||
const { hash } = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -23,48 +23,49 @@ export default ({ renderLeft, withinFileEditor, isNewFile }: Props) => {
|
|||
const name = path.split('/').pop() || null;
|
||||
setFile(name);
|
||||
}
|
||||
}, [ withinFileEditor, isNewFile, hash ]);
|
||||
}, [withinFileEditor, isNewFile, hash]);
|
||||
|
||||
const breadcrumbs = (): { name: string; path?: string }[] => directory.split('/')
|
||||
.filter(directory => !!directory)
|
||||
.map((directory, index, dirs) => {
|
||||
if (!withinFileEditor && index === dirs.length - 1) {
|
||||
return { name: directory };
|
||||
}
|
||||
const breadcrumbs = (): { name: string; path?: string }[] =>
|
||||
directory
|
||||
.split('/')
|
||||
.filter((directory) => !!directory)
|
||||
.map((directory, index, dirs) => {
|
||||
if (!withinFileEditor && index === dirs.length - 1) {
|
||||
return { name: directory };
|
||||
}
|
||||
|
||||
return { name: directory, path: `/${dirs.slice(0, index + 1).join('/')}` };
|
||||
});
|
||||
return { name: directory, path: `/${dirs.slice(0, index + 1).join('/')}` };
|
||||
});
|
||||
|
||||
return (
|
||||
<div css={tw`flex flex-grow-0 items-center text-sm text-neutral-500 overflow-x-hidden`}>
|
||||
{renderLeft || <div css={tw`w-12`}/>}
|
||||
/<span css={tw`px-1 text-neutral-300`}>home</span>/
|
||||
<NavLink
|
||||
to={`/server/${id}/files`}
|
||||
css={tw`px-1 text-neutral-200 no-underline hover:text-neutral-100`}
|
||||
>
|
||||
{renderLeft || <div css={tw`w-12`} />}/<span css={tw`px-1 text-neutral-300`}>home</span>/
|
||||
<NavLink to={`/server/${id}/files`} css={tw`px-1 text-neutral-200 no-underline hover:text-neutral-100`}>
|
||||
container
|
||||
</NavLink>/
|
||||
{
|
||||
breadcrumbs().map((crumb, index) => (
|
||||
crumb.path ?
|
||||
<React.Fragment key={index}>
|
||||
<NavLink
|
||||
to={`/server/${id}/files#${encodePathSegments(crumb.path)}`}
|
||||
css={tw`px-1 text-neutral-200 no-underline hover:text-neutral-100`}
|
||||
>
|
||||
{crumb.name}
|
||||
</NavLink>/
|
||||
</React.Fragment>
|
||||
:
|
||||
<span key={index} css={tw`px-1 text-neutral-300`}>{crumb.name}</span>
|
||||
))
|
||||
}
|
||||
{file &&
|
||||
<React.Fragment>
|
||||
<span css={tw`px-1 text-neutral-300`}>{file}</span>
|
||||
</React.Fragment>
|
||||
}
|
||||
</NavLink>
|
||||
/
|
||||
{breadcrumbs().map((crumb, index) =>
|
||||
crumb.path ? (
|
||||
<React.Fragment key={index}>
|
||||
<NavLink
|
||||
to={`/server/${id}/files#${encodePathSegments(crumb.path)}`}
|
||||
css={tw`px-1 text-neutral-200 no-underline hover:text-neutral-100`}
|
||||
>
|
||||
{crumb.name}
|
||||
</NavLink>
|
||||
/
|
||||
</React.Fragment>
|
||||
) : (
|
||||
<span key={index} css={tw`px-1 text-neutral-300`}>
|
||||
{crumb.name}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
{file && (
|
||||
<React.Fragment>
|
||||
<span css={tw`px-1 text-neutral-300`}>{file}</span>
|
||||
</React.Fragment>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -23,39 +23,39 @@ import { hashToPath } from '@/helpers';
|
|||
import style from './style.module.css';
|
||||
|
||||
const sortFiles = (files: FileObject[]): FileObject[] => {
|
||||
const sortedFiles: FileObject[] = files.sort((a, b) => a.name.localeCompare(b.name)).sort((a, b) => a.isFile === b.isFile ? 0 : (a.isFile ? 1 : -1));
|
||||
const sortedFiles: FileObject[] = files
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.sort((a, b) => (a.isFile === b.isFile ? 0 : a.isFile ? 1 : -1));
|
||||
return sortedFiles.filter((file, index) => index === 0 || file.name !== sortedFiles[index - 1].name);
|
||||
};
|
||||
|
||||
export default () => {
|
||||
const id = ServerContext.useStoreState(state => state.server.data!.id);
|
||||
const id = ServerContext.useStoreState((state) => state.server.data!.id);
|
||||
const { hash } = useLocation();
|
||||
const { data: files, error, mutate } = useFileManagerSwr();
|
||||
const directory = ServerContext.useStoreState(state => state.files.directory);
|
||||
const clearFlashes = useStoreActions(actions => actions.flashes.clearFlashes);
|
||||
const setDirectory = ServerContext.useStoreActions(actions => actions.files.setDirectory);
|
||||
const directory = ServerContext.useStoreState((state) => state.files.directory);
|
||||
const clearFlashes = useStoreActions((actions) => actions.flashes.clearFlashes);
|
||||
const setDirectory = ServerContext.useStoreActions((actions) => actions.files.setDirectory);
|
||||
|
||||
const setSelectedFiles = ServerContext.useStoreActions(actions => actions.files.setSelectedFiles);
|
||||
const selectedFilesLength = ServerContext.useStoreState(state => state.files.selectedFiles.length);
|
||||
const setSelectedFiles = ServerContext.useStoreActions((actions) => actions.files.setSelectedFiles);
|
||||
const selectedFilesLength = ServerContext.useStoreState((state) => state.files.selectedFiles.length);
|
||||
|
||||
useEffect(() => {
|
||||
clearFlashes('files');
|
||||
setSelectedFiles([]);
|
||||
setDirectory(hashToPath(hash));
|
||||
}, [ hash ]);
|
||||
}, [hash]);
|
||||
|
||||
useEffect(() => {
|
||||
mutate();
|
||||
}, [ directory ]);
|
||||
}, [directory]);
|
||||
|
||||
const onSelectAllClick = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSelectedFiles(e.currentTarget.checked ? (files?.map(file => file.name) || []) : []);
|
||||
setSelectedFiles(e.currentTarget.checked ? files?.map((file) => file.name) || [] : []);
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<ServerError message={httpErrorToHuman(error)} onRetry={() => mutate()}/>
|
||||
);
|
||||
return <ServerError message={httpErrorToHuman(error)} onRetry={() => mutate()} />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
@ -74,8 +74,8 @@ export default () => {
|
|||
/>
|
||||
<Can action={'file.create'}>
|
||||
<div className={style.manager_actions}>
|
||||
<NewDirectoryButton/>
|
||||
<UploadButton/>
|
||||
<NewDirectoryButton />
|
||||
<UploadButton />
|
||||
<NavLink to={`/server/${id}/files/new${window.location.hash}`}>
|
||||
<Button>New File</Button>
|
||||
</NavLink>
|
||||
|
@ -83,37 +83,32 @@ export default () => {
|
|||
</Can>
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
{
|
||||
!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>
|
||||
<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>
|
||||
}
|
||||
{
|
||||
sortFiles(files.slice(0, 250)).map(file => (
|
||||
<FileObjectRow key={file.key} file={file}/>
|
||||
))
|
||||
}
|
||||
<MassActionsBar/>
|
||||
</div>
|
||||
</CSSTransition>
|
||||
}
|
||||
</>
|
||||
}
|
||||
{!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>
|
||||
<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>
|
||||
)}
|
||||
{sortFiles(files.slice(0, 250)).map((file) => (
|
||||
<FileObjectRow key={file.key} file={file} />
|
||||
))}
|
||||
<MassActionsBar />
|
||||
</div>
|
||||
</CSSTransition>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ServerContentBlock>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -17,7 +17,7 @@ interface Values {
|
|||
}
|
||||
|
||||
export default ({ onFileNamed, onDismissed, ...props }: Props) => {
|
||||
const directory = ServerContext.useStoreState(state => state.files.directory);
|
||||
const directory = ServerContext.useStoreState((state) => state.files.directory);
|
||||
|
||||
const submit = (values: Values, { setSubmitting }: FormikHelpers<Values>) => {
|
||||
onFileNamed(join(directory, values.fileName));
|
||||
|
|
|
@ -20,63 +20,53 @@ const Row = styled.div`
|
|||
`;
|
||||
|
||||
const Clickable: React.FC<{ file: FileObject }> = memo(({ file, children }) => {
|
||||
const [ canReadContents ] = usePermissions([ 'file.read-content' ]);
|
||||
const directory = ServerContext.useStoreState(state => state.files.directory);
|
||||
const [canReadContents] = usePermissions(['file.read-content']);
|
||||
const directory = ServerContext.useStoreState((state) => state.files.directory);
|
||||
|
||||
const match = useRouteMatch();
|
||||
|
||||
return (
|
||||
(!canReadContents || (file.isFile && !file.isEditable())) ?
|
||||
<div css={tw`flex flex-1 text-neutral-300 no-underline p-3 cursor-default overflow-hidden truncate`}>
|
||||
{children}
|
||||
</div>
|
||||
:
|
||||
<NavLink
|
||||
to={`${match.url}${file.isFile ? '/edit' : ''}#${encodePathSegments(join(directory, file.name))}`}
|
||||
css={tw`flex flex-1 text-neutral-300 no-underline p-3 overflow-hidden truncate`}
|
||||
>
|
||||
{children}
|
||||
</NavLink>
|
||||
return !canReadContents || (file.isFile && !file.isEditable()) ? (
|
||||
<div css={tw`flex flex-1 text-neutral-300 no-underline p-3 cursor-default overflow-hidden truncate`}>
|
||||
{children}
|
||||
</div>
|
||||
) : (
|
||||
<NavLink
|
||||
to={`${match.url}${file.isFile ? '/edit' : ''}#${encodePathSegments(join(directory, file.name))}`}
|
||||
css={tw`flex flex-1 text-neutral-300 no-underline p-3 overflow-hidden truncate`}
|
||||
>
|
||||
{children}
|
||||
</NavLink>
|
||||
);
|
||||
}, isEqual);
|
||||
|
||||
const FileObjectRow = ({ file }: { file: FileObject }) => (
|
||||
<Row
|
||||
key={file.name}
|
||||
onContextMenu={e => {
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
window.dispatchEvent(new CustomEvent(`pterodactyl:files:ctx:${file.key}`, { detail: e.clientX }));
|
||||
}}
|
||||
>
|
||||
<SelectFileCheckbox name={file.name}/>
|
||||
<SelectFileCheckbox name={file.name} />
|
||||
<Clickable file={file}>
|
||||
<div css={tw`flex-none self-center text-neutral-400 ml-6 mr-4 text-lg pl-3`}>
|
||||
{file.isFile ?
|
||||
<FontAwesomeIcon icon={file.isSymlink ? faFileImport : file.isArchiveType() ? faFileArchive : faFileAlt}/>
|
||||
:
|
||||
<FontAwesomeIcon icon={faFolder}/>
|
||||
}
|
||||
{file.isFile ? (
|
||||
<FontAwesomeIcon
|
||||
icon={file.isSymlink ? faFileImport : file.isArchiveType() ? faFileArchive : faFileAlt}
|
||||
/>
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faFolder} />
|
||||
)}
|
||||
</div>
|
||||
<div css={tw`flex-1 truncate`}>
|
||||
{file.name}
|
||||
</div>
|
||||
{file.isFile &&
|
||||
<div css={tw`w-1/6 text-right mr-4 hidden sm:block`}>
|
||||
{bytesToString(file.size)}
|
||||
</div>
|
||||
}
|
||||
<div
|
||||
css={tw`w-1/5 text-right mr-4 hidden md:block`}
|
||||
title={file.modifiedAt.toString()}
|
||||
>
|
||||
{Math.abs(differenceInHours(file.modifiedAt, new Date())) > 48 ?
|
||||
format(file.modifiedAt, 'MMM do, yyyy h:mma')
|
||||
:
|
||||
formatDistanceToNow(file.modifiedAt, { addSuffix: true })
|
||||
}
|
||||
<div css={tw`flex-1 truncate`}>{file.name}</div>
|
||||
{file.isFile && <div css={tw`w-1/6 text-right mr-4 hidden sm:block`}>{bytesToString(file.size)}</div>}
|
||||
<div css={tw`w-1/5 text-right mr-4 hidden md:block`} title={file.modifiedAt.toString()}>
|
||||
{Math.abs(differenceInHours(file.modifiedAt, new Date())) > 48
|
||||
? format(file.modifiedAt, 'MMM do, yyyy h:mma')
|
||||
: formatDistanceToNow(file.modifiedAt, { addSuffix: true })}
|
||||
</div>
|
||||
</Clickable>
|
||||
<FileDropdownMenu file={file}/>
|
||||
<FileDropdownMenu file={file} />
|
||||
</Row>
|
||||
);
|
||||
|
||||
|
|
|
@ -13,22 +13,22 @@ import Portal from '@/components/elements/Portal';
|
|||
import { Dialog } from '@/components/elements/dialog';
|
||||
|
||||
const MassActionsBar = () => {
|
||||
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
|
||||
const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid);
|
||||
|
||||
const { mutate } = useFileManagerSwr();
|
||||
const { clearFlashes, clearAndAddHttpError } = useFlash();
|
||||
const [ loading, setLoading ] = useState(false);
|
||||
const [ loadingMessage, setLoadingMessage ] = useState('');
|
||||
const [ showConfirm, setShowConfirm ] = useState(false);
|
||||
const [ showMove, setShowMove ] = useState(false);
|
||||
const directory = ServerContext.useStoreState(state => state.files.directory);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingMessage, setLoadingMessage] = useState('');
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
const [showMove, setShowMove] = useState(false);
|
||||
const directory = ServerContext.useStoreState((state) => state.files.directory);
|
||||
|
||||
const selectedFiles = ServerContext.useStoreState(state => state.files.selectedFiles);
|
||||
const setSelectedFiles = ServerContext.useStoreActions(actions => actions.files.setSelectedFiles);
|
||||
const selectedFiles = ServerContext.useStoreState((state) => state.files.selectedFiles);
|
||||
const setSelectedFiles = ServerContext.useStoreActions((actions) => actions.files.setSelectedFiles);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading) setLoadingMessage('');
|
||||
}, [ loading ]);
|
||||
}, [loading]);
|
||||
|
||||
const onClickCompress = () => {
|
||||
setLoading(true);
|
||||
|
@ -38,7 +38,7 @@ const MassActionsBar = () => {
|
|||
compressFiles(uuid, directory, selectedFiles)
|
||||
.then(() => mutate())
|
||||
.then(() => setSelectedFiles([]))
|
||||
.catch(error => clearAndAddHttpError({ key: 'files', error }))
|
||||
.catch((error) => clearAndAddHttpError({ key: 'files', error }))
|
||||
.then(() => setLoading(false));
|
||||
};
|
||||
|
||||
|
@ -50,10 +50,10 @@ const MassActionsBar = () => {
|
|||
|
||||
deleteFiles(uuid, directory, selectedFiles)
|
||||
.then(() => {
|
||||
mutate(files => files.filter(f => selectedFiles.indexOf(f.name) < 0), false);
|
||||
mutate((files) => files.filter((f) => selectedFiles.indexOf(f.name) < 0), false);
|
||||
setSelectedFiles([]);
|
||||
})
|
||||
.catch(error => {
|
||||
.catch((error) => {
|
||||
mutate();
|
||||
clearAndAddHttpError({ key: 'files', error });
|
||||
})
|
||||
|
@ -75,17 +75,15 @@ const MassActionsBar = () => {
|
|||
>
|
||||
<p className={'mb-2'}>
|
||||
Are you sure you want to delete
|
||||
<span className={'font-semibold text-gray-50'}>{selectedFiles.length} files</span>? This is
|
||||
a permanent action and the files cannot be recovered.
|
||||
<span className={'font-semibold text-gray-50'}>{selectedFiles.length} files</span>? This is a
|
||||
permanent action and the files cannot be recovered.
|
||||
</p>
|
||||
{selectedFiles.slice(0, 15).map(file => (
|
||||
<li key={file}>{file}</li>))
|
||||
}
|
||||
{selectedFiles.length > 15 &&
|
||||
<li>and {selectedFiles.length - 15} others</li>
|
||||
}
|
||||
{selectedFiles.slice(0, 15).map((file) => (
|
||||
<li key={file}>{file}</li>
|
||||
))}
|
||||
{selectedFiles.length > 15 && <li>and {selectedFiles.length - 15} others</li>}
|
||||
</Dialog.Confirm>
|
||||
{showMove &&
|
||||
{showMove && (
|
||||
<RenameFileModal
|
||||
files={selectedFiles}
|
||||
visible
|
||||
|
@ -93,7 +91,7 @@ const MassActionsBar = () => {
|
|||
useMoveTerminology
|
||||
onDismissed={() => setShowMove(false)}
|
||||
/>
|
||||
}
|
||||
)}
|
||||
<Portal>
|
||||
<div className={'fixed bottom-0 mb-6 flex justify-center w-full z-50'}>
|
||||
<Fade timeout={75} in={selectedFiles.length > 0} unmountOnExit>
|
||||
|
|
|
@ -40,12 +40,12 @@ const generateDirectoryData = (name: string): FileObject => ({
|
|||
});
|
||||
|
||||
export default ({ className }: WithClassname) => {
|
||||
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
|
||||
const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid);
|
||||
const { clearFlashes, clearAndAddHttpError } = useFlash();
|
||||
const [ visible, setVisible ] = useState(false);
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
const { mutate } = useFileManagerSwr();
|
||||
const directory = ServerContext.useStoreState(state => state.files.directory);
|
||||
const directory = ServerContext.useStoreState((state) => state.files.directory);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
|
@ -53,13 +53,13 @@ export default ({ className }: WithClassname) => {
|
|||
return () => {
|
||||
clearFlashes('files:directory-modal');
|
||||
};
|
||||
}, [ visible ]);
|
||||
}, [visible]);
|
||||
|
||||
const submit = ({ directoryName }: Values, { setSubmitting }: FormikHelpers<Values>) => {
|
||||
createDirectory(uuid, directory, directoryName)
|
||||
.then(() => mutate(data => [ ...data, generateDirectoryData(directoryName) ], false))
|
||||
.then(() => mutate((data) => [...data, generateDirectoryData(directoryName)], false))
|
||||
.then(() => setVisible(false))
|
||||
.catch(error => {
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
setSubmitting(false);
|
||||
clearAndAddHttpError({ key: 'files:directory-modal', error });
|
||||
|
@ -69,11 +69,7 @@ export default ({ className }: WithClassname) => {
|
|||
return (
|
||||
<>
|
||||
<Portal>
|
||||
<Formik
|
||||
onSubmit={submit}
|
||||
validationSchema={schema}
|
||||
initialValues={{ directoryName: '' }}
|
||||
>
|
||||
<Formik onSubmit={submit} validationSchema={schema} initialValues={{ directoryName: '' }}>
|
||||
{({ resetForm, submitForm, isSubmitting: _, values }) => (
|
||||
<Dialog
|
||||
title={'Create Directory'}
|
||||
|
@ -83,17 +79,13 @@ export default ({ className }: WithClassname) => {
|
|||
resetForm();
|
||||
}}
|
||||
>
|
||||
<FlashMessageRender key={'files:directory-modal'}/>
|
||||
<FlashMessageRender key={'files:directory-modal'} />
|
||||
<Form css={tw`m-0`}>
|
||||
<Field
|
||||
autoFocus
|
||||
id={'directoryName'}
|
||||
name={'directoryName'}
|
||||
label={'Name'}
|
||||
/>
|
||||
<Field autoFocus id={'directoryName'} name={'directoryName'} label={'Name'} />
|
||||
<p css={tw`mt-2 text-sm md:text-base break-all`}>
|
||||
<span css={tw`text-neutral-200`}>This directory will be created as </span>
|
||||
<Code>/home/container/
|
||||
<Code>
|
||||
/home/container/
|
||||
<span css={tw`text-cyan-200`}>
|
||||
{join(directory, values.directoryName).replace(/^(\.\.\/|\/)+/, '')}
|
||||
</span>
|
||||
|
@ -110,7 +102,9 @@ export default ({ className }: WithClassname) => {
|
|||
>
|
||||
Cancel
|
||||
</Button.Text>
|
||||
<Button className={'w-full sm:w-auto'} onClick={submitForm}>Create</Button>
|
||||
<Button className={'w-full sm:w-auto'} onClick={submitForm}>
|
||||
Create
|
||||
</Button>
|
||||
</Dialog.Buttons>
|
||||
</Dialog>
|
||||
)}
|
||||
|
|
|
@ -17,11 +17,11 @@ interface FormikValues {
|
|||
type OwnProps = RequiredModalProps & { files: string[]; useMoveTerminology?: boolean };
|
||||
|
||||
const RenameFileModal = ({ files, useMoveTerminology, ...props }: OwnProps) => {
|
||||
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
|
||||
const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid);
|
||||
const { mutate } = useFileManagerSwr();
|
||||
const { clearFlashes, clearAndAddHttpError } = useFlash();
|
||||
const directory = ServerContext.useStoreState(state => state.files.directory);
|
||||
const setSelectedFiles = ServerContext.useStoreActions(actions => actions.files.setSelectedFiles);
|
||||
const directory = ServerContext.useStoreState((state) => state.files.directory);
|
||||
const setSelectedFiles = ServerContext.useStoreActions((actions) => actions.files.setSelectedFiles);
|
||||
|
||||
const submit = ({ name }: FormikValues, { setSubmitting }: FormikHelpers<FormikValues>) => {
|
||||
clearFlashes('files');
|
||||
|
@ -30,24 +30,24 @@ const RenameFileModal = ({ files, useMoveTerminology, ...props }: OwnProps) => {
|
|||
if (files.length === 1) {
|
||||
if (!useMoveTerminology && len === 1) {
|
||||
// Rename the file within this directory.
|
||||
mutate(data => data.map(f => f.name === files[0] ? { ...f, name } : f), false);
|
||||
} else if ((useMoveTerminology || len > 1)) {
|
||||
mutate((data) => data.map((f) => (f.name === files[0] ? { ...f, name } : f)), false);
|
||||
} else if (useMoveTerminology || len > 1) {
|
||||
// Remove the file from this directory since they moved it elsewhere.
|
||||
mutate(data => data.filter(f => f.name !== files[0]), false);
|
||||
mutate((data) => data.filter((f) => f.name !== files[0]), false);
|
||||
}
|
||||
}
|
||||
|
||||
let data;
|
||||
if (useMoveTerminology && files.length > 1) {
|
||||
data = files.map(f => ({ from: f, to: join(name, f) }));
|
||||
data = files.map((f) => ({ from: f, to: join(name, f) }));
|
||||
} else {
|
||||
data = files.map(f => ({ from: f, to: name }));
|
||||
data = files.map((f) => ({ from: f, to: name }));
|
||||
}
|
||||
|
||||
renameFiles(uuid, directory, data)
|
||||
.then((): Promise<any> => files.length > 0 ? mutate() : Promise.resolve())
|
||||
.then((): Promise<any> => (files.length > 0 ? mutate() : Promise.resolve()))
|
||||
.then(() => setSelectedFiles([]))
|
||||
.catch(error => {
|
||||
.catch((error) => {
|
||||
mutate();
|
||||
setSubmitting(false);
|
||||
clearAndAddHttpError({ key: 'files', error });
|
||||
|
@ -56,25 +56,21 @@ const RenameFileModal = ({ files, useMoveTerminology, ...props }: OwnProps) => {
|
|||
};
|
||||
|
||||
return (
|
||||
<Formik onSubmit={submit} initialValues={{ name: files.length > 1 ? '' : (files[0] || '') }}>
|
||||
<Formik onSubmit={submit} initialValues={{ name: files.length > 1 ? '' : files[0] || '' }}>
|
||||
{({ isSubmitting, values }) => (
|
||||
<Modal {...props} dismissable={!isSubmitting} showSpinnerOverlay={isSubmitting}>
|
||||
<Form css={tw`m-0`}>
|
||||
<div
|
||||
css={[
|
||||
tw`flex flex-wrap`,
|
||||
useMoveTerminology ? tw`items-center` : tw`items-end`,
|
||||
]}
|
||||
>
|
||||
<div css={[tw`flex flex-wrap`, useMoveTerminology ? tw`items-center` : tw`items-end`]}>
|
||||
<div css={tw`w-full sm:flex-1 sm:mr-4`}>
|
||||
<Field
|
||||
type={'string'}
|
||||
id={'file_name'}
|
||||
name={'name'}
|
||||
label={'File Name'}
|
||||
description={useMoveTerminology
|
||||
? 'Enter the new name and directory of this file or folder, relative to the current directory.'
|
||||
: undefined
|
||||
description={
|
||||
useMoveTerminology
|
||||
? 'Enter the new name and directory of this file or folder, relative to the current directory.'
|
||||
: undefined
|
||||
}
|
||||
autoFocus
|
||||
/>
|
||||
|
@ -83,12 +79,12 @@ const RenameFileModal = ({ files, useMoveTerminology, ...props }: OwnProps) => {
|
|||
<Button css={tw`w-full`}>{useMoveTerminology ? 'Move' : 'Rename'}</Button>
|
||||
</div>
|
||||
</div>
|
||||
{useMoveTerminology &&
|
||||
<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>
|
||||
}
|
||||
{useMoveTerminology && (
|
||||
<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>
|
||||
)}
|
||||
</Form>
|
||||
</Modal>
|
||||
)}
|
||||
|
|
|
@ -7,7 +7,7 @@ import Input from '@/components/elements/Input';
|
|||
export const FileActionCheckbox = styled(Input)`
|
||||
&& {
|
||||
${tw`border-neutral-500 bg-transparent`};
|
||||
|
||||
|
||||
&:not(:checked) {
|
||||
${tw`hover:border-neutral-300`};
|
||||
}
|
||||
|
@ -15,9 +15,9 @@ export const FileActionCheckbox = styled(Input)`
|
|||
`;
|
||||
|
||||
export default ({ name }: { name: string }) => {
|
||||
const isChecked = ServerContext.useStoreState(state => state.files.selectedFiles.indexOf(name) >= 0);
|
||||
const appendSelectedFile = ServerContext.useStoreActions(actions => actions.files.appendSelectedFile);
|
||||
const removeSelectedFile = ServerContext.useStoreActions(actions => actions.files.removeSelectedFile);
|
||||
const isChecked = ServerContext.useStoreState((state) => state.files.selectedFiles.indexOf(name) >= 0);
|
||||
const appendSelectedFile = ServerContext.useStoreActions((actions) => actions.files.appendSelectedFile);
|
||||
const removeSelectedFile = ServerContext.useStoreActions((actions) => actions.files.removeSelectedFile);
|
||||
|
||||
return (
|
||||
<label css={tw`flex-none p-4 absolute self-center z-30 cursor-pointer`}>
|
||||
|
|
|
@ -15,28 +15,36 @@ import { WithClassname } from '@/components/types';
|
|||
import Portal from '@/components/elements/Portal';
|
||||
|
||||
const InnerContainer = styled.div`
|
||||
max-width: 600px;
|
||||
${tw`bg-black w-full border-4 border-primary-500 border-dashed rounded p-10 mx-10`}
|
||||
max-width: 600px;
|
||||
${tw`bg-black w-full border-4 border-primary-500 border-dashed rounded p-10 mx-10`}
|
||||
`;
|
||||
|
||||
export default ({ className }: WithClassname) => {
|
||||
const fileUploadInput = useRef<HTMLInputElement>(null);
|
||||
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
|
||||
const [ visible, setVisible ] = useState(false);
|
||||
const [ loading, setLoading ] = useState(false);
|
||||
const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { mutate } = useFileManagerSwr();
|
||||
const { clearFlashes, clearAndAddHttpError } = useFlash();
|
||||
const directory = ServerContext.useStoreState(state => state.files.directory);
|
||||
const directory = ServerContext.useStoreState((state) => state.files.directory);
|
||||
|
||||
useEventListener('dragenter', e => {
|
||||
e.stopPropagation();
|
||||
setVisible(true);
|
||||
}, true);
|
||||
useEventListener(
|
||||
'dragenter',
|
||||
(e) => {
|
||||
e.stopPropagation();
|
||||
setVisible(true);
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
useEventListener('dragexit', e => {
|
||||
e.stopPropagation();
|
||||
setVisible(false);
|
||||
}, true);
|
||||
useEventListener(
|
||||
'dragexit',
|
||||
(e) => {
|
||||
e.stopPropagation();
|
||||
setVisible(false);
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
|
@ -47,22 +55,24 @@ export default ({ className }: WithClassname) => {
|
|||
return () => {
|
||||
window.removeEventListener('keydown', hide);
|
||||
};
|
||||
}, [ visible ]);
|
||||
}, [visible]);
|
||||
|
||||
const onFileSubmission = (files: FileList) => {
|
||||
const form = new FormData();
|
||||
Array.from(files).forEach(file => form.append('files', file));
|
||||
Array.from(files).forEach((file) => form.append('files', file));
|
||||
|
||||
setLoading(true);
|
||||
clearFlashes('files');
|
||||
getFileUploadUrl(uuid)
|
||||
.then(url => axios.post(`${url}&directory=${directory}`, form, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
}))
|
||||
.then((url) =>
|
||||
axios.post(`${url}&directory=${directory}`, form, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
})
|
||||
)
|
||||
.then(() => mutate())
|
||||
.catch(error => {
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
clearAndAddHttpError({ error, key: 'files' });
|
||||
})
|
||||
|
@ -73,17 +83,11 @@ export default ({ className }: WithClassname) => {
|
|||
return (
|
||||
<>
|
||||
<Portal>
|
||||
<Fade
|
||||
appear
|
||||
in={visible}
|
||||
timeout={75}
|
||||
key={'upload_modal_mask'}
|
||||
unmountOnExit
|
||||
>
|
||||
<Fade appear in={visible} timeout={75} key={'upload_modal_mask'} unmountOnExit>
|
||||
<ModalMask
|
||||
onClick={() => setVisible(false)}
|
||||
onDragOver={e => e.preventDefault()}
|
||||
onDrop={e => {
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
|
@ -95,20 +99,18 @@ export default ({ className }: WithClassname) => {
|
|||
>
|
||||
<div css={tw`w-full flex items-center justify-center`} style={{ pointerEvents: 'none' }}>
|
||||
<InnerContainer>
|
||||
<p css={tw`text-lg text-neutral-200 text-center`}>
|
||||
Drag and drop files to upload.
|
||||
</p>
|
||||
<p css={tw`text-lg text-neutral-200 text-center`}>Drag and drop files to upload.</p>
|
||||
</InnerContainer>
|
||||
</div>
|
||||
</ModalMask>
|
||||
</Fade>
|
||||
<SpinnerOverlay visible={loading} size={'large'} fixed/>
|
||||
<SpinnerOverlay visible={loading} size={'large'} fixed />
|
||||
</Portal>
|
||||
<input
|
||||
type={'file'}
|
||||
ref={fileUploadInput}
|
||||
css={tw`hidden`}
|
||||
onChange={e => {
|
||||
onChange={(e) => {
|
||||
if (!e.currentTarget.files) return;
|
||||
|
||||
onFileSubmission(e.currentTarget.files);
|
||||
|
@ -120,9 +122,7 @@ export default ({ className }: WithClassname) => {
|
|||
<Button
|
||||
className={className}
|
||||
onClick={() => {
|
||||
fileUploadInput.current
|
||||
? fileUploadInput.current.click()
|
||||
: setVisible(true);
|
||||
fileUploadInput.current ? fileUploadInput.current.click() : setVisible(true);
|
||||
}}
|
||||
>
|
||||
Upload
|
||||
|
|
|
@ -21,20 +21,22 @@ import getServerAllocations from '@/api/swr/getServerAllocations';
|
|||
import { ip } from '@/lib/formatters';
|
||||
import Code from '@/components/elements/Code';
|
||||
|
||||
const Label = styled.label`${tw`uppercase text-xs mt-1 text-neutral-400 block px-1 select-none transition-colors duration-150`}`;
|
||||
const Label = styled.label`
|
||||
${tw`uppercase text-xs mt-1 text-neutral-400 block px-1 select-none transition-colors duration-150`}
|
||||
`;
|
||||
|
||||
interface Props {
|
||||
allocation: Allocation;
|
||||
}
|
||||
|
||||
const AllocationRow = ({ allocation }: Props) => {
|
||||
const [ loading, setLoading ] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { clearFlashes, clearAndAddHttpError } = useFlashKey('server:network');
|
||||
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
|
||||
const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid);
|
||||
const { mutate } = getServerAllocations();
|
||||
|
||||
const onNotesChanged = useCallback((id: number, notes: string) => {
|
||||
mutate(data => data?.map(a => a.id === id ? { ...a, notes } : a), false);
|
||||
mutate((data) => data?.map((a) => (a.id === id ? { ...a, notes } : a)), false);
|
||||
}, []);
|
||||
|
||||
const setAllocationNotes = debounce((notes: string) => {
|
||||
|
@ -43,31 +45,38 @@ const AllocationRow = ({ allocation }: Props) => {
|
|||
|
||||
setServerAllocationNotes(uuid, allocation.id, notes)
|
||||
.then(() => onNotesChanged(allocation.id, notes))
|
||||
.catch(error => clearAndAddHttpError(error))
|
||||
.catch((error) => clearAndAddHttpError(error))
|
||||
.then(() => setLoading(false));
|
||||
}, 750);
|
||||
|
||||
const setPrimaryAllocation = () => {
|
||||
clearFlashes();
|
||||
mutate(data => data?.map(a => ({ ...a, isDefault: a.id === allocation.id })), false);
|
||||
mutate((data) => data?.map((a) => ({ ...a, isDefault: a.id === allocation.id })), false);
|
||||
|
||||
setPrimaryServerAllocation(uuid, allocation.id)
|
||||
.catch(error => {
|
||||
clearAndAddHttpError(error);
|
||||
mutate();
|
||||
});
|
||||
setPrimaryServerAllocation(uuid, allocation.id).catch((error) => {
|
||||
clearAndAddHttpError(error);
|
||||
mutate();
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<GreyRowBox $hoverable={false} className={'flex-wrap md:flex-nowrap mt-2'}>
|
||||
<div className={'flex items-center w-full md:w-auto'}>
|
||||
<div className={'pl-4 pr-6 text-neutral-400'}>
|
||||
<FontAwesomeIcon icon={faNetworkWired}/>
|
||||
<FontAwesomeIcon icon={faNetworkWired} />
|
||||
</div>
|
||||
<div className={'mr-4 flex-1 md:w-40'}>
|
||||
{allocation.alias ?
|
||||
<CopyOnClick text={allocation.alias}><Code dark className={'w-40 truncate'}>{allocation.alias}</Code></CopyOnClick> :
|
||||
<CopyOnClick text={ip(allocation.ip)}><Code dark>{ip(allocation.ip)}</Code></CopyOnClick>}
|
||||
{allocation.alias ? (
|
||||
<CopyOnClick text={allocation.alias}>
|
||||
<Code dark className={'w-40 truncate'}>
|
||||
{allocation.alias}
|
||||
</Code>
|
||||
</CopyOnClick>
|
||||
) : (
|
||||
<CopyOnClick text={ip(allocation.ip)}>
|
||||
<Code dark>{ip(allocation.ip)}</Code>
|
||||
</CopyOnClick>
|
||||
)}
|
||||
<Label>{allocation.alias ? 'Hostname' : 'IP Address'}</Label>
|
||||
</div>
|
||||
<div className={'w-16 md:w-24 overflow-hidden'}>
|
||||
|
@ -81,17 +90,19 @@ const AllocationRow = ({ allocation }: Props) => {
|
|||
className={'bg-neutral-800 hover:border-neutral-600 border-transparent'}
|
||||
placeholder={'Notes'}
|
||||
defaultValue={allocation.notes || undefined}
|
||||
onChange={e => setAllocationNotes(e.currentTarget.value)}
|
||||
onChange={(e) => setAllocationNotes(e.currentTarget.value)}
|
||||
/>
|
||||
</InputSpinner>
|
||||
</div>
|
||||
<div className={'flex justify-end space-x-4 mt-4 w-full md:mt-0 md:w-48'}>
|
||||
{allocation.isDefault ?
|
||||
<Button size={Button.Sizes.Small} className={'!text-gray-50 !bg-blue-600'} disabled>Primary</Button>
|
||||
:
|
||||
{allocation.isDefault ? (
|
||||
<Button size={Button.Sizes.Small} className={'!text-gray-50 !bg-blue-600'} disabled>
|
||||
Primary
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Can action={'allocation.delete'}>
|
||||
<DeleteAllocationButton allocation={allocation.id}/>
|
||||
<DeleteAllocationButton allocation={allocation.id} />
|
||||
</Can>
|
||||
<Can action={'allocation.update'}>
|
||||
<Button.Text size={Button.Sizes.Small} onClick={setPrimaryAllocation}>
|
||||
|
@ -99,7 +110,7 @@ const AllocationRow = ({ allocation }: Props) => {
|
|||
</Button.Text>
|
||||
</Can>
|
||||
</>
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
</GreyRowBox>
|
||||
);
|
||||
|
|
|
@ -14,10 +14,10 @@ interface Props {
|
|||
}
|
||||
|
||||
const DeleteAllocationButton = ({ allocation }: Props) => {
|
||||
const [ confirm, setConfirm ] = useState(false);
|
||||
const [confirm, setConfirm] = useState(false);
|
||||
|
||||
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
|
||||
const setServerFromState = ServerContext.useStoreActions(actions => actions.server.setServerFromState);
|
||||
const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid);
|
||||
const setServerFromState = ServerContext.useStoreActions((actions) => actions.server.setServerFromState);
|
||||
|
||||
const { mutate } = getServerAllocations();
|
||||
const { clearFlashes, clearAndAddHttpError } = useFlashKey('server:network');
|
||||
|
@ -25,14 +25,13 @@ const DeleteAllocationButton = ({ allocation }: Props) => {
|
|||
const deleteAllocation = () => {
|
||||
clearFlashes();
|
||||
|
||||
mutate(data => data?.filter(a => a.id !== allocation), false);
|
||||
setServerFromState(s => ({ ...s, allocations: s.allocations.filter(a => a.id !== allocation) }));
|
||||
mutate((data) => data?.filter((a) => a.id !== allocation), false);
|
||||
setServerFromState((s) => ({ ...s, allocations: s.allocations.filter((a) => a.id !== allocation) }));
|
||||
|
||||
deleteServerAllocation(uuid, allocation)
|
||||
.catch(error => {
|
||||
clearAndAddHttpError(error);
|
||||
mutate();
|
||||
});
|
||||
deleteServerAllocation(uuid, allocation).catch((error) => {
|
||||
clearAndAddHttpError(error);
|
||||
mutate();
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
|
@ -53,7 +52,7 @@ const DeleteAllocationButton = ({ allocation }: Props) => {
|
|||
type={'button'}
|
||||
onClick={() => setConfirm(true)}
|
||||
>
|
||||
<Icon icon={faTrashAlt} css={tw`w-3 h-auto`}/>
|
||||
<Icon icon={faTrashAlt} css={tw`w-3 h-auto`} />
|
||||
</Button.Danger>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -14,11 +14,11 @@ import isEqual from 'react-fast-compare';
|
|||
import { useDeepCompareEffect } from '@/plugins/useDeepCompareEffect';
|
||||
|
||||
const NetworkContainer = () => {
|
||||
const [ loading, setLoading ] = useState(false);
|
||||
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
|
||||
const allocationLimit = ServerContext.useStoreState(state => state.server.data!.featureLimits.allocations);
|
||||
const allocations = ServerContext.useStoreState(state => state.server.data!.allocations, isEqual);
|
||||
const setServerFromState = ServerContext.useStoreActions(actions => actions.server.setServerFromState);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid);
|
||||
const allocationLimit = ServerContext.useStoreState((state) => state.server.data!.featureLimits.allocations);
|
||||
const allocations = ServerContext.useStoreState((state) => state.server.data!.allocations, isEqual);
|
||||
const setServerFromState = ServerContext.useStoreActions((actions) => actions.server.setServerFromState);
|
||||
|
||||
const { clearFlashes, clearAndAddHttpError } = useFlashKey('server:network');
|
||||
const { data, error, mutate } = getServerAllocations();
|
||||
|
@ -29,59 +29,54 @@ const NetworkContainer = () => {
|
|||
|
||||
useEffect(() => {
|
||||
clearAndAddHttpError(error);
|
||||
}, [ error ]);
|
||||
}, [error]);
|
||||
|
||||
useDeepCompareEffect(() => {
|
||||
if (!data) return;
|
||||
|
||||
setServerFromState(state => ({ ...state, allocations: data }));
|
||||
}, [ data ]);
|
||||
setServerFromState((state) => ({ ...state, allocations: data }));
|
||||
}, [data]);
|
||||
|
||||
const onCreateAllocation = () => {
|
||||
clearFlashes();
|
||||
|
||||
setLoading(true);
|
||||
createServerAllocation(uuid)
|
||||
.then(allocation => {
|
||||
setServerFromState(s => ({ ...s, allocations: s.allocations.concat(allocation) }));
|
||||
.then((allocation) => {
|
||||
setServerFromState((s) => ({ ...s, allocations: s.allocations.concat(allocation) }));
|
||||
return mutate(data?.concat(allocation), false);
|
||||
})
|
||||
.catch(error => clearAndAddHttpError(error))
|
||||
.catch((error) => clearAndAddHttpError(error))
|
||||
.then(() => setLoading(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<ServerContentBlock showFlashKey={'server:network'} title={'Network'}>
|
||||
{!data ?
|
||||
<Spinner size={'large'} centered/>
|
||||
:
|
||||
{!data ? (
|
||||
<Spinner size={'large'} centered />
|
||||
) : (
|
||||
<>
|
||||
{
|
||||
data.map(allocation => (
|
||||
<AllocationRow
|
||||
key={`${allocation.ip}:${allocation.port}`}
|
||||
allocation={allocation}
|
||||
/>
|
||||
))
|
||||
}
|
||||
{allocationLimit > 0 &&
|
||||
{data.map((allocation) => (
|
||||
<AllocationRow key={`${allocation.ip}:${allocation.port}`} allocation={allocation} />
|
||||
))}
|
||||
{allocationLimit > 0 && (
|
||||
<Can action={'allocation.create'}>
|
||||
<SpinnerOverlay visible={loading}/>
|
||||
<SpinnerOverlay visible={loading} />
|
||||
<div css={tw`mt-6 sm:flex items-center justify-end`}>
|
||||
<p css={tw`text-sm text-neutral-300 mb-4 sm:mr-6 sm:mb-0`}>
|
||||
You are currently using {data.length} of {allocationLimit} allowed allocations for
|
||||
this server.
|
||||
</p>
|
||||
{allocationLimit > data.length &&
|
||||
{allocationLimit > data.length && (
|
||||
<Button css={tw`w-full sm:w-auto`} color={'primary'} onClick={onCreateAllocation}>
|
||||
Create Allocation
|
||||
</Button>
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
</Can>
|
||||
}
|
||||
)}
|
||||
</>
|
||||
}
|
||||
)}
|
||||
</ServerContentBlock>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -14,9 +14,9 @@ interface Props {
|
|||
}
|
||||
|
||||
export default ({ scheduleId, onDeleted }: Props) => {
|
||||
const [ visible, setVisible ] = useState(false);
|
||||
const [ isLoading, setIsLoading ] = useState(false);
|
||||
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid);
|
||||
const { addError, clearFlashes } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
|
||||
|
||||
const onDelete = () => {
|
||||
|
@ -27,7 +27,7 @@ export default ({ scheduleId, onDeleted }: Props) => {
|
|||
setIsLoading(false);
|
||||
onDeleted();
|
||||
})
|
||||
.catch(error => {
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
|
||||
addError({ key: 'schedules', message: httpErrorToHuman(error) });
|
||||
|
|
|
@ -34,9 +34,9 @@ const EditScheduleModal = ({ schedule }: Props) => {
|
|||
const { addError, clearFlashes } = useFlash();
|
||||
const { dismiss } = useContext(ModalContext);
|
||||
|
||||
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
|
||||
const appendSchedule = ServerContext.useStoreActions(actions => actions.schedules.appendSchedule);
|
||||
const [ showCheatsheet, setShowCheetsheet ] = useState(false);
|
||||
const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid);
|
||||
const appendSchedule = ServerContext.useStoreActions((actions) => actions.schedules.appendSchedule);
|
||||
const [showCheatsheet, setShowCheetsheet] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
|
@ -59,12 +59,12 @@ const EditScheduleModal = ({ schedule }: Props) => {
|
|||
onlyWhenOnline: values.onlyWhenOnline,
|
||||
isActive: values.enabled,
|
||||
})
|
||||
.then(schedule => {
|
||||
.then((schedule) => {
|
||||
setSubmitting(false);
|
||||
appendSchedule(schedule);
|
||||
dismiss();
|
||||
})
|
||||
.catch(error => {
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
|
||||
setSubmitting(false);
|
||||
|
@ -75,32 +75,34 @@ const EditScheduleModal = ({ schedule }: Props) => {
|
|||
return (
|
||||
<Formik
|
||||
onSubmit={submit}
|
||||
initialValues={{
|
||||
name: schedule?.name || '',
|
||||
minute: schedule?.cron.minute || '*/5',
|
||||
hour: schedule?.cron.hour || '*',
|
||||
dayOfMonth: schedule?.cron.dayOfMonth || '*',
|
||||
month: schedule?.cron.month || '*',
|
||||
dayOfWeek: schedule?.cron.dayOfWeek || '*',
|
||||
enabled: schedule?.isActive ?? true,
|
||||
onlyWhenOnline: schedule?.onlyWhenOnline ?? true,
|
||||
} as Values}
|
||||
initialValues={
|
||||
{
|
||||
name: schedule?.name || '',
|
||||
minute: schedule?.cron.minute || '*/5',
|
||||
hour: schedule?.cron.hour || '*',
|
||||
dayOfMonth: schedule?.cron.dayOfMonth || '*',
|
||||
month: schedule?.cron.month || '*',
|
||||
dayOfWeek: schedule?.cron.dayOfWeek || '*',
|
||||
enabled: schedule?.isActive ?? true,
|
||||
onlyWhenOnline: schedule?.onlyWhenOnline ?? true,
|
||||
} as Values
|
||||
}
|
||||
>
|
||||
{({ isSubmitting }) => (
|
||||
<Form>
|
||||
<h3 css={tw`text-2xl mb-6`}>{schedule ? 'Edit schedule' : 'Create new schedule'}</h3>
|
||||
<FlashMessageRender byKey={'schedule:edit'} css={tw`mb-6`}/>
|
||||
<FlashMessageRender byKey={'schedule:edit'} css={tw`mb-6`} />
|
||||
<Field
|
||||
name={'name'}
|
||||
label={'Schedule name'}
|
||||
description={'A human readable identifier for this schedule.'}
|
||||
/>
|
||||
<div css={tw`grid grid-cols-2 sm:grid-cols-5 gap-4 mt-6`}>
|
||||
<Field name={'minute'} label={'Minute'}/>
|
||||
<Field name={'hour'} label={'Hour'}/>
|
||||
<Field name={'dayOfMonth'} label={'Day of month'}/>
|
||||
<Field name={'month'} label={'Month'}/>
|
||||
<Field name={'dayOfWeek'} label={'Day of week'}/>
|
||||
<Field name={'minute'} label={'Minute'} />
|
||||
<Field name={'hour'} label={'Hour'} />
|
||||
<Field name={'dayOfMonth'} label={'Day of month'} />
|
||||
<Field name={'month'} label={'Month'} />
|
||||
<Field name={'dayOfWeek'} label={'Day of week'} />
|
||||
</div>
|
||||
<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
|
||||
|
@ -112,13 +114,13 @@ const EditScheduleModal = ({ schedule }: Props) => {
|
|||
description={'Show the cron cheatsheet for some examples.'}
|
||||
label={'Show Cheatsheet'}
|
||||
defaultChecked={showCheatsheet}
|
||||
onChange={() => setShowCheetsheet(s => !s)}
|
||||
onChange={() => setShowCheetsheet((s) => !s)}
|
||||
/>
|
||||
{showCheatsheet &&
|
||||
{showCheatsheet && (
|
||||
<div css={tw`block md:flex w-full`}>
|
||||
<ScheduleCheatsheetCards/>
|
||||
<ScheduleCheatsheetCards />
|
||||
</div>
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
<div css={tw`mt-6 bg-neutral-700 border border-neutral-800 shadow-inner p-4 rounded`}>
|
||||
<FormikSwitch
|
||||
|
|
|
@ -8,11 +8,11 @@ interface Props {
|
|||
}
|
||||
|
||||
export default ({ schedule }: Props) => {
|
||||
const [ visible, setVisible ] = useState(false);
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<TaskDetailsModal schedule={schedule} visible={visible} onModalDismissed={() => setVisible(false)}/>
|
||||
<TaskDetailsModal schedule={schedule} visible={visible} onModalDismissed={() => setVisible(false)} />
|
||||
<Button onClick={() => setVisible(true)} className={'flex-1'}>
|
||||
New Task
|
||||
</Button>
|
||||
|
|
|
@ -7,11 +7,11 @@ import useFlash from '@/plugins/useFlash';
|
|||
import { Schedule } from '@/api/server/schedules/getServerSchedules';
|
||||
|
||||
const RunScheduleButton = ({ schedule }: { schedule: Schedule }) => {
|
||||
const [ loading, setLoading ] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { clearFlashes, clearAndAddHttpError } = useFlash();
|
||||
|
||||
const id = ServerContext.useStoreState(state => state.server.data!.id);
|
||||
const appendSchedule = ServerContext.useStoreActions(actions => actions.schedules.appendSchedule);
|
||||
const id = ServerContext.useStoreState((state) => state.server.data!.id);
|
||||
const appendSchedule = ServerContext.useStoreActions((actions) => actions.schedules.appendSchedule);
|
||||
|
||||
const onTriggerExecute = useCallback(() => {
|
||||
clearFlashes('schedule');
|
||||
|
@ -21,7 +21,7 @@ const RunScheduleButton = ({ schedule }: { schedule: Schedule }) => {
|
|||
setLoading(false);
|
||||
appendSchedule({ ...schedule, isProcessing: true });
|
||||
})
|
||||
.catch(error => {
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
clearAndAddHttpError({ error, key: 'schedules' });
|
||||
})
|
||||
|
@ -30,7 +30,7 @@ const RunScheduleButton = ({ schedule }: { schedule: Schedule }) => {
|
|||
|
||||
return (
|
||||
<>
|
||||
<SpinnerOverlay visible={loading} size={'large'}/>
|
||||
<SpinnerOverlay visible={loading} size={'large'} />
|
||||
<Button
|
||||
variant={Button.Variants.Secondary}
|
||||
className={'flex-1 sm:flex-none'}
|
||||
|
|
|
@ -18,19 +18,19 @@ export default () => {
|
|||
const match = useRouteMatch();
|
||||
const history = useHistory();
|
||||
|
||||
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
|
||||
const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid);
|
||||
const { clearFlashes, addError } = useFlash();
|
||||
const [ loading, setLoading ] = useState(true);
|
||||
const [ visible, setVisible ] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
const schedules = ServerContext.useStoreState(state => state.schedules.data);
|
||||
const setSchedules = ServerContext.useStoreActions(actions => actions.schedules.setSchedules);
|
||||
const schedules = ServerContext.useStoreState((state) => state.schedules.data);
|
||||
const setSchedules = ServerContext.useStoreActions((actions) => actions.schedules.setSchedules);
|
||||
|
||||
useEffect(() => {
|
||||
clearFlashes('schedules');
|
||||
getServerSchedules(uuid)
|
||||
.then(schedules => setSchedules(schedules))
|
||||
.catch(error => {
|
||||
.then((schedules) => setSchedules(schedules))
|
||||
.catch((error) => {
|
||||
addError({ message: httpErrorToHuman(error), key: 'schedules' });
|
||||
console.error(error);
|
||||
})
|
||||
|
@ -39,42 +39,41 @@ export default () => {
|
|||
|
||||
return (
|
||||
<ServerContentBlock title={'Schedules'}>
|
||||
<FlashMessageRender byKey={'schedules'} css={tw`mb-4`}/>
|
||||
{(!schedules.length && loading) ?
|
||||
<Spinner size={'large'} centered/>
|
||||
:
|
||||
<FlashMessageRender byKey={'schedules'} css={tw`mb-4`} />
|
||||
{!schedules.length && loading ? (
|
||||
<Spinner size={'large'} centered />
|
||||
) : (
|
||||
<>
|
||||
{
|
||||
schedules.length === 0 ?
|
||||
<p css={tw`text-sm text-center text-neutral-300`}>
|
||||
There are no schedules configured for this server.
|
||||
</p>
|
||||
:
|
||||
schedules.map(schedule => (
|
||||
<GreyRowBox
|
||||
as={'a'}
|
||||
key={schedule.id}
|
||||
href={`${match.url}/${schedule.id}`}
|
||||
css={tw`cursor-pointer mb-2 flex-wrap`}
|
||||
onClick={(e: any) => {
|
||||
e.preventDefault();
|
||||
history.push(`${match.url}/${schedule.id}`);
|
||||
}}
|
||||
>
|
||||
<ScheduleRow schedule={schedule}/>
|
||||
</GreyRowBox>
|
||||
))
|
||||
}
|
||||
{schedules.length === 0 ? (
|
||||
<p css={tw`text-sm text-center text-neutral-300`}>
|
||||
There are no schedules configured for this server.
|
||||
</p>
|
||||
) : (
|
||||
schedules.map((schedule) => (
|
||||
<GreyRowBox
|
||||
as={'a'}
|
||||
key={schedule.id}
|
||||
href={`${match.url}/${schedule.id}`}
|
||||
css={tw`cursor-pointer mb-2 flex-wrap`}
|
||||
onClick={(e: any) => {
|
||||
e.preventDefault();
|
||||
history.push(`${match.url}/${schedule.id}`);
|
||||
}}
|
||||
>
|
||||
<ScheduleRow schedule={schedule} />
|
||||
</GreyRowBox>
|
||||
))
|
||||
)}
|
||||
<Can action={'schedule.create'}>
|
||||
<div css={tw`mt-8 flex justify-end`}>
|
||||
<EditScheduleModal visible={visible} onModalDismissed={() => setVisible(false)}/>
|
||||
<EditScheduleModal visible={visible} onModalDismissed={() => setVisible(false)} />
|
||||
<Button type={'button'} onClick={() => setVisible(true)}>
|
||||
Create schedule
|
||||
</Button>
|
||||
</div>
|
||||
</Can>
|
||||
</>
|
||||
}
|
||||
)}
|
||||
</ServerContentBlock>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -44,15 +44,18 @@ export default () => {
|
|||
const history = useHistory();
|
||||
const { id: scheduleId } = useParams<Params>();
|
||||
|
||||
const id = ServerContext.useStoreState(state => state.server.data!.id);
|
||||
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
|
||||
const id = ServerContext.useStoreState((state) => state.server.data!.id);
|
||||
const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid);
|
||||
|
||||
const { clearFlashes, clearAndAddHttpError } = useFlash();
|
||||
const [ isLoading, setIsLoading ] = useState(true);
|
||||
const [ showEditModal, setShowEditModal ] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
|
||||
const schedule = ServerContext.useStoreState(st => st.schedules.data.find(s => s.id === Number(scheduleId)), isEqual);
|
||||
const appendSchedule = ServerContext.useStoreActions(actions => actions.schedules.appendSchedule);
|
||||
const schedule = ServerContext.useStoreState(
|
||||
(st) => st.schedules.data.find((s) => s.id === Number(scheduleId)),
|
||||
isEqual
|
||||
);
|
||||
const appendSchedule = ServerContext.useStoreActions((actions) => actions.schedules.appendSchedule);
|
||||
|
||||
useEffect(() => {
|
||||
if (schedule?.id === Number(scheduleId)) {
|
||||
|
@ -62,56 +65,58 @@ export default () => {
|
|||
|
||||
clearFlashes('schedules');
|
||||
getServerSchedule(uuid, Number(scheduleId))
|
||||
.then(schedule => appendSchedule(schedule))
|
||||
.catch(error => {
|
||||
.then((schedule) => appendSchedule(schedule))
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
clearAndAddHttpError({ error, key: 'schedules' });
|
||||
})
|
||||
.then(() => setIsLoading(false));
|
||||
}, [ scheduleId ]);
|
||||
}, [scheduleId]);
|
||||
|
||||
const toggleEditModal = useCallback(() => {
|
||||
setShowEditModal(s => !s);
|
||||
setShowEditModal((s) => !s);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<PageContentBlock title={'Schedules'}>
|
||||
<FlashMessageRender byKey={'schedules'} css={tw`mb-4`}/>
|
||||
{!schedule || isLoading ?
|
||||
<Spinner size={'large'} centered/>
|
||||
:
|
||||
<FlashMessageRender byKey={'schedules'} css={tw`mb-4`} />
|
||||
{!schedule || isLoading ? (
|
||||
<Spinner size={'large'} centered />
|
||||
) : (
|
||||
<>
|
||||
<ScheduleCronRow cron={schedule.cron} css={tw`sm:hidden bg-neutral-700 rounded mb-4 p-3`}/>
|
||||
<ScheduleCronRow cron={schedule.cron} css={tw`sm:hidden bg-neutral-700 rounded mb-4 p-3`} />
|
||||
<div css={tw`rounded shadow`}>
|
||||
<div css={tw`sm:flex items-center bg-neutral-900 p-3 sm:p-6 border-b-4 border-neutral-600 rounded-t`}>
|
||||
<div
|
||||
css={tw`sm:flex items-center bg-neutral-900 p-3 sm:p-6 border-b-4 border-neutral-600 rounded-t`}
|
||||
>
|
||||
<div css={tw`flex-1`}>
|
||||
<h3 css={tw`flex items-center text-neutral-100 text-2xl`}>
|
||||
{schedule.name}
|
||||
{schedule.isProcessing ?
|
||||
{schedule.isProcessing ? (
|
||||
<span
|
||||
css={tw`flex items-center rounded-full px-2 py-px text-xs ml-4 uppercase bg-neutral-600 text-white`}
|
||||
>
|
||||
<Spinner css={tw`w-3! h-3! mr-2`}/>
|
||||
<Spinner css={tw`w-3! h-3! mr-2`} />
|
||||
Processing
|
||||
</span>
|
||||
:
|
||||
<ActivePill active={schedule.isActive}/>
|
||||
}
|
||||
) : (
|
||||
<ActivePill active={schedule.isActive} />
|
||||
)}
|
||||
</h3>
|
||||
<p css={tw`mt-1 text-sm text-neutral-200`}>
|
||||
Last run at:
|
||||
{schedule.lastRunAt ?
|
||||
format(schedule.lastRunAt, 'MMM do \'at\' h:mma')
|
||||
:
|
||||
{schedule.lastRunAt ? (
|
||||
format(schedule.lastRunAt, "MMM do 'at' h:mma")
|
||||
) : (
|
||||
<span css={tw`text-neutral-300`}>n/a</span>
|
||||
}
|
||||
)}
|
||||
<span css={tw`ml-4 pl-4 border-l-4 border-neutral-600 py-px`}>
|
||||
Next run at:
|
||||
{schedule.nextRunAt ?
|
||||
format(schedule.nextRunAt, 'MMM do \'at\' h:mma')
|
||||
:
|
||||
{schedule.nextRunAt ? (
|
||||
format(schedule.nextRunAt, "MMM do 'at' h:mma")
|
||||
) : (
|
||||
<span css={tw`text-neutral-300`}>n/a</span>
|
||||
}
|
||||
)}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
@ -120,28 +125,34 @@ export default () => {
|
|||
<Button.Text className={'flex-1 mr-4'} onClick={toggleEditModal}>
|
||||
Edit
|
||||
</Button.Text>
|
||||
<NewTaskButton schedule={schedule}/>
|
||||
<NewTaskButton schedule={schedule} />
|
||||
</Can>
|
||||
</div>
|
||||
</div>
|
||||
<div css={tw`hidden sm:grid grid-cols-5 md:grid-cols-5 gap-4 mb-4 mt-4`}>
|
||||
<CronBox title={'Minute'} value={schedule.cron.minute}/>
|
||||
<CronBox title={'Hour'} value={schedule.cron.hour}/>
|
||||
<CronBox title={'Day (Month)'} value={schedule.cron.dayOfMonth}/>
|
||||
<CronBox title={'Month'} value={schedule.cron.month}/>
|
||||
<CronBox title={'Day (Week)'} value={schedule.cron.dayOfWeek}/>
|
||||
<CronBox title={'Minute'} value={schedule.cron.minute} />
|
||||
<CronBox title={'Hour'} value={schedule.cron.hour} />
|
||||
<CronBox title={'Day (Month)'} value={schedule.cron.dayOfMonth} />
|
||||
<CronBox title={'Month'} value={schedule.cron.month} />
|
||||
<CronBox title={'Day (Week)'} value={schedule.cron.dayOfWeek} />
|
||||
</div>
|
||||
<div css={tw`bg-neutral-700 rounded-b`}>
|
||||
{schedule.tasks.length > 0 ?
|
||||
schedule.tasks.sort((a, b) => a.sequenceId === b.sequenceId ? 0 : (a.sequenceId > b.sequenceId ? 1 : -1)).map(task => (
|
||||
<ScheduleTaskRow key={`${schedule.id}_${task.id}`} task={task} schedule={schedule}/>
|
||||
))
|
||||
:
|
||||
null
|
||||
}
|
||||
{schedule.tasks.length > 0
|
||||
? schedule.tasks
|
||||
.sort((a, b) =>
|
||||
a.sequenceId === b.sequenceId ? 0 : a.sequenceId > b.sequenceId ? 1 : -1
|
||||
)
|
||||
.map((task) => (
|
||||
<ScheduleTaskRow
|
||||
key={`${schedule.id}_${task.id}`}
|
||||
task={task}
|
||||
schedule={schedule}
|
||||
/>
|
||||
))
|
||||
: null}
|
||||
</div>
|
||||
</div>
|
||||
<EditScheduleModal visible={showEditModal} schedule={schedule} onModalDismissed={toggleEditModal}/>
|
||||
<EditScheduleModal visible={showEditModal} schedule={schedule} onModalDismissed={toggleEditModal} />
|
||||
<div css={tw`mt-6 flex sm:justify-end`}>
|
||||
<Can action={'schedule.delete'}>
|
||||
<DeleteScheduleButton
|
||||
|
@ -149,14 +160,14 @@ export default () => {
|
|||
onDeleted={() => history.push(`/server/${id}/schedules`)}
|
||||
/>
|
||||
</Can>
|
||||
{schedule.tasks.length > 0 &&
|
||||
<Can action={'schedule.update'}>
|
||||
<RunScheduleButton schedule={schedule}/>
|
||||
</Can>
|
||||
}
|
||||
{schedule.tasks.length > 0 && (
|
||||
<Can action={'schedule.update'}>
|
||||
<RunScheduleButton schedule={schedule} />
|
||||
</Can>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
)}
|
||||
</PageContentBlock>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -9,13 +9,12 @@ import ScheduleCronRow from '@/components/server/schedules/ScheduleCronRow';
|
|||
export default ({ schedule }: { schedule: Schedule }) => (
|
||||
<>
|
||||
<div css={tw`hidden md:block`}>
|
||||
<FontAwesomeIcon icon={faCalendarAlt} fixedWidth/>
|
||||
<FontAwesomeIcon icon={faCalendarAlt} fixedWidth />
|
||||
</div>
|
||||
<div css={tw`flex-1 md:ml-4`}>
|
||||
<p>{schedule.name}</p>
|
||||
<p css={tw`text-xs text-neutral-400`}>
|
||||
Last run
|
||||
at: {schedule.lastRunAt ? format(schedule.lastRunAt, 'MMM do \'at\' h:mma') : 'never'}
|
||||
Last run at: {schedule.lastRunAt ? format(schedule.lastRunAt, "MMM do 'at' h:mma") : 'never'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
|
@ -28,7 +27,7 @@ export default ({ schedule }: { schedule: Schedule }) => (
|
|||
{schedule.isActive ? 'Active' : 'Inactive'}
|
||||
</p>
|
||||
</div>
|
||||
<ScheduleCronRow cron={schedule.cron} css={tw`mx-auto sm:mx-8 w-full sm:w-auto mt-4 sm:mt-0`}/>
|
||||
<ScheduleCronRow cron={schedule.cron} css={tw`mx-auto sm:mx-8 w-full sm:w-auto mt-4 sm:mt-0`} />
|
||||
<div>
|
||||
<p
|
||||
css={[
|
||||
|
@ -36,11 +35,7 @@ export default ({ schedule }: { schedule: Schedule }) => (
|
|||
schedule.isActive && !schedule.isProcessing ? tw`bg-green-600` : tw`bg-neutral-400`,
|
||||
]}
|
||||
>
|
||||
{schedule.isProcessing ?
|
||||
'Processing'
|
||||
:
|
||||
schedule.isActive ? 'Active' : 'Inactive'
|
||||
}
|
||||
{schedule.isProcessing ? 'Processing' : schedule.isActive ? 'Active' : 'Inactive'}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
|
|
|
@ -26,47 +26,49 @@ interface Props {
|
|||
task: Task;
|
||||
}
|
||||
|
||||
const getActionDetails = (action: string): [ string, any ] => {
|
||||
const getActionDetails = (action: string): [string, any] => {
|
||||
switch (action) {
|
||||
case 'command':
|
||||
return [ 'Send Command', faCode ];
|
||||
return ['Send Command', faCode];
|
||||
case 'power':
|
||||
return [ 'Send Power Action', faToggleOn ];
|
||||
return ['Send Power Action', faToggleOn];
|
||||
case 'backup':
|
||||
return [ 'Create Backup', faFileArchive ];
|
||||
return ['Create Backup', faFileArchive];
|
||||
default:
|
||||
return [ 'Unknown Action', faCode ];
|
||||
return ['Unknown Action', faCode];
|
||||
}
|
||||
};
|
||||
|
||||
export default ({ schedule, task }: Props) => {
|
||||
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
|
||||
const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid);
|
||||
const { clearFlashes, addError } = useFlash();
|
||||
const [ visible, setVisible ] = useState(false);
|
||||
const [ isLoading, setIsLoading ] = useState(false);
|
||||
const [ isEditing, setIsEditing ] = useState(false);
|
||||
const appendSchedule = ServerContext.useStoreActions(actions => actions.schedules.appendSchedule);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const appendSchedule = ServerContext.useStoreActions((actions) => actions.schedules.appendSchedule);
|
||||
|
||||
const onConfirmDeletion = () => {
|
||||
setIsLoading(true);
|
||||
clearFlashes('schedules');
|
||||
deleteScheduleTask(uuid, schedule.id, task.id)
|
||||
.then(() => appendSchedule({
|
||||
...schedule,
|
||||
tasks: schedule.tasks.filter(t => t.id !== task.id),
|
||||
}))
|
||||
.catch(error => {
|
||||
.then(() =>
|
||||
appendSchedule({
|
||||
...schedule,
|
||||
tasks: schedule.tasks.filter((t) => t.id !== task.id),
|
||||
})
|
||||
)
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
setIsLoading(false);
|
||||
addError({ message: httpErrorToHuman(error), key: 'schedules' });
|
||||
});
|
||||
};
|
||||
|
||||
const [ title, icon ] = getActionDetails(task.action);
|
||||
const [title, icon] = getActionDetails(task.action);
|
||||
|
||||
return (
|
||||
<div css={tw`sm:flex items-center p-3 sm:p-6 border-b border-neutral-800`}>
|
||||
<SpinnerOverlay visible={isLoading} fixed size={'large'}/>
|
||||
<SpinnerOverlay visible={isLoading} fixed size={'large'} />
|
||||
<TaskDetailsModal
|
||||
schedule={schedule}
|
||||
task={task}
|
||||
|
@ -82,38 +84,39 @@ export default ({ schedule, task }: Props) => {
|
|||
>
|
||||
Are you sure you want to delete this task? This action cannot be undone.
|
||||
</ConfirmationModal>
|
||||
<FontAwesomeIcon icon={icon} css={tw`text-lg text-white hidden md:block`}/>
|
||||
<FontAwesomeIcon icon={icon} css={tw`text-lg text-white hidden md:block`} />
|
||||
<div css={tw`flex-none sm:flex-1 w-full sm:w-auto overflow-x-auto`}>
|
||||
<p css={tw`md:ml-6 text-neutral-200 uppercase text-sm`}>
|
||||
{title}
|
||||
</p>
|
||||
{task.payload &&
|
||||
<div css={tw`md:ml-6 mt-2`}>
|
||||
{task.action === 'backup' &&
|
||||
<p css={tw`text-xs uppercase text-neutral-400 mb-1`}>Ignoring files & folders:</p>}
|
||||
<div css={tw`font-mono bg-neutral-800 rounded py-1 px-2 text-sm w-auto inline-block whitespace-pre-wrap break-all`}>
|
||||
{task.payload}
|
||||
<p css={tw`md:ml-6 text-neutral-200 uppercase text-sm`}>{title}</p>
|
||||
{task.payload && (
|
||||
<div css={tw`md:ml-6 mt-2`}>
|
||||
{task.action === 'backup' && (
|
||||
<p css={tw`text-xs uppercase text-neutral-400 mb-1`}>Ignoring files & folders:</p>
|
||||
)}
|
||||
<div
|
||||
css={tw`font-mono bg-neutral-800 rounded py-1 px-2 text-sm w-auto inline-block whitespace-pre-wrap break-all`}
|
||||
>
|
||||
{task.payload}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
<div css={tw`mt-3 sm:mt-0 flex items-center w-full sm:w-auto`}>
|
||||
{task.continueOnFailure &&
|
||||
<div css={tw`mr-6`}>
|
||||
<div css={tw`flex items-center px-2 py-1 bg-yellow-500 text-yellow-800 text-sm rounded-full`}>
|
||||
<Icon icon={faArrowCircleDown} css={tw`w-3 h-3 mr-2`}/>
|
||||
Continues on Failure
|
||||
{task.continueOnFailure && (
|
||||
<div css={tw`mr-6`}>
|
||||
<div css={tw`flex items-center px-2 py-1 bg-yellow-500 text-yellow-800 text-sm rounded-full`}>
|
||||
<Icon icon={faArrowCircleDown} css={tw`w-3 h-3 mr-2`} />
|
||||
Continues on Failure
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
{task.sequenceId > 1 && task.timeOffset > 0 &&
|
||||
<div css={tw`mr-6`}>
|
||||
<div css={tw`flex items-center px-2 py-1 bg-neutral-500 text-sm rounded-full`}>
|
||||
<Icon icon={faClock} css={tw`w-3 h-3 mr-2`}/>
|
||||
{task.timeOffset}s later
|
||||
)}
|
||||
{task.sequenceId > 1 && task.timeOffset > 0 && (
|
||||
<div css={tw`mr-6`}>
|
||||
<div css={tw`flex items-center px-2 py-1 bg-neutral-500 text-sm rounded-full`}>
|
||||
<Icon icon={faClock} css={tw`w-3 h-3 mr-2`} />
|
||||
{task.timeOffset}s later
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
)}
|
||||
<Can action={'schedule.update'}>
|
||||
<button
|
||||
type={'button'}
|
||||
|
@ -121,7 +124,7 @@ export default ({ schedule, task }: Props) => {
|
|||
css={tw`block text-sm p-2 text-neutral-500 hover:text-neutral-100 transition-colors duration-150 mr-4 ml-auto sm:ml-0`}
|
||||
onClick={() => setIsEditing(true)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPencilAlt}/>
|
||||
<FontAwesomeIcon icon={faPencilAlt} />
|
||||
</button>
|
||||
</Can>
|
||||
<Can action={'schedule.update'}>
|
||||
|
@ -131,7 +134,7 @@ export default ({ schedule, task }: Props) => {
|
|||
css={tw`block text-sm p-2 text-neutral-500 hover:text-red-600 transition-colors duration-150`}
|
||||
onClick={() => setVisible(true)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrashAlt}/>
|
||||
<FontAwesomeIcon icon={faTrashAlt} />
|
||||
</button>
|
||||
</Can>
|
||||
</div>
|
||||
|
|
|
@ -33,22 +33,23 @@ interface Values {
|
|||
}
|
||||
|
||||
const schema = object().shape({
|
||||
action: string().required().oneOf([ 'command', 'power', 'backup' ]),
|
||||
action: string().required().oneOf(['command', 'power', 'backup']),
|
||||
payload: string().when('action', {
|
||||
is: v => v !== 'backup',
|
||||
is: (v) => v !== 'backup',
|
||||
then: string().required('A task payload must be provided.'),
|
||||
otherwise: string(),
|
||||
}),
|
||||
continueOnFailure: boolean(),
|
||||
timeOffset: number().typeError('The time offset must be a valid number between 0 and 900.')
|
||||
timeOffset: number()
|
||||
.typeError('The time offset must be a valid number between 0 and 900.')
|
||||
.required('A time offset value must be provided.')
|
||||
.min(0, 'The time offset must be at least 0 seconds.')
|
||||
.max(900, 'The time offset must be less than 900 seconds.'),
|
||||
});
|
||||
|
||||
const ActionListener = () => {
|
||||
const [ { value }, { initialValue: initialAction } ] = useField<string>('action');
|
||||
const [ , { initialValue: initialPayload }, { setValue, setTouched } ] = useField<string>('payload');
|
||||
const [{ value }, { initialValue: initialAction }] = useField<string>('action');
|
||||
const [, { initialValue: initialPayload }, { setValue, setTouched }] = useField<string>('payload');
|
||||
|
||||
useEffect(() => {
|
||||
if (value !== initialAction) {
|
||||
|
@ -58,7 +59,7 @@ const ActionListener = () => {
|
|||
setValue(initialPayload || '');
|
||||
setTouched(false);
|
||||
}
|
||||
}, [ value ]);
|
||||
}, [value]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
@ -67,9 +68,9 @@ const TaskDetailsModal = ({ schedule, task }: Props) => {
|
|||
const { dismiss } = useContext(ModalContext);
|
||||
const { clearFlashes, addError } = useFlash();
|
||||
|
||||
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
|
||||
const appendSchedule = ServerContext.useStoreActions(actions => actions.schedules.appendSchedule);
|
||||
const backupLimit = ServerContext.useStoreState(state => state.server.data!.featureLimits.backups);
|
||||
const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid);
|
||||
const appendSchedule = ServerContext.useStoreActions((actions) => actions.schedules.appendSchedule);
|
||||
const backupLimit = ServerContext.useStoreState((state) => state.server.data!.featureLimits.backups);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
|
@ -81,19 +82,22 @@ const TaskDetailsModal = ({ schedule, task }: Props) => {
|
|||
clearFlashes('schedule:task');
|
||||
if (backupLimit === 0 && values.action === 'backup') {
|
||||
setSubmitting(false);
|
||||
addError({ message: 'A backup task cannot be created when the server\'s backup limit is set to 0.', key: 'schedule:task' });
|
||||
addError({
|
||||
message: "A backup task cannot be created when the server's backup limit is set to 0.",
|
||||
key: 'schedule:task',
|
||||
});
|
||||
} else {
|
||||
createOrUpdateScheduleTask(uuid, schedule.id, task?.id, values)
|
||||
.then(task => {
|
||||
let tasks = schedule.tasks.map(t => t.id === task.id ? task : t);
|
||||
if (!schedule.tasks.find(t => t.id === task.id)) {
|
||||
tasks = [ ...tasks, task ];
|
||||
.then((task) => {
|
||||
let tasks = schedule.tasks.map((t) => (t.id === task.id ? task : t));
|
||||
if (!schedule.tasks.find((t) => t.id === task.id)) {
|
||||
tasks = [...tasks, task];
|
||||
}
|
||||
|
||||
appendSchedule({ ...schedule, tasks });
|
||||
dismiss();
|
||||
})
|
||||
.catch(error => {
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
setSubmitting(false);
|
||||
addError({ message: httpErrorToHuman(error), key: 'schedule:task' });
|
||||
|
@ -114,12 +118,12 @@ const TaskDetailsModal = ({ schedule, task }: Props) => {
|
|||
>
|
||||
{({ isSubmitting, values }) => (
|
||||
<Form css={tw`m-0`}>
|
||||
<FlashMessageRender byKey={'schedule:task'} css={tw`mb-4`}/>
|
||||
<FlashMessageRender byKey={'schedule:task'} css={tw`mb-4`} />
|
||||
<h2 css={tw`text-2xl mb-6`}>{task ? 'Edit Task' : 'Create Task'}</h2>
|
||||
<div css={tw`flex`}>
|
||||
<div css={tw`mr-2 w-1/3`}>
|
||||
<Label>Action</Label>
|
||||
<ActionListener/>
|
||||
<ActionListener />
|
||||
<FormikFieldWrapper name={'action'}>
|
||||
<FormikField as={Select} name={'action'}>
|
||||
<option value={'command'}>Send command</option>
|
||||
|
@ -132,42 +136,45 @@ const TaskDetailsModal = ({ schedule, task }: Props) => {
|
|||
<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.'}
|
||||
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>
|
||||
<div css={tw`mt-6`}>
|
||||
{values.action === 'command' ?
|
||||
{values.action === 'command' ? (
|
||||
<div>
|
||||
<Label>Payload</Label>
|
||||
<FormikFieldWrapper name={'payload'}>
|
||||
<FormikField as={Textarea} name={'payload'} rows={6}/>
|
||||
<FormikField as={Textarea} name={'payload'} rows={6} />
|
||||
</FormikFieldWrapper>
|
||||
</div>
|
||||
:
|
||||
values.action === 'power' ?
|
||||
<div>
|
||||
<Label>Payload</Label>
|
||||
<FormikFieldWrapper name={'payload'}>
|
||||
<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>
|
||||
<option value={'kill'}>Terminate the server</option>
|
||||
</FormikField>
|
||||
</FormikFieldWrapper>
|
||||
</div>
|
||||
:
|
||||
<div>
|
||||
<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. If you have reached your backup limit, the oldest backup will be rotated.'}
|
||||
>
|
||||
<FormikField as={Textarea} name={'payload'} rows={6}/>
|
||||
</FormikFieldWrapper>
|
||||
</div>
|
||||
}
|
||||
) : values.action === 'power' ? (
|
||||
<div>
|
||||
<Label>Payload</Label>
|
||||
<FormikFieldWrapper name={'payload'}>
|
||||
<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>
|
||||
<option value={'kill'}>Terminate the server</option>
|
||||
</FormikField>
|
||||
</FormikFieldWrapper>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<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. If you have reached your backup limit, the oldest backup will be rotated.'
|
||||
}
|
||||
>
|
||||
<FormikField as={Textarea} name={'payload'} rows={6} />
|
||||
</FormikFieldWrapper>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div css={tw`mt-6 bg-neutral-700 border border-neutral-800 shadow-inner p-4 rounded`}>
|
||||
<FormikSwitch
|
||||
|
|
|
@ -10,8 +10,8 @@ import { Button } from '@/components/elements/button/index';
|
|||
import { Dialog } from '@/components/elements/dialog';
|
||||
|
||||
export default () => {
|
||||
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
|
||||
const [ modalVisible, setModalVisible ] = useState(false);
|
||||
const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const { addFlash, clearFlashes } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
|
||||
|
||||
const reinstall = () => {
|
||||
|
@ -24,7 +24,7 @@ export default () => {
|
|||
message: 'Your server has begun the reinstallation process.',
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
|
||||
addFlash({ key: 'settings', type: 'error', message: httpErrorToHuman(error) });
|
||||
|
@ -49,8 +49,8 @@ export default () => {
|
|||
you wish to continue?
|
||||
</Dialog.Confirm>
|
||||
<p css={tw`text-sm`}>
|
||||
Reinstalling your server will stop it, and then re-run the installation script that initially
|
||||
set it up.
|
||||
Reinstalling your server will stop it, and then re-run the installation script that initially 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.
|
||||
|
|
|
@ -21,18 +21,11 @@ const RenameServerBox = () => {
|
|||
|
||||
return (
|
||||
<TitledGreyBox title={'Change Server Name'} css={tw`relative`}>
|
||||
<SpinnerOverlay visible={isSubmitting}/>
|
||||
<SpinnerOverlay visible={isSubmitting} />
|
||||
<Form css={tw`mb-0`}>
|
||||
<Field
|
||||
id={'name'}
|
||||
name={'name'}
|
||||
label={'Server Name'}
|
||||
type={'text'}
|
||||
/>
|
||||
<Field id={'name'} name={'name'} label={'Server Name'} type={'text'} />
|
||||
<div css={tw`mt-6 text-right`}>
|
||||
<Button type={'submit'}>
|
||||
Save
|
||||
</Button>
|
||||
<Button type={'submit'}>Save</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</TitledGreyBox>
|
||||
|
@ -40,15 +33,15 @@ const RenameServerBox = () => {
|
|||
};
|
||||
|
||||
export default () => {
|
||||
const server = ServerContext.useStoreState(state => state.server.data!);
|
||||
const setServer = ServerContext.useStoreActions(actions => actions.server.setServer);
|
||||
const server = ServerContext.useStoreState((state) => state.server.data!);
|
||||
const setServer = ServerContext.useStoreActions((actions) => actions.server.setServer);
|
||||
const { addError, clearFlashes } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
|
||||
|
||||
const submit = ({ name }: Values, { setSubmitting }: FormikHelpers<Values>) => {
|
||||
clearFlashes('settings');
|
||||
renameServer(server.uuid, name)
|
||||
.then(() => setServer({ ...server, name }))
|
||||
.catch(error => {
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
addError({ key: 'settings', message: httpErrorToHuman(error) });
|
||||
})
|
||||
|
@ -65,7 +58,7 @@ export default () => {
|
|||
name: string().required().min(1),
|
||||
})}
|
||||
>
|
||||
<RenameServerBox/>
|
||||
<RenameServerBox />
|
||||
</Formik>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -16,15 +16,15 @@ import { ip } from '@/lib/formatters';
|
|||
import { Button } from '@/components/elements/button/index';
|
||||
|
||||
export default () => {
|
||||
const username = useStoreState(state => state.user.data!.username);
|
||||
const id = ServerContext.useStoreState(state => state.server.data!.id);
|
||||
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
|
||||
const node = ServerContext.useStoreState(state => state.server.data!.node);
|
||||
const sftp = ServerContext.useStoreState(state => state.server.data!.sftpDetails, isEqual);
|
||||
const username = useStoreState((state) => state.user.data!.username);
|
||||
const id = ServerContext.useStoreState((state) => state.server.data!.id);
|
||||
const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid);
|
||||
const node = ServerContext.useStoreState((state) => state.server.data!.node);
|
||||
const sftp = ServerContext.useStoreState((state) => state.server.data!.sftpDetails, isEqual);
|
||||
|
||||
return (
|
||||
<ServerContentBlock title={'Settings'}>
|
||||
<FlashMessageRender byKey={'settings'} css={tw`mb-4`}/>
|
||||
<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'}>
|
||||
|
@ -32,21 +32,13 @@ export default () => {
|
|||
<div>
|
||||
<Label>Server Address</Label>
|
||||
<CopyOnClick text={`sftp://${ip(sftp.ip)}:${sftp.port}`}>
|
||||
<Input
|
||||
type={'text'}
|
||||
value={`sftp://${ip(sftp.ip)}:${sftp.port}`}
|
||||
readOnly
|
||||
/>
|
||||
<Input type={'text'} value={`sftp://${ip(sftp.ip)}:${sftp.port}`} readOnly />
|
||||
</CopyOnClick>
|
||||
</div>
|
||||
<div css={tw`mt-6`}>
|
||||
<Label>Username</Label>
|
||||
<CopyOnClick text={`${username}.${id}`}>
|
||||
<Input
|
||||
type={'text'}
|
||||
value={`${username}.${id}`}
|
||||
readOnly
|
||||
/>
|
||||
<Input type={'text'} value={`${username}.${id}`} readOnly />
|
||||
</CopyOnClick>
|
||||
</div>
|
||||
<div css={tw`mt-6 flex items-center`}>
|
||||
|
@ -81,11 +73,11 @@ export default () => {
|
|||
<div css={tw`w-full mt-6 md:flex-1 md:mt-0`}>
|
||||
<Can action={'settings.rename'}>
|
||||
<div css={tw`mb-6 md:mb-10`}>
|
||||
<RenameServerBox/>
|
||||
<RenameServerBox />
|
||||
</div>
|
||||
</Can>
|
||||
<Can action={'settings.reinstall'}>
|
||||
<ReinstallServerBox/>
|
||||
<ReinstallServerBox />
|
||||
</Can>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -17,23 +17,30 @@ import InputSpinner from '@/components/elements/InputSpinner';
|
|||
import useFlash from '@/plugins/useFlash';
|
||||
|
||||
const StartupContainer = () => {
|
||||
const [ loading, setLoading ] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { clearFlashes, clearAndAddHttpError } = useFlash();
|
||||
|
||||
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
|
||||
const variables = ServerContext.useStoreState(({ server }) => ({
|
||||
variables: server.data!.variables,
|
||||
invocation: server.data!.invocation,
|
||||
dockerImage: server.data!.dockerImage,
|
||||
}), isEqual);
|
||||
const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid);
|
||||
const variables = ServerContext.useStoreState(
|
||||
({ server }) => ({
|
||||
variables: server.data!.variables,
|
||||
invocation: server.data!.invocation,
|
||||
dockerImage: server.data!.dockerImage,
|
||||
}),
|
||||
isEqual
|
||||
);
|
||||
|
||||
const { data, error, isValidating, mutate } = getServerStartup(uuid, {
|
||||
...variables,
|
||||
dockerImages: { [variables.dockerImage]: variables.dockerImage },
|
||||
});
|
||||
|
||||
const setServerFromState = ServerContext.useStoreActions(actions => actions.server.setServerFromState);
|
||||
const isCustomImage = data && !Object.values(data.dockerImages).map(v => v.toLowerCase()).includes(variables.dockerImage.toLowerCase());
|
||||
const setServerFromState = ServerContext.useStoreActions((actions) => actions.server.setServerFromState);
|
||||
const isCustomImage =
|
||||
data &&
|
||||
!Object.values(data.dockerImages)
|
||||
.map((v) => v.toLowerCase())
|
||||
.includes(variables.dockerImage.toLowerCase());
|
||||
|
||||
useEffect(() => {
|
||||
// Since we're passing in initial data this will not trigger on mount automatically. We
|
||||
|
@ -45,86 +52,85 @@ const StartupContainer = () => {
|
|||
useDeepCompareEffect(() => {
|
||||
if (!data) return;
|
||||
|
||||
setServerFromState(s => ({
|
||||
setServerFromState((s) => ({
|
||||
...s,
|
||||
invocation: data.invocation,
|
||||
variables: data.variables,
|
||||
}));
|
||||
}, [ data ]);
|
||||
}, [data]);
|
||||
|
||||
const updateSelectedDockerImage = useCallback((v: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
setLoading(true);
|
||||
clearFlashes('startup:image');
|
||||
const updateSelectedDockerImage = useCallback(
|
||||
(v: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
setLoading(true);
|
||||
clearFlashes('startup:image');
|
||||
|
||||
const image = v.currentTarget.value;
|
||||
setSelectedDockerImage(uuid, image)
|
||||
.then(() => setServerFromState(s => ({ ...s, dockerImage: image })))
|
||||
.catch(error => {
|
||||
console.error(error);
|
||||
clearAndAddHttpError({ key: 'startup:image', error });
|
||||
})
|
||||
.then(() => setLoading(false));
|
||||
}, [ uuid ]);
|
||||
const image = v.currentTarget.value;
|
||||
setSelectedDockerImage(uuid, image)
|
||||
.then(() => setServerFromState((s) => ({ ...s, dockerImage: image })))
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
clearAndAddHttpError({ key: 'startup:image', error });
|
||||
})
|
||||
.then(() => setLoading(false));
|
||||
},
|
||||
[uuid]
|
||||
);
|
||||
|
||||
return (
|
||||
!data ?
|
||||
(!error || (error && isValidating)) ?
|
||||
<Spinner centered size={Spinner.Size.LARGE}/>
|
||||
:
|
||||
<ServerError
|
||||
title={'Oops!'}
|
||||
message={httpErrorToHuman(error)}
|
||||
onRetry={() => mutate()}
|
||||
/>
|
||||
:
|
||||
<ServerContentBlock title={'Startup Settings'} showFlashKey={'startup:image'}>
|
||||
<div css={tw`md:flex`}>
|
||||
<TitledGreyBox title={'Startup Command'} css={tw`flex-1`}>
|
||||
<div css={tw`px-1 py-2`}>
|
||||
<p css={tw`font-mono bg-neutral-900 rounded py-2 px-4`}>
|
||||
{data.invocation}
|
||||
return !data ? (
|
||||
!error || (error && isValidating) ? (
|
||||
<Spinner centered size={Spinner.Size.LARGE} />
|
||||
) : (
|
||||
<ServerError title={'Oops!'} message={httpErrorToHuman(error)} onRetry={() => mutate()} />
|
||||
)
|
||||
) : (
|
||||
<ServerContentBlock title={'Startup Settings'} showFlashKey={'startup:image'}>
|
||||
<div css={tw`md:flex`}>
|
||||
<TitledGreyBox title={'Startup Command'} css={tw`flex-1`}>
|
||||
<div css={tw`px-1 py-2`}>
|
||||
<p css={tw`font-mono bg-neutral-900 rounded py-2 px-4`}>{data.invocation}</p>
|
||||
</div>
|
||||
</TitledGreyBox>
|
||||
<TitledGreyBox title={'Docker Image'} css={tw`flex-1 lg:flex-none lg:w-1/3 mt-8 md:mt-0 md:ml-10`}>
|
||||
{Object.keys(data.dockerImages).length > 1 && !isCustomImage ? (
|
||||
<>
|
||||
<InputSpinner visible={loading}>
|
||||
<Select
|
||||
disabled={Object.keys(data.dockerImages).length < 2}
|
||||
onChange={updateSelectedDockerImage}
|
||||
defaultValue={variables.dockerImage}
|
||||
>
|
||||
{Object.keys(data.dockerImages).map((key) => (
|
||||
<option key={data.dockerImages[key]} value={data.dockerImages[key]}>
|
||||
{key}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</InputSpinner>
|
||||
<p css={tw`text-xs text-neutral-300 mt-2`}>
|
||||
This is an advanced feature allowing you to select a Docker image to use when running
|
||||
this server instance.
|
||||
</p>
|
||||
</div>
|
||||
</TitledGreyBox>
|
||||
<TitledGreyBox title={'Docker Image'} css={tw`flex-1 lg:flex-none lg:w-1/3 mt-8 md:mt-0 md:ml-10`}>
|
||||
{Object.keys(data.dockerImages).length > 1 && !isCustomImage ?
|
||||
<>
|
||||
<InputSpinner visible={loading}>
|
||||
<Select
|
||||
disabled={(Object.keys(data.dockerImages)).length < 2}
|
||||
onChange={updateSelectedDockerImage}
|
||||
defaultValue={variables.dockerImage}
|
||||
>
|
||||
{Object.keys(data.dockerImages).map(key => (
|
||||
<option key={data.dockerImages[key]} value={data.dockerImages[key]}>
|
||||
{key}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</InputSpinner>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Input disabled readOnly value={variables.dockerImage} />
|
||||
{isCustomImage && (
|
||||
<p css={tw`text-xs text-neutral-300 mt-2`}>
|
||||
This is an advanced feature allowing you to select a Docker image to use when
|
||||
running this server instance.
|
||||
</p>
|
||||
</>
|
||||
:
|
||||
<>
|
||||
<Input disabled readOnly value={variables.dockerImage}/>
|
||||
{isCustomImage &&
|
||||
<p css={tw`text-xs text-neutral-300 mt-2`}>
|
||||
This {'server\'s'} Docker image has been manually set by an administrator and cannot
|
||||
This {"server's"} Docker image has been manually set by an administrator and cannot
|
||||
be changed through this UI.
|
||||
</p>
|
||||
}
|
||||
</>
|
||||
}
|
||||
</TitledGreyBox>
|
||||
</div>
|
||||
<h3 css={tw`mt-8 mb-2 text-2xl`}>Variables</h3>
|
||||
<div css={tw`grid gap-8 md:grid-cols-2`}>
|
||||
{data.variables.map(variable => <VariableBox key={variable.envVariable} variable={variable}/>)}
|
||||
</div>
|
||||
</ServerContentBlock>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</TitledGreyBox>
|
||||
</div>
|
||||
<h3 css={tw`mt-8 mb-2 text-2xl`}>Variables</h3>
|
||||
<div css={tw`grid gap-8 md:grid-cols-2`}>
|
||||
{data.variables.map((variable) => (
|
||||
<VariableBox key={variable.envVariable} variable={variable} />
|
||||
))}
|
||||
</div>
|
||||
</ServerContentBlock>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -22,9 +22,9 @@ interface Props {
|
|||
const VariableBox = ({ variable }: Props) => {
|
||||
const FLASH_KEY = `server:startup:${variable.envVariable}`;
|
||||
|
||||
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
|
||||
const [ loading, setLoading ] = useState(false);
|
||||
const [ canEdit ] = usePermissions([ 'startup.update' ]);
|
||||
const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [canEdit] = usePermissions(['startup.update']);
|
||||
const { clearFlashes, clearAndAddHttpError } = useFlash();
|
||||
const { mutate } = getServerStartup(uuid);
|
||||
|
||||
|
@ -33,35 +33,42 @@ const VariableBox = ({ variable }: Props) => {
|
|||
clearFlashes(FLASH_KEY);
|
||||
|
||||
updateStartupVariable(uuid, variable.envVariable, value)
|
||||
.then(([ response, invocation ]) => mutate(data => ({
|
||||
...data,
|
||||
invocation,
|
||||
variables: (data.variables || []).map(v => v.envVariable === response.envVariable ? response : v),
|
||||
}), false))
|
||||
.catch(error => {
|
||||
.then(([response, invocation]) =>
|
||||
mutate(
|
||||
(data) => ({
|
||||
...data,
|
||||
invocation,
|
||||
variables: (data.variables || []).map((v) =>
|
||||
v.envVariable === response.envVariable ? response : v
|
||||
),
|
||||
}),
|
||||
false
|
||||
)
|
||||
)
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
clearAndAddHttpError({ error, key: FLASH_KEY });
|
||||
})
|
||||
.then(() => setLoading(false));
|
||||
}, 500);
|
||||
|
||||
const useSwitch = variable.rules.some(v => v === 'boolean' || v === 'in:0,1');
|
||||
const selectValues = variable.rules.find(v => v.startsWith('in:'))?.split(',') || [];
|
||||
const useSwitch = variable.rules.some((v) => v === 'boolean' || v === 'in:0,1');
|
||||
const selectValues = variable.rules.find((v) => v.startsWith('in:'))?.split(',') || [];
|
||||
|
||||
return (
|
||||
<TitledGreyBox
|
||||
title={
|
||||
<p css={tw`text-sm uppercase`}>
|
||||
{!variable.isEditable &&
|
||||
<span css={tw`bg-neutral-700 text-xs py-1 px-2 rounded-full mr-2 mb-1`}>Read Only</span>
|
||||
}
|
||||
{!variable.isEditable && (
|
||||
<span css={tw`bg-neutral-700 text-xs py-1 px-2 rounded-full mr-2 mb-1`}>Read Only</span>
|
||||
)}
|
||||
{variable.name}
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<FlashMessageRender byKey={FLASH_KEY} css={tw`mb-2 md:mb-4`}/>
|
||||
<FlashMessageRender byKey={FLASH_KEY} css={tw`mb-2 md:mb-4`} />
|
||||
<InputSpinner visible={loading}>
|
||||
{useSwitch ?
|
||||
{useSwitch ? (
|
||||
<>
|
||||
<Switch
|
||||
readOnly={!canEdit || !variable.isEditable}
|
||||
|
@ -74,26 +81,30 @@ const VariableBox = ({ variable }: Props) => {
|
|||
}}
|
||||
/>
|
||||
</>
|
||||
:
|
||||
) : (
|
||||
<>
|
||||
{selectValues.length > 0 ?
|
||||
{selectValues.length > 0 ? (
|
||||
<>
|
||||
<Select
|
||||
onChange={e => setVariableValue(e.target.value)}
|
||||
onChange={(e) => setVariableValue(e.target.value)}
|
||||
name={variable.envVariable}
|
||||
defaultValue={variable.serverValue}
|
||||
disabled={!canEdit || !variable.isEditable}
|
||||
>
|
||||
{selectValues.map(selectValue => (
|
||||
<option key={selectValue.replace('in:', '')} value={selectValue.replace('in:', '')}>{selectValue.replace('in:', '')}</option>
|
||||
{selectValues.map((selectValue) => (
|
||||
<option
|
||||
key={selectValue.replace('in:', '')}
|
||||
value={selectValue.replace('in:', '')}
|
||||
>
|
||||
{selectValue.replace('in:', '')}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
</>
|
||||
:
|
||||
) : (
|
||||
<>
|
||||
<Input
|
||||
onKeyUp={e => {
|
||||
onKeyUp={(e) => {
|
||||
if (canEdit && variable.isEditable) {
|
||||
setVariableValue(e.currentTarget.value);
|
||||
}
|
||||
|
@ -104,13 +115,11 @@ const VariableBox = ({ variable }: Props) => {
|
|||
placeholder={variable.defaultValue}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
)}
|
||||
</>
|
||||
}
|
||||
)}
|
||||
</InputSpinner>
|
||||
<p css={tw`mt-1 text-xs text-neutral-300`}>
|
||||
{variable.description}
|
||||
</p>
|
||||
<p css={tw`mt-1 text-xs text-neutral-300`}>{variable.description}</p>
|
||||
</TitledGreyBox>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -3,14 +3,12 @@ import EditSubuserModal from '@/components/server/users/EditSubuserModal';
|
|||
import { Button } from '@/components/elements/button/index';
|
||||
|
||||
export default () => {
|
||||
const [ visible, setVisible ] = useState(false);
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<EditSubuserModal visible={visible} onModalDismissed={() => setVisible(false)}/>
|
||||
<Button onClick={() => setVisible(true)}>
|
||||
New User
|
||||
</Button>
|
||||
<EditSubuserModal visible={visible} onModalDismissed={() => setVisible(false)} />
|
||||
<Button onClick={() => setVisible(true)}>New User</Button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -29,22 +29,25 @@ interface Values {
|
|||
|
||||
const EditSubuserModal = ({ subuser }: Props) => {
|
||||
const ref = useRef<HTMLHeadingElement>(null);
|
||||
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
|
||||
const appendSubuser = ServerContext.useStoreActions(actions => actions.subusers.appendSubuser);
|
||||
const { clearFlashes, clearAndAddHttpError } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
|
||||
const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid);
|
||||
const appendSubuser = ServerContext.useStoreActions((actions) => actions.subusers.appendSubuser);
|
||||
const { clearFlashes, clearAndAddHttpError } = useStoreActions(
|
||||
(actions: Actions<ApplicationStore>) => actions.flashes
|
||||
);
|
||||
const { dismiss, setPropOverrides } = useContext(ModalContext);
|
||||
|
||||
const isRootAdmin = useStoreState(state => state.user.data!.rootAdmin);
|
||||
const permissions = useStoreState(state => state.permissions.data);
|
||||
const isRootAdmin = useStoreState((state) => state.user.data!.rootAdmin);
|
||||
const permissions = useStoreState((state) => state.permissions.data);
|
||||
// The currently logged in user's permissions. We're going to filter out any permissions
|
||||
// that they should not need.
|
||||
const loggedInPermissions = ServerContext.useStoreState(state => state.server.permissions);
|
||||
const [ canEditUser ] = usePermissions(subuser ? [ 'user.update' ] : [ 'user.create' ]);
|
||||
const loggedInPermissions = ServerContext.useStoreState((state) => state.server.permissions);
|
||||
const [canEditUser] = usePermissions(subuser ? ['user.update'] : ['user.create']);
|
||||
|
||||
// The permissions that can be modified by this user.
|
||||
const editablePermissions = useDeepCompareMemo(() => {
|
||||
const cleaned = Object.keys(permissions)
|
||||
.map(key => Object.keys(permissions[key].keys).map(pkey => `${key}.${pkey}`));
|
||||
const cleaned = Object.keys(permissions).map((key) =>
|
||||
Object.keys(permissions[key].keys).map((pkey) => `${key}.${pkey}`)
|
||||
);
|
||||
|
||||
const list: string[] = ([] as string[]).concat.apply([], Object.values(cleaned));
|
||||
|
||||
|
@ -52,19 +55,19 @@ const EditSubuserModal = ({ subuser }: Props) => {
|
|||
return list;
|
||||
}
|
||||
|
||||
return list.filter(key => loggedInPermissions.indexOf(key) >= 0);
|
||||
}, [ isRootAdmin, permissions, loggedInPermissions ]);
|
||||
return list.filter((key) => loggedInPermissions.indexOf(key) >= 0);
|
||||
}, [isRootAdmin, permissions, loggedInPermissions]);
|
||||
|
||||
const submit = (values: Values) => {
|
||||
setPropOverrides({ showSpinnerOverlay: true });
|
||||
clearFlashes('user:edit');
|
||||
|
||||
createOrUpdateSubuser(uuid, values, subuser)
|
||||
.then(subuser => {
|
||||
.then((subuser) => {
|
||||
appendSubuser(subuser);
|
||||
dismiss();
|
||||
})
|
||||
.catch(error => {
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
setPropOverrides(null);
|
||||
clearAndAddHttpError({ key: 'user:edit', error });
|
||||
|
@ -75,17 +78,22 @@ const EditSubuserModal = ({ subuser }: Props) => {
|
|||
});
|
||||
};
|
||||
|
||||
useEffect(() => () => {
|
||||
clearFlashes('user:edit');
|
||||
}, []);
|
||||
useEffect(
|
||||
() => () => {
|
||||
clearFlashes('user:edit');
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<Formik
|
||||
onSubmit={submit}
|
||||
initialValues={{
|
||||
email: subuser?.email || '',
|
||||
permissions: subuser?.permissions || [],
|
||||
} as Values}
|
||||
initialValues={
|
||||
{
|
||||
email: subuser?.email || '',
|
||||
permissions: subuser?.permissions || [],
|
||||
} as Values
|
||||
}
|
||||
validationSchema={object().shape({
|
||||
email: string()
|
||||
.max(191, 'Email addresses must not exceed 191 characters.')
|
||||
|
@ -97,7 +105,9 @@ const EditSubuserModal = ({ subuser }: Props) => {
|
|||
<Form>
|
||||
<div css={tw`flex justify-between`}>
|
||||
<h2 css={tw`text-2xl`} ref={ref}>
|
||||
{subuser ? `${canEditUser ? 'Modify' : 'View'} permissions for ${subuser.email}` : 'Create new subuser'}
|
||||
{subuser
|
||||
? `${canEditUser ? 'Modify' : 'View'} permissions for ${subuser.email}`
|
||||
: 'Create new subuser'}
|
||||
</h2>
|
||||
<div>
|
||||
<Button type={'submit'} css={tw`w-full sm:w-auto`}>
|
||||
|
@ -106,44 +116,46 @@ const EditSubuserModal = ({ subuser }: Props) => {
|
|||
</div>
|
||||
</div>
|
||||
<FlashMessageRender byKey={'user:edit'} css={tw`mt-4`} />
|
||||
{(!isRootAdmin && loggedInPermissions[0] !== '*') &&
|
||||
{!isRootAdmin && loggedInPermissions[0] !== '*' && (
|
||||
<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 &&
|
||||
)}
|
||||
{!subuser && (
|
||||
<div css={tw`mt-6`}>
|
||||
<Field
|
||||
name={'email'}
|
||||
label={'User Email'}
|
||||
description={'Enter the email address of the user you wish to invite as a subuser for this server.'}
|
||||
description={
|
||||
'Enter the email address of the user you wish to invite as a subuser for this server.'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
)}
|
||||
<div css={tw`my-6`}>
|
||||
{Object.keys(permissions).filter(key => key !== 'websocket').map((key, index) => (
|
||||
<PermissionTitleBox
|
||||
key={`permission_${key}`}
|
||||
title={key}
|
||||
isEditable={canEditUser}
|
||||
permissions={Object.keys(permissions[key].keys).map(pkey => `${key}.${pkey}`)}
|
||||
css={index > 0 ? tw`mt-4` : undefined}
|
||||
>
|
||||
<p css={tw`text-sm text-neutral-400 mb-4`}>
|
||||
{permissions[key].description}
|
||||
</p>
|
||||
{Object.keys(permissions[key].keys).map(pkey => (
|
||||
<PermissionRow
|
||||
key={`permission_${key}.${pkey}`}
|
||||
permission={`${key}.${pkey}`}
|
||||
disabled={!canEditUser || editablePermissions.indexOf(`${key}.${pkey}`) < 0}
|
||||
/>
|
||||
))}
|
||||
</PermissionTitleBox>
|
||||
))}
|
||||
{Object.keys(permissions)
|
||||
.filter((key) => key !== 'websocket')
|
||||
.map((key, index) => (
|
||||
<PermissionTitleBox
|
||||
key={`permission_${key}`}
|
||||
title={key}
|
||||
isEditable={canEditUser}
|
||||
permissions={Object.keys(permissions[key].keys).map((pkey) => `${key}.${pkey}`)}
|
||||
css={index > 0 ? tw`mt-4` : undefined}
|
||||
>
|
||||
<p css={tw`text-sm text-neutral-400 mb-4`}>{permissions[key].description}</p>
|
||||
{Object.keys(permissions[key].keys).map((pkey) => (
|
||||
<PermissionRow
|
||||
key={`permission_${key}.${pkey}`}
|
||||
permission={`${key}.${pkey}`}
|
||||
disabled={!canEditUser || editablePermissions.indexOf(`${key}.${pkey}`) < 0}
|
||||
/>
|
||||
))}
|
||||
</PermissionTitleBox>
|
||||
))}
|
||||
</div>
|
||||
<Can action={subuser ? 'user.update' : 'user.create'}>
|
||||
<div css={tw`pb-6 flex justify-end`}>
|
||||
|
|
|
@ -6,28 +6,28 @@ import { useStoreState } from 'easy-peasy';
|
|||
import Label from '@/components/elements/Label';
|
||||
|
||||
const Container = styled.label`
|
||||
${tw`flex items-center border border-transparent rounded md:p-2 transition-colors duration-75`};
|
||||
text-transform: none;
|
||||
${tw`flex items-center border border-transparent rounded md:p-2 transition-colors duration-75`};
|
||||
text-transform: none;
|
||||
|
||||
&:not(.disabled) {
|
||||
${tw`cursor-pointer`};
|
||||
&:not(.disabled) {
|
||||
${tw`cursor-pointer`};
|
||||
|
||||
&:hover {
|
||||
${tw`border-neutral-500 bg-neutral-800`};
|
||||
}
|
||||
}
|
||||
|
||||
&:not(:first-of-type) {
|
||||
${tw`mt-4 sm:mt-2`};
|
||||
}
|
||||
&:hover {
|
||||
${tw`border-neutral-500 bg-neutral-800`};
|
||||
}
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
${tw`opacity-50`};
|
||||
&:not(:first-of-type) {
|
||||
${tw`mt-4 sm:mt-2`};
|
||||
}
|
||||
|
||||
& input[type="checkbox"]:not(:checked) {
|
||||
${tw`border-0`};
|
||||
}
|
||||
}
|
||||
&.disabled {
|
||||
${tw`opacity-50`};
|
||||
|
||||
& input[type='checkbox']:not(:checked) {
|
||||
${tw`border-0`};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
interface Props {
|
||||
|
@ -36,8 +36,8 @@ interface Props {
|
|||
}
|
||||
|
||||
const PermissionRow = ({ permission, disabled }: Props) => {
|
||||
const [ key, pkey ] = permission.split('.', 2);
|
||||
const permissions = useStoreState(state => state.permissions.data);
|
||||
const [key, pkey] = permission.split('.', 2);
|
||||
const permissions = useStoreState((state) => state.permissions.data);
|
||||
|
||||
return (
|
||||
<Container htmlFor={`permission_${permission}`} className={disabled ? 'disabled' : undefined}>
|
||||
|
@ -51,12 +51,12 @@ const PermissionRow = ({ permission, disabled }: Props) => {
|
|||
/>
|
||||
</div>
|
||||
<div css={tw`flex-1`}>
|
||||
<Label as={'p'} css={tw`font-medium`}>{pkey}</Label>
|
||||
{permissions[key].keys[pkey].length > 0 &&
|
||||
<p css={tw`text-xs text-neutral-400 mt-1`}>
|
||||
{permissions[key].keys[pkey]}
|
||||
</p>
|
||||
}
|
||||
<Label as={'p'} css={tw`font-medium`}>
|
||||
{pkey}
|
||||
</Label>
|
||||
{permissions[key].keys[pkey].length > 0 && (
|
||||
<p css={tw`text-xs text-neutral-400 mt-1`}>{permissions[key].keys[pkey]}</p>
|
||||
)}
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
|
|
|
@ -13,31 +13,31 @@ interface Props {
|
|||
}
|
||||
|
||||
const PermissionTitleBox: React.FC<Props> = memo(({ isEditable, title, permissions, className, children }) => {
|
||||
const [ { value }, , { setValue } ] = useField<string[]>('permissions');
|
||||
const [{ value }, , { setValue }] = useField<string[]>('permissions');
|
||||
|
||||
const onCheckboxClicked = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.currentTarget.checked) {
|
||||
setValue([
|
||||
...value,
|
||||
...permissions.filter(p => !value.includes(p)),
|
||||
]);
|
||||
} else {
|
||||
setValue(value.filter(p => !permissions.includes(p)));
|
||||
}
|
||||
}, [ permissions, value ]);
|
||||
const onCheckboxClicked = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.currentTarget.checked) {
|
||||
setValue([...value, ...permissions.filter((p) => !value.includes(p))]);
|
||||
} else {
|
||||
setValue(value.filter((p) => !permissions.includes(p)));
|
||||
}
|
||||
},
|
||||
[permissions, value]
|
||||
);
|
||||
|
||||
return (
|
||||
<TitledGreyBox
|
||||
title={
|
||||
<div css={tw`flex items-center`}>
|
||||
<p css={tw`text-sm uppercase flex-1`}>{title}</p>
|
||||
{isEditable &&
|
||||
<Input
|
||||
type={'checkbox'}
|
||||
checked={permissions.every(p => value.includes(p))}
|
||||
onChange={onCheckboxClicked}
|
||||
/>
|
||||
}
|
||||
{isEditable && (
|
||||
<Input
|
||||
type={'checkbox'}
|
||||
checked={permissions.every((p) => value.includes(p))}
|
||||
onChange={onCheckboxClicked}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
className={className}
|
||||
|
|
|
@ -11,11 +11,11 @@ import { httpErrorToHuman } from '@/api/http';
|
|||
import tw from 'twin.macro';
|
||||
|
||||
export default ({ subuser }: { subuser: Subuser }) => {
|
||||
const [ loading, setLoading ] = useState(false);
|
||||
const [ showConfirmation, setShowConfirmation ] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showConfirmation, setShowConfirmation] = useState(false);
|
||||
|
||||
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
|
||||
const removeSubuser = ServerContext.useStoreActions(actions => actions.subusers.removeSubuser);
|
||||
const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid);
|
||||
const removeSubuser = ServerContext.useStoreActions((actions) => actions.subusers.removeSubuser);
|
||||
const { addError, clearFlashes } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
|
||||
|
||||
const doDeletion = () => {
|
||||
|
@ -26,7 +26,7 @@ export default ({ subuser }: { subuser: Subuser }) => {
|
|||
setLoading(false);
|
||||
removeSubuser(subuser.uuid);
|
||||
})
|
||||
.catch(error => {
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
addError({ key: 'users', message: httpErrorToHuman(error) });
|
||||
setShowConfirmation(false);
|
||||
|
@ -52,7 +52,7 @@ export default ({ subuser }: { subuser: Subuser }) => {
|
|||
css={tw`block text-sm p-2 text-neutral-500 hover:text-red-600 transition-colors duration-150`}
|
||||
onClick={() => setShowConfirmation(true)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrashAlt}/>
|
||||
<FontAwesomeIcon icon={faTrashAlt} />
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -14,18 +14,14 @@ interface Props {
|
|||
}
|
||||
|
||||
export default ({ subuser }: Props) => {
|
||||
const uuid = useStoreState(state => state.user!.data!.uuid);
|
||||
const [ visible, setVisible ] = useState(false);
|
||||
const uuid = useStoreState((state) => state.user!.data!.uuid);
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
return (
|
||||
<GreyRowBox css={tw`mb-2`}>
|
||||
<EditSubuserModal
|
||||
subuser={subuser}
|
||||
visible={visible}
|
||||
onModalDismissed={() => setVisible(false)}
|
||||
/>
|
||||
<EditSubuserModal subuser={subuser} visible={visible} onModalDismissed={() => setVisible(false)} />
|
||||
<div css={tw`w-10 h-10 rounded-full bg-white border-2 border-neutral-800 overflow-hidden hidden md:block`}>
|
||||
<img css={tw`w-full h-full`} src={`${subuser.image}?s=400`}/>
|
||||
<img css={tw`w-full h-full`} src={`${subuser.image}?s=400`} />
|
||||
</div>
|
||||
<div css={tw`ml-4 flex-1 overflow-hidden`}>
|
||||
<p css={tw`text-sm truncate`}>{subuser.email}</p>
|
||||
|
@ -44,11 +40,11 @@ export default ({ subuser }: Props) => {
|
|||
</div>
|
||||
<div css={tw`ml-4 hidden md:block`}>
|
||||
<p css={tw`font-medium text-center`}>
|
||||
{subuser.permissions.filter(permission => permission !== 'websocket.connect').length}
|
||||
{subuser.permissions.filter((permission) => permission !== 'websocket.connect').length}
|
||||
</p>
|
||||
<p css={tw`text-2xs text-neutral-500 uppercase`}>Permissions</p>
|
||||
</div>
|
||||
{subuser.uuid !== uuid &&
|
||||
{subuser.uuid !== uuid && (
|
||||
<>
|
||||
<Can action={'user.update'}>
|
||||
<button
|
||||
|
@ -64,7 +60,7 @@ export default ({ subuser }: Props) => {
|
|||
<RemoveSubuserButton subuser={subuser} />
|
||||
</Can>
|
||||
</>
|
||||
}
|
||||
)}
|
||||
</GreyRowBox>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -13,11 +13,11 @@ import ServerContentBlock from '@/components/elements/ServerContentBlock';
|
|||
import tw from 'twin.macro';
|
||||
|
||||
export default () => {
|
||||
const [ loading, setLoading ] = useState(true);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
|
||||
const subusers = ServerContext.useStoreState(state => state.subusers.data);
|
||||
const setSubusers = ServerContext.useStoreActions(actions => actions.subusers.setSubusers);
|
||||
const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid);
|
||||
const subusers = ServerContext.useStoreState((state) => state.subusers.data);
|
||||
const setSubusers = ServerContext.useStoreActions((actions) => actions.subusers.setSubusers);
|
||||
|
||||
const permissions = useStoreState((state: ApplicationStore) => state.permissions.data);
|
||||
const getPermissions = useStoreActions((actions: Actions<ApplicationStore>) => actions.permissions.getPermissions);
|
||||
|
@ -26,42 +26,38 @@ export default () => {
|
|||
useEffect(() => {
|
||||
clearFlashes('users');
|
||||
getServerSubusers(uuid)
|
||||
.then(subusers => {
|
||||
.then((subusers) => {
|
||||
setSubusers(subusers);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(error => {
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
addError({ key: 'users', message: httpErrorToHuman(error) });
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
getPermissions().catch(error => {
|
||||
getPermissions().catch((error) => {
|
||||
addError({ key: 'users', message: httpErrorToHuman(error) });
|
||||
console.error(error);
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (!subusers.length && (loading || !Object.keys(permissions).length)) {
|
||||
return <Spinner size={'large'} centered/>;
|
||||
return <Spinner size={'large'} centered />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ServerContentBlock title={'Users'}>
|
||||
<FlashMessageRender byKey={'users'} css={tw`mb-4`}/>
|
||||
{!subusers.length ?
|
||||
<p css={tw`text-center text-sm text-neutral-300`}>
|
||||
It looks like you don't have any subusers.
|
||||
</p>
|
||||
:
|
||||
subusers.map(subuser => (
|
||||
<UserRow key={subuser.uuid} subuser={subuser}/>
|
||||
))
|
||||
}
|
||||
<FlashMessageRender byKey={'users'} css={tw`mb-4`} />
|
||||
{!subusers.length ? (
|
||||
<p css={tw`text-center text-sm text-neutral-300`}>It looks like you don't have any subusers.</p>
|
||||
) : (
|
||||
subusers.map((subuser) => <UserRow key={subuser.uuid} subuser={subuser} />)
|
||||
)}
|
||||
<Can action={'user.create'}>
|
||||
<div css={tw`flex justify-end mt-6`}>
|
||||
<AddSubuserButton/>
|
||||
<AddSubuserButton />
|
||||
</div>
|
||||
</Can>
|
||||
</ServerContentBlock>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue