Merge branch 'develop' into feature/react-admin

This commit is contained in:
Matthew Penner 2021-02-07 16:16:22 -07:00
commit a87fef37ec
77 changed files with 1082 additions and 839 deletions

View file

@ -13,6 +13,7 @@ import 'xterm/css/xterm.css';
import useEventListener from '@/plugins/useEventListener';
import { debounce } from 'debounce';
import { usePersistedState } from '@/plugins/usePersistedState';
import { SocketEvent, SocketRequest } from '@/components/server/events';
const theme = {
background: th`colors.black`.toString(),
@ -173,32 +174,35 @@ export default () => {
useEventListener('resize', () => fit());
useEffect(() => {
const listeners: Record<string, (s: string) => void> = {
[SocketEvent.STATUS]: handlePowerChangeEvent,
[SocketEvent.CONSOLE_OUTPUT]: handleConsoleOutput,
[SocketEvent.INSTALL_OUTPUT]: handleConsoleOutput,
[SocketEvent.TRANSFER_LOGS]: handleConsoleOutput,
[SocketEvent.TRANSFER_STATUS]: handleTransferStatus,
[SocketEvent.DAEMON_MESSAGE]: line => handleConsoleOutput(line, true),
[SocketEvent.DAEMON_ERROR]: handleDaemonErrorOutput,
};
if (connected && instance) {
// Do not clear the console if the server is being transferred.
if (!isTransferring) {
terminal.clear();
}
instance.addListener('status', handlePowerChangeEvent);
instance.addListener('console output', handleConsoleOutput);
instance.addListener('install output', handleConsoleOutput);
instance.addListener('transfer logs', handleConsoleOutput);
instance.addListener('transfer status', handleTransferStatus);
instance.addListener('daemon message', line => handleConsoleOutput(line, true));
instance.addListener('daemon error', handleDaemonErrorOutput);
instance.send('send logs');
Object.keys(listeners).forEach((key: string) => {
instance.addListener(key, listeners[key]);
});
instance.send(SocketRequest.SEND_LOGS);
}
return () => {
instance && instance.removeListener('status', handlePowerChangeEvent)
.removeListener('console output', handleConsoleOutput)
.removeListener('install output', handleConsoleOutput)
.removeListener('transfer logs', handleConsoleOutput)
.removeListener('transfer status', handleTransferStatus)
.removeListener('daemon message', line => handleConsoleOutput(line, true))
.removeListener('daemon error', handleDaemonErrorOutput);
if (instance) {
Object.keys(listeners).forEach((key: string) => {
instance.removeListener(key, listeners[key]);
});
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ connected, instance ]);
return (

View file

@ -1,22 +1,30 @@
import useWebsocketEvent from '@/plugins/useWebsocketEvent';
import { ServerContext } from '@/state/server';
import { SocketEvent } from '@/components/server/events';
import useFileManagerSwr from '@/plugins/useFileManagerSwr';
const InstallListener = () => {
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
const getServer = ServerContext.useStoreActions(actions => actions.server.getServer);
const { mutate } = useFileManagerSwr();
const setServerFromState = ServerContext.useStoreActions(actions => actions.server.setServerFromState);
useWebsocketEvent(SocketEvent.BACKUP_RESTORE_COMPLETED, () => {
mutate(undefined);
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('install completed', () => {
useWebsocketEvent(SocketEvent.INSTALL_COMPLETED, () => {
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('install started', () => {
setServerFromState(s => ({ ...s, isInstalling: true }));
useWebsocketEvent(SocketEvent.INSTALL_STARTED, () => {
setServerFromState(s => ({ ...s, status: 'installing' }));
});
return null;

View file

@ -6,6 +6,7 @@ import { bytesToHuman, megabytesToHuman } from '@/helpers';
import TitledGreyBox from '@/components/elements/TitledGreyBox';
import { ServerContext } from '@/state/server';
import CopyOnClick from '@/components/elements/CopyOnClick';
import { SocketEvent, SocketRequest } from '@/components/server/events';
interface Stats {
memory: number;
@ -55,11 +56,11 @@ const ServerDetailsBlock = () => {
return;
}
instance.addListener('stats', statsListener);
instance.send('send stats');
instance.addListener(SocketEvent.STATS, statsListener);
instance.send(SocketRequest.SEND_STATS);
return () => {
instance.removeListener('stats', statsListener);
instance.removeListener(SocketEvent.STATS, statsListener);
};
}, [ instance, connected ]);

View file

@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useState } from 'react';
import React, { useCallback, useState } from 'react';
import Chart, { ChartConfiguration } from 'chart.js';
import { ServerContext } from '@/state/server';
import { bytesToMegabytes } from '@/helpers';
@ -6,6 +6,8 @@ import merge from 'deepmerge';
import TitledGreyBox from '@/components/elements/TitledGreyBox';
import { faMemory, faMicrochip } from '@fortawesome/free-solid-svg-icons';
import tw from 'twin.macro';
import { SocketEvent } from '@/components/server/events';
import useWebsocketEvent from '@/plugins/useWebsocketEvent';
const chartDefaults = (ticks?: Chart.TickOptions | undefined): ChartConfiguration => ({
type: 'line',
@ -70,7 +72,6 @@ const chartDefaults = (ticks?: Chart.TickOptions | undefined): ChartConfiguratio
export default () => {
const status = ServerContext.useStoreState(state => state.status.value);
const limits = ServerContext.useStoreState(state => state.server.data!.limits);
const { connected, instance } = ServerContext.useStoreState(state => state.socket);
const [ memory, setMemory ] = useState<Chart>();
const [ cpu, setCpu ] = useState<Chart>();
@ -84,7 +85,7 @@ export default () => {
new Chart(node.getContext('2d')!, chartDefaults({
callback: (value) => `${value}Mb `,
suggestedMax: limits.memory,
}))
})),
);
}, []);
@ -100,7 +101,7 @@ export default () => {
);
}, []);
const statsListener = (data: string) => {
useWebsocketEvent(SocketEvent.STATS, (data: string) => {
let stats: any = {};
try {
stats = JSON.parse(data);
@ -125,27 +126,19 @@ export default () => {
cpu.update({ lazy: true });
}
};
useEffect(() => {
if (!connected || !instance) {
return;
}
instance.addListener('stats', statsListener);
return () => {
instance.removeListener('stats', statsListener);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ instance, connected, memory, cpu ]);
});
return (
<div css={tw`flex flex-wrap mt-4`}>
<div css={tw`w-full sm:w-1/2`}>
<TitledGreyBox title={'Memory usage'} icon={faMemory} css={tw`mr-0 sm:mr-4`}>
{status !== 'offline' ?
<canvas id={'memory_chart'} ref={memoryRef} aria-label={'Server Memory Usage Graph'} role={'img'}/>
<canvas
id={'memory_chart'}
ref={memoryRef}
aria-label={'Server Memory Usage Graph'}
role={'img'}
/>
:
<p css={tw`text-xs text-neutral-400 text-center p-3`}>
Server is offline.

View file

@ -1,5 +1,6 @@
import useWebsocketEvent from '@/plugins/useWebsocketEvent';
import { ServerContext } from '@/state/server';
import { SocketEvent } from '@/components/server/events';
const TransferListener = () => {
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
@ -7,7 +8,7 @@ const TransferListener = () => {
const setServerFromState = ServerContext.useStoreActions(actions => actions.server.setServerFromState);
// Listen for the transfer status event so we can update the state of the server.
useWebsocketEvent('transfer status', (status: string) => {
useWebsocketEvent(SocketEvent.TRANSFER_STATUS, (status: string) => {
if (status === 'starting') {
setServerFromState(s => ({ ...s, isTransferring: true }));
return;

View file

@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { faCloudDownloadAlt, faEllipsisH, faLock, faTrashAlt } from '@fortawesome/free-solid-svg-icons';
import { faBoxOpen, faCloudDownloadAlt, faEllipsisH, faLock, faTrashAlt } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import DropdownMenu, { DropdownButtonRow } from '@/components/elements/DropdownMenu';
import getBackupDownloadUrl from '@/api/server/backups/getBackupDownloadUrl';
@ -13,6 +13,8 @@ import tw from 'twin.macro';
import getServerBackups from '@/api/swr/getServerBackups';
import { ServerBackup } from '@/api/server/types';
import { ServerContext } from '@/state/server';
import Input from '@/components/elements/Input';
import { restoreServerBackup } from '@/api/server/backups';
interface Props {
backup: ServerBackup;
@ -20,9 +22,9 @@ 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 [ visible, setVisible ] = useState(false);
const [ deleteVisible, setDeleteVisible ] = useState(false);
const { clearFlashes, clearAndAddHttpError } = useFlash();
const { mutate } = getServerBackups();
@ -45,36 +47,78 @@ 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),
}), false);
})
.then(() => mutate(data => ({
...data,
items: data.items.filter(b => b.uuid !== backup.uuid),
}), false))
.catch(error => {
console.error(error);
clearAndAddHttpError({ key: 'backups', error });
setLoading(false);
setDeleteVisible(false);
setModal('');
});
};
const doRestorationAction = () => {
setLoading(true);
clearFlashes('backups');
restoreServerBackup(uuid, backup.uuid)
.then(() => setServerFromState(s => ({
...s,
status: 'restoring_backup',
})))
.catch(error => {
console.error(error);
clearAndAddHttpError({ key: 'backups', error });
})
.then(() => setLoading(false))
.then(() => setModal(''));
};
return (
<>
{visible &&
<ChecksumModal
appear
visible={visible}
onDismissed={() => setVisible(false)}
visible={modal === 'checksum'}
onDismissed={() => setModal('')}
checksum={backup.checksum}
/>
}
<ConfirmationModal
visible={deleteVisible}
visible={modal === 'restore'}
title={'Restore this backup?'}
buttonText={'Restore backup'}
onConfirmed={() => doRestorationAction()}
onModalDismissed={() => setModal('')}
>
<p css={tw`text-neutral-300`}>
This server will be stopped in order to restore the backup. Once the backup has started you will
not be able to control the server power state, access the file manager, or create additional backups
until it has completed.
</p>
<p css={tw`text-neutral-300 mt-4`}>
Are you sure you want to continue?
</p>
<p css={tw`mt-4 -mb-2 bg-neutral-900 p-3 rounded`}>
<label
htmlFor={'restore_truncate'}
css={tw`text-base text-neutral-200 flex items-center cursor-pointer`}
>
<Input
type={'checkbox'}
css={tw`text-red-500! w-5! h-5! mr-2`}
id={'restore_truncate'}
value={'true'}
/>
Remove all files and folders before restoring this backup.
</label>
</p>
</ConfirmationModal>
<ConfirmationModal
visible={modal === 'delete'}
title={'Delete this backup?'}
buttonText={'Yes, delete backup'}
onConfirmed={() => doDeletion()}
onModalDismissed={() => setDeleteVisible(false)}
onModalDismissed={() => setModal('')}
>
Are you sure you wish to delete this backup? This is a permanent operation and the backup cannot
be recovered once deleted.
@ -93,17 +137,23 @@ export default ({ backup }: Props) => {
>
<div css={tw`text-sm`}>
<Can action={'backup.download'}>
<DropdownButtonRow onClick={() => doDownload()}>
<DropdownButtonRow onClick={doDownload}>
<FontAwesomeIcon fixedWidth icon={faCloudDownloadAlt} css={tw`text-xs`}/>
<span css={tw`ml-2`}>Download</span>
</DropdownButtonRow>
</Can>
<DropdownButtonRow onClick={() => setVisible(true)}>
<Can action={'backup.restore'}>
<DropdownButtonRow onClick={() => setModal('restore')}>
<FontAwesomeIcon fixedWidth icon={faBoxOpen} css={tw`text-xs`}/>
<span css={tw`ml-2`}>Restore</span>
</DropdownButtonRow>
</Can>
<DropdownButtonRow onClick={() => setModal('checksum')}>
<FontAwesomeIcon fixedWidth icon={faLock} css={tw`text-xs`}/>
<span css={tw`ml-2`}>Checksum</span>
</DropdownButtonRow>
<Can action={'backup.delete'}>
<DropdownButtonRow danger onClick={() => setDeleteVisible(true)}>
<DropdownButtonRow danger onClick={() => setModal('delete')}>
<FontAwesomeIcon fixedWidth icon={faTrashAlt} css={tw`text-xs`}/>
<span css={tw`ml-2`}>Delete</span>
</DropdownButtonRow>
@ -112,7 +162,7 @@ export default ({ backup }: Props) => {
</DropdownMenu>
:
<button
onClick={() => setDeleteVisible(true)}
onClick={() => setModal('delete')}
css={tw`text-neutral-200 transition-colors duration-150 hover:text-neutral-100 p-2`}
>
<FontAwesomeIcon icon={faTrashAlt}/>

View file

@ -11,6 +11,7 @@ import tw from 'twin.macro';
import GreyRowBox from '@/components/elements/GreyRowBox';
import getServerBackups from '@/api/swr/getServerBackups';
import { ServerBackup } from '@/api/server/types';
import { SocketEvent } from '@/components/server/events';
interface Props {
backup: ServerBackup;
@ -20,7 +21,7 @@ interface Props {
export default ({ backup, className }: Props) => {
const { mutate } = getServerBackups();
useWebsocketEvent(`backup completed:${backup.uuid}`, data => {
useWebsocketEvent(`${SocketEvent.BACKUP_COMPLETED}:${backup.uuid}` as SocketEvent, data => {
try {
const parsed = JSON.parse(data);

View file

@ -1,10 +1,20 @@
export enum SocketEvent {
DAEMON_MESSAGE = 'daemon message',
DAEMON_ERROR = 'daemon error',
INSTALL_OUTPUT = 'install output',
INSTALL_STARTED = 'install started',
INSTALL_COMPLETED = 'install completed',
CONSOLE_OUTPUT = 'console output',
STATUS = 'status',
STATS = 'stats',
TRANSFER_LOGS = 'transfer logs',
TRANSFER_STATUS = 'transfer status',
BACKUP_COMPLETED = 'backup completed',
BACKUP_RESTORE_COMPLETED = 'backup restore completed',
}
export enum SocketRequest {
SEND_LOGS = 'send logs',
SEND_STATS = 'send stats',
SET_STATE = 'set state'
}

View file

@ -6,6 +6,7 @@ import Button from '@/components/elements/Button';
import saveFileContents from '@/api/server/files/saveFileContents';
import FlashMessageRender from '@/components/FlashMessageRender';
import useFlash from '@/plugins/useFlash';
import { SocketEvent, SocketRequest } from '@/components/server/events';
const EulaModalFeature = () => {
const [ visible, setVisible ] = useState(false);
@ -25,10 +26,10 @@ const EulaModalFeature = () => {
}
};
instance.addListener('console output', listener);
instance.addListener(SocketEvent.CONSOLE_OUTPUT, listener);
return () => {
instance.removeListener('console output', listener);
instance.removeListener(SocketEvent.CONSOLE_OUTPUT, listener);
};
}, [ connected, instance, status ]);
@ -39,7 +40,7 @@ const EulaModalFeature = () => {
saveFileContents(uuid, 'eula.txt', 'eula=true')
.then(() => {
if (status === 'offline' && instance) {
instance.send('set state', 'restart');
instance.send(SocketRequest.SET_STATE, 'restart');
}
setLoading(false);

View file

@ -9,7 +9,7 @@ import FileNameModal from '@/components/server/files/FileNameModal';
import Can from '@/components/elements/Can';
import FlashMessageRender from '@/components/FlashMessageRender';
import PageContentBlock from '@/components/elements/PageContentBlock';
import ServerError from '@/components/screens/ServerError';
import { ServerError } from '@/components/elements/ScreenBlock';
import tw from 'twin.macro';
import Button from '@/components/elements/Button';
import Select from '@/components/elements/Select';

View file

@ -8,7 +8,7 @@ import { FileObject } from '@/api/server/files/loadDirectory';
import NewDirectoryButton from '@/components/server/files/NewDirectoryButton';
import { NavLink, useLocation } from 'react-router-dom';
import Can from '@/components/elements/Can';
import ServerError from '@/components/screens/ServerError';
import { ServerError } from '@/components/elements/ScreenBlock';
import tw from 'twin.macro';
import Button from '@/components/elements/Button';
import { ServerContext } from '@/state/server';

View file

@ -5,7 +5,7 @@ import VariableBox from '@/components/server/startup/VariableBox';
import ServerContentBlock from '@/components/elements/ServerContentBlock';
import getServerStartup from '@/api/swr/getServerStartup';
import Spinner from '@/components/elements/Spinner';
import ServerError from '@/components/screens/ServerError';
import { ServerError } from '@/components/elements/ScreenBlock';
import { httpErrorToHuman } from '@/api/http';
import { ServerContext } from '@/state/server';
import { useDeepCompareEffect } from '@/plugins/useDeepCompareEffect';