Start cleaning up the mess of useServer; make startup page update in real time

This commit is contained in:
Dane Everitt 2020-08-25 21:25:31 -07:00
parent 179885b546
commit c4418640eb
No known key found for this signature in database
GPG key ID: EEA66103B3D71F53
16 changed files with 175 additions and 61 deletions

View file

@ -2,8 +2,8 @@ import http from '@/api/http';
import { ServerEggVariable } from '@/api/server/types'; import { ServerEggVariable } from '@/api/server/types';
import { rawDataToServerEggVariable } from '@/api/transformers'; import { rawDataToServerEggVariable } from '@/api/transformers';
export default async (uuid: string, key: string, value: string): Promise<ServerEggVariable> => { export default async (uuid: string, key: string, value: string): Promise<[ ServerEggVariable, string ]> => {
const { data } = await http.put(`/api/client/servers/${uuid}/startup/variable`, { key, value }); const { data } = await http.put(`/api/client/servers/${uuid}/startup/variable`, { key, value });
return rawDataToServerEggVariable(data); return [ rawDataToServerEggVariable(data), data.meta.startup_command ];
}; };

View file

@ -0,0 +1,18 @@
import useSWR from 'swr';
import http, { FractalResponseList } from '@/api/http';
import { rawDataToServerEggVariable } from '@/api/transformers';
import { ServerEggVariable } from '@/api/server/types';
interface Response {
invocation: string;
variables: ServerEggVariable[];
}
export default (uuid: string, initialData?: Response) => useSWR([ uuid, '/startup' ], async (): Promise<Response> => {
console.log('firing getServerStartup');
const { data } = await http.get(`/api/client/servers/${uuid}/startup`);
const variables = ((data as FractalResponseList).data || []).map(rawDataToServerEggVariable);
return { invocation: data.meta.startup_command, variables };
}, { initialData, errorRetryCount: 3 });

View file

@ -1,5 +1,6 @@
import React from 'react'; import React, { memo } from 'react';
import { usePermissions } from '@/plugins/usePermissions'; import { usePermissions } from '@/plugins/usePermissions';
import isEqual from 'react-fast-compare';
interface Props { interface Props {
action: string | string[]; action: string | string[];
@ -23,4 +24,4 @@ const Can = ({ action, matchAny = false, renderOnError, children }: Props) => {
); );
}; };
export default Can; export default memo(Can, isEqual);

View file

@ -1,13 +1,13 @@
import useServer from '@/plugins/useServer';
import PageContentBlock, { PageContentBlockProps } from '@/components/elements/PageContentBlock'; import PageContentBlock, { PageContentBlockProps } from '@/components/elements/PageContentBlock';
import React from 'react'; import React from 'react';
import { ServerContext } from '@/state/server';
interface Props extends PageContentBlockProps { interface Props extends PageContentBlockProps {
title: string; title: string;
} }
const ServerContentBlock: React.FC<Props> = ({ title, children, ...props }) => { const ServerContentBlock: React.FC<Props> = ({ title, children, ...props }) => {
const { name } = useServer(); const name = ServerContext.useStoreState(state => state.server.data!.name);
return ( return (
<PageContentBlock title={`${name} | ${title}`} {...props}> <PageContentBlock title={`${name} | ${title}`} {...props}>

View file

@ -45,4 +45,10 @@ const Spinner = ({ centered, ...props }: Props) => (
); );
Spinner.DisplayName = 'Spinner'; Spinner.DisplayName = 'Spinner';
Spinner.Size = {
SMALL: 'small' as SpinnerSize,
BASE: 'base' as SpinnerSize,
LARGE: 'large' as SpinnerSize,
};
export default Spinner; export default Spinner;

View file

@ -1,26 +1,64 @@
import React from 'react'; import React, { useEffect } from 'react';
import TitledGreyBox from '@/components/elements/TitledGreyBox'; import TitledGreyBox from '@/components/elements/TitledGreyBox';
import useServer from '@/plugins/useServer';
import tw from 'twin.macro'; import tw from 'twin.macro';
import VariableBox from '@/components/server/startup/VariableBox'; import VariableBox from '@/components/server/startup/VariableBox';
import ServerContentBlock from '@/components/elements/ServerContentBlock'; 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 { httpErrorToHuman } from '@/api/http';
import { ServerContext } from '@/state/server';
import { useDeepCompareEffect } from '@/plugins/useDeepCompareEffect';
const StartupContainer = () => { const StartupContainer = () => {
const { invocation, variables } = useServer(); const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
const invocation = ServerContext.useStoreState(state => state.server.data!.invocation);
const variables = ServerContext.useStoreState(state => state.server.data!.variables);
const { data, error, isValidating, mutate } = getServerStartup(uuid, { invocation, variables });
const setServerFromState = ServerContext.useStoreActions(actions => actions.server.setServerFromState);
useEffect(() => {
// Since we're passing in initial data this will not trigger on mount automatically. We
// want to always fetch fresh information from the API however when we're loading the startup
// information.
mutate();
}, []);
useDeepCompareEffect(() => {
if (!data) return;
setServerFromState(s => ({
...s,
invocation: data.invocation,
variables: data.variables,
}));
}, [ data ]);
return ( return (
<ServerContentBlock title={'Startup Settings'}> !data ?
<TitledGreyBox title={'Startup Command'}> (!error || (error && isValidating)) ?
<div css={tw`px-1 py-2`}> <Spinner centered size={Spinner.Size.LARGE}/>
<p css={tw`font-mono bg-neutral-900 rounded py-2 px-4`}> :
{invocation} <ServerError
</p> title={'Oops!'}
message={httpErrorToHuman(error)}
onRetry={() => mutate()}
/>
:
<ServerContentBlock title={'Startup Settings'}>
<TitledGreyBox title={'Startup Command'}>
<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>
<div css={tw`grid gap-8 grid-cols-2 mt-10`}>
{data.variables.map(variable => <VariableBox key={variable.envVariable} variable={variable}/>)}
</div> </div>
</TitledGreyBox> </ServerContentBlock>
<div css={tw`grid gap-8 grid-cols-2 mt-10`}>
{variables.map(variable => <VariableBox key={variable.envVariable} variable={variable}/>)}
</div>
</ServerContentBlock>
); );
}; };

View file

@ -1,4 +1,4 @@
import React, { useState } from 'react'; import React, { memo, useState } from 'react';
import { ServerEggVariable } from '@/api/server/types'; import { ServerEggVariable } from '@/api/server/types';
import TitledGreyBox from '@/components/elements/TitledGreyBox'; import TitledGreyBox from '@/components/elements/TitledGreyBox';
import { usePermissions } from '@/plugins/usePermissions'; import { usePermissions } from '@/plugins/usePermissions';
@ -8,9 +8,11 @@ import tw from 'twin.macro';
import { debounce } from 'debounce'; import { debounce } from 'debounce';
import updateStartupVariable from '@/api/server/updateStartupVariable'; import updateStartupVariable from '@/api/server/updateStartupVariable';
import useServer from '@/plugins/useServer'; import useServer from '@/plugins/useServer';
import { ServerContext } from '@/state/server';
import useFlash from '@/plugins/useFlash'; import useFlash from '@/plugins/useFlash';
import FlashMessageRender from '@/components/FlashMessageRender'; import FlashMessageRender from '@/components/FlashMessageRender';
import getServerStartup from '@/api/swr/getServerStartup';
import isEqual from 'react-fast-compare';
import { ServerContext } from '@/state/server';
interface Props { interface Props {
variable: ServerEggVariable; variable: ServerEggVariable;
@ -19,22 +21,21 @@ interface Props {
const VariableBox = ({ variable }: Props) => { const VariableBox = ({ variable }: Props) => {
const FLASH_KEY = `server:startup:${variable.envVariable}`; const FLASH_KEY = `server:startup:${variable.envVariable}`;
const server = useServer(); const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
const [ loading, setLoading ] = useState(false); const [ loading, setLoading ] = useState(false);
const [ canEdit ] = usePermissions([ 'startup.update' ]); const [ canEdit ] = usePermissions([ 'startup.update' ]);
const { clearFlashes, clearAndAddHttpError } = useFlash(); const { clearFlashes, clearAndAddHttpError } = useFlash();
const { mutate } = getServerStartup(uuid);
const setServer = ServerContext.useStoreActions(actions => actions.server.setServer);
const setVariableValue = debounce((value: string) => { const setVariableValue = debounce((value: string) => {
setLoading(true); setLoading(true);
clearFlashes(FLASH_KEY); clearFlashes(FLASH_KEY);
updateStartupVariable(server.uuid, variable.envVariable, value) updateStartupVariable(uuid, variable.envVariable, value)
.then(response => setServer({ .then(([ response, invocation ]) => mutate(data => ({
...server, invocation,
variables: server.variables.map(v => v.envVariable === response.envVariable ? response : v), variables: data.variables.map(v => v.envVariable === response.envVariable ? response : v),
})) }), false))
.catch(error => { .catch(error => {
console.error(error); console.error(error);
clearAndAddHttpError({ error, key: FLASH_KEY }); clearAndAddHttpError({ error, key: FLASH_KEY });
@ -74,4 +75,4 @@ const VariableBox = ({ variable }: Props) => {
); );
}; };
export default VariableBox; export default memo(VariableBox, isEqual);

View file

@ -15,7 +15,7 @@ import { httpErrorToHuman } from '@/api/http';
import FlashMessageRender from '@/components/FlashMessageRender'; import FlashMessageRender from '@/components/FlashMessageRender';
import Can from '@/components/elements/Can'; import Can from '@/components/elements/Can';
import { usePermissions } from '@/plugins/usePermissions'; import { usePermissions } from '@/plugins/usePermissions';
import { useDeepMemo } from '@/plugins/useDeepMemo'; import { useDeepCompareMemo } from '@/plugins/useDeepCompareMemo';
import tw from 'twin.macro'; import tw from 'twin.macro';
import Button from '@/components/elements/Button'; import Button from '@/components/elements/Button';
import Label from '@/components/elements/Label'; import Label from '@/components/elements/Label';
@ -63,7 +63,7 @@ const EditSubuserModal = forwardRef<HTMLHeadingElement, Props>(({ subuser, ...pr
const loggedInPermissions = ServerContext.useStoreState(state => state.server.permissions); const loggedInPermissions = ServerContext.useStoreState(state => state.server.permissions);
// The permissions that can be modified by this user. // The permissions that can be modified by this user.
const editablePermissions = useDeepMemo(() => { const editablePermissions = useDeepCompareMemo(() => {
const cleaned = Object.keys(permissions) const cleaned = Object.keys(permissions)
.map(key => Object.keys(permissions[key].keys).map(pkey => `${key}.${pkey}`)); .map(key => Object.keys(permissions[key].keys).map(pkey => `${key}.${pkey}`));

View file

@ -1,9 +1,14 @@
import React from 'react'; import React from 'react';
import Can from '@/components/elements/Can'; import Can from '@/components/elements/Can';
import ScreenBlock from '@/components/screens/ScreenBlock'; import ScreenBlock from '@/components/screens/ScreenBlock';
import isEqual from 'react-fast-compare';
const requireServerPermission = (Component: React.ComponentType<any>, permissions: string | string[]) => { const requireServerPermission = (Component: React.ComponentType<any>, permissions: string | string[]) => {
return class extends React.Component<any, any> { return class extends React.Component<any, any> {
shouldComponentUpdate (nextProps: Readonly<any>) {
return !isEqual(nextProps, this.props);
}
render () { render () {
return ( return (
<Can <Can

View file

@ -0,0 +1,5 @@
import { DependencyList, EffectCallback, useEffect } from 'react';
import { useDeepMemoize } from './useDeepMemoize';
export const useDeepCompareEffect = (callback: EffectCallback, dependencies: DependencyList) =>
useEffect(callback, useDeepMemoize(dependencies));

View file

@ -0,0 +1,5 @@
import { DependencyList, useMemo } from 'react';
import { useDeepMemoize } from '@/plugins/useDeepMemoize';
export const useDeepCompareMemo = <T> (callback: () => T, dependencies: DependencyList) =>
useMemo(callback, useDeepMemoize(dependencies));

View file

@ -1,12 +0,0 @@
import { useRef } from 'react';
import isEqual from 'react-fast-compare';
export const useDeepMemo = <T, K> (fn: () => T, key: K): T => {
const ref = useRef<{ key: K, value: T }>();
if (!ref.current || !isEqual(key, ref.current.key)) {
ref.current = { key, value: fn() };
}
return ref.current.value;
};

View file

@ -0,0 +1,12 @@
import { DependencyList, MutableRefObject, useRef } from 'react';
import isEqual from 'react-fast-compare';
export const useDeepMemoize = <T = DependencyList> (value: T): T => {
const ref: MutableRefObject<T | undefined> = useRef();
if (!isEqual(value, ref.current)) {
ref.current = value;
}
return ref.current as T;
};

View file

@ -1,10 +1,10 @@
import { ServerContext } from '@/state/server'; import { ServerContext } from '@/state/server';
import { useDeepMemo } from '@/plugins/useDeepMemo'; import { useDeepCompareMemo } from '@/plugins/useDeepCompareMemo';
export const usePermissions = (action: string | string[]): boolean[] => { export const usePermissions = (action: string | string[]): boolean[] => {
const userPermissions = ServerContext.useStoreState(state => state.server.permissions); const userPermissions = ServerContext.useStoreState(state => state.server.permissions);
return useDeepMemo(() => { return useDeepCompareMemo(() => {
if (userPermissions[0] === '*') { if (userPermissions[0] === '*') {
return Array(Array.isArray(action) ? action.length : 1).fill(true); return Array(Array.isArray(action) ? action.length : 1).fill(true);
} }

View file

@ -22,7 +22,6 @@ import ServerError from '@/components/screens/ServerError';
import { httpErrorToHuman } from '@/api/http'; import { httpErrorToHuman } from '@/api/http';
import NotFound from '@/components/screens/NotFound'; import NotFound from '@/components/screens/NotFound';
import { useStoreState } from 'easy-peasy'; import { useStoreState } from 'easy-peasy';
import useServer from '@/plugins/useServer';
import ScreenBlock from '@/components/screens/ScreenBlock'; import ScreenBlock from '@/components/screens/ScreenBlock';
import SubNavigation from '@/components/elements/SubNavigation'; import SubNavigation from '@/components/elements/SubNavigation';
import NetworkContainer from '@/components/server/network/NetworkContainer'; import NetworkContainer from '@/components/server/network/NetworkContainer';
@ -34,7 +33,10 @@ const ServerRouter = ({ match, location }: RouteComponentProps<{ id: string }>)
const { rootAdmin } = useStoreState(state => state.user.data!); const { rootAdmin } = useStoreState(state => state.user.data!);
const [ error, setError ] = useState(''); const [ error, setError ] = useState('');
const [ installing, setInstalling ] = useState(false); const [ installing, setInstalling ] = useState(false);
const server = useServer();
const id = ServerContext.useStoreState(state => state.server.data?.id);
const uuid = ServerContext.useStoreState(state => state.server.data?.uuid);
const isInstalling = ServerContext.useStoreState(state => state.server.data?.isInstalling);
const getServer = ServerContext.useStoreActions(actions => actions.server.getServer); const getServer = ServerContext.useStoreActions(actions => actions.server.getServer);
const clearServerState = ServerContext.useStoreActions(actions => actions.clearServerState); const clearServerState = ServerContext.useStoreActions(actions => actions.clearServerState);
@ -43,8 +45,8 @@ const ServerRouter = ({ match, location }: RouteComponentProps<{ id: string }>)
}, []); }, []);
useEffect(() => { useEffect(() => {
setInstalling(server?.isInstalling !== false); setInstalling(!!isInstalling);
}, [ server?.isInstalling ]); }, [ isInstalling ]);
useEffect(() => { useEffect(() => {
setError(''); setError('');
@ -71,7 +73,7 @@ const ServerRouter = ({ match, location }: RouteComponentProps<{ id: string }>)
return ( return (
<React.Fragment key={'server-router'}> <React.Fragment key={'server-router'}>
<NavigationBar/> <NavigationBar/>
{!server ? {(!uuid || !id) ?
error ? error ?
<ServerError message={error}/> <ServerError message={error}/>
: :
@ -111,7 +113,7 @@ const ServerRouter = ({ match, location }: RouteComponentProps<{ id: string }>)
</CSSTransition> </CSSTransition>
<InstallListener/> <InstallListener/>
<WebsocketHandler/> <WebsocketHandler/>
{(installing && (!rootAdmin || (rootAdmin && !location.pathname.endsWith(`/server/${server.id}`)))) ? {(installing && (!rootAdmin || (rootAdmin && !location.pathname.endsWith(`/server/${id}`)))) ?
<ScreenBlock <ScreenBlock
title={'Your server is installing.'} title={'Your server is installing.'}
image={'/assets/svgs/server_installing.svg'} image={'/assets/svgs/server_installing.svg'}
@ -136,17 +138,37 @@ const ServerRouter = ({ match, location }: RouteComponentProps<{ id: string }>)
)} )}
exact exact
/> />
<Route path={`${match.path}/databases`} component={requireServerPermission(DatabasesContainer, 'database.*')} exact/> <Route
<Route path={`${match.path}/schedules`} component={requireServerPermission(ScheduleContainer, 'schedule.*')} exact/> path={`${match.path}/databases`}
component={requireServerPermission(DatabasesContainer, 'database.*')}
exact
/>
<Route
path={`${match.path}/schedules`}
component={requireServerPermission(ScheduleContainer, 'schedule.*')}
exact
/>
<Route <Route
path={`${match.path}/schedules/:id`} path={`${match.path}/schedules/:id`}
component={ScheduleEditContainer} component={ScheduleEditContainer}
exact exact
/> />
<Route path={`${match.path}/users`} component={requireServerPermission(UsersContainer, 'user.*')} exact/> <Route
<Route path={`${match.path}/backups`} component={requireServerPermission(BackupContainer, 'backup.*')} exact/> path={`${match.path}/users`}
<Route path={`${match.path}/network`} component={requireServerPermission(NetworkContainer, 'allocation.*')} exact/> component={requireServerPermission(UsersContainer, 'user.*')}
<Route path={`${match.path}/startup`} component={requireServerPermission(StartupContainer, 'startup.*')} exact/> exact
/>
<Route
path={`${match.path}/backups`}
component={requireServerPermission(BackupContainer, 'backup.*')}
exact
/>
<Route
path={`${match.path}/network`}
component={requireServerPermission(NetworkContainer, 'allocation.*')}
exact
/>
<Route path={`${match.path}/startup`} component={StartupContainer} exact/>
<Route path={`${match.path}/settings`} component={SettingsContainer} exact/> <Route path={`${match.path}/settings`} component={SettingsContainer} exact/>
<Route path={'*'} component={NotFound}/> <Route path={'*'} component={NotFound}/>
</Switch> </Switch>

View file

@ -6,6 +6,7 @@ import subusers, { ServerSubuserStore } from '@/state/server/subusers';
import { composeWithDevTools } from 'redux-devtools-extension'; import { composeWithDevTools } from 'redux-devtools-extension';
import schedules, { ServerScheduleStore } from '@/state/server/schedules'; import schedules, { ServerScheduleStore } from '@/state/server/schedules';
import databases, { ServerDatabaseStore } from '@/state/server/databases'; import databases, { ServerDatabaseStore } from '@/state/server/databases';
import isEqual from 'react-fast-compare';
export type ServerStatus = 'offline' | 'starting' | 'stopping' | 'running'; export type ServerStatus = 'offline' | 'starting' | 'stopping' | 'running';
@ -15,6 +16,7 @@ interface ServerDataStore {
getServer: Thunk<ServerDataStore, string, Record<string, unknown>, ServerStore, Promise<void>>; getServer: Thunk<ServerDataStore, string, Record<string, unknown>, ServerStore, Promise<void>>;
setServer: Action<ServerDataStore, Server>; setServer: Action<ServerDataStore, Server>;
setServerFromState: Action<ServerDataStore, (s: Server) => Server>;
setPermissions: Action<ServerDataStore, string[]>; setPermissions: Action<ServerDataStore, string[]>;
} }
@ -29,11 +31,22 @@ const server: ServerDataStore = {
}), }),
setServer: action((state, payload) => { setServer: action((state, payload) => {
state.data = payload; if (!isEqual(payload, state.data)) {
state.data = payload;
}
}),
setServerFromState: action((state, payload) => {
const output = payload(state.data!);
if (!isEqual(output, state.data)) {
state.data = output;
}
}), }),
setPermissions: action((state, payload) => { setPermissions: action((state, payload) => {
state.permissions = payload; if (!isEqual(payload, state.permissions)) {
state.permissions = payload;
}
}), }),
}; };