Start cleaning up the mess of useServer; make startup page update in real time
This commit is contained in:
parent
179885b546
commit
c4418640eb
16 changed files with 175 additions and 61 deletions
|
@ -2,8 +2,8 @@ import http from '@/api/http';
|
|||
import { ServerEggVariable } from '@/api/server/types';
|
||||
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 });
|
||||
|
||||
return rawDataToServerEggVariable(data);
|
||||
return [ rawDataToServerEggVariable(data), data.meta.startup_command ];
|
||||
};
|
||||
|
|
18
resources/scripts/api/swr/getServerStartup.ts
Normal file
18
resources/scripts/api/swr/getServerStartup.ts
Normal 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 });
|
|
@ -1,5 +1,6 @@
|
|||
import React from 'react';
|
||||
import React, { memo } from 'react';
|
||||
import { usePermissions } from '@/plugins/usePermissions';
|
||||
import isEqual from 'react-fast-compare';
|
||||
|
||||
interface Props {
|
||||
action: string | string[];
|
||||
|
@ -23,4 +24,4 @@ const Can = ({ action, matchAny = false, renderOnError, children }: Props) => {
|
|||
);
|
||||
};
|
||||
|
||||
export default Can;
|
||||
export default memo(Can, isEqual);
|
||||
|
|
|
@ -1,13 +1,13 @@
|
|||
import useServer from '@/plugins/useServer';
|
||||
import PageContentBlock, { PageContentBlockProps } from '@/components/elements/PageContentBlock';
|
||||
import React from 'react';
|
||||
import { ServerContext } from '@/state/server';
|
||||
|
||||
interface Props extends PageContentBlockProps {
|
||||
title: string;
|
||||
}
|
||||
|
||||
const ServerContentBlock: React.FC<Props> = ({ title, children, ...props }) => {
|
||||
const { name } = useServer();
|
||||
const name = ServerContext.useStoreState(state => state.server.data!.name);
|
||||
|
||||
return (
|
||||
<PageContentBlock title={`${name} | ${title}`} {...props}>
|
||||
|
|
|
@ -45,4 +45,10 @@ const Spinner = ({ centered, ...props }: Props) => (
|
|||
);
|
||||
Spinner.DisplayName = 'Spinner';
|
||||
|
||||
Spinner.Size = {
|
||||
SMALL: 'small' as SpinnerSize,
|
||||
BASE: 'base' as SpinnerSize,
|
||||
LARGE: 'large' as SpinnerSize,
|
||||
};
|
||||
|
||||
export default Spinner;
|
||||
|
|
|
@ -1,26 +1,64 @@
|
|||
import React from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import TitledGreyBox from '@/components/elements/TitledGreyBox';
|
||||
import useServer from '@/plugins/useServer';
|
||||
import tw from 'twin.macro';
|
||||
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 { httpErrorToHuman } from '@/api/http';
|
||||
import { ServerContext } from '@/state/server';
|
||||
import { useDeepCompareEffect } from '@/plugins/useDeepCompareEffect';
|
||||
|
||||
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 (
|
||||
<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`}>
|
||||
{invocation}
|
||||
</p>
|
||||
!data ?
|
||||
(!error || (error && isValidating)) ?
|
||||
<Spinner centered size={Spinner.Size.LARGE}/>
|
||||
:
|
||||
<ServerError
|
||||
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>
|
||||
</TitledGreyBox>
|
||||
<div css={tw`grid gap-8 grid-cols-2 mt-10`}>
|
||||
{variables.map(variable => <VariableBox key={variable.envVariable} variable={variable}/>)}
|
||||
</div>
|
||||
</ServerContentBlock>
|
||||
</ServerContentBlock>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useState } from 'react';
|
||||
import React, { memo, useState } from 'react';
|
||||
import { ServerEggVariable } from '@/api/server/types';
|
||||
import TitledGreyBox from '@/components/elements/TitledGreyBox';
|
||||
import { usePermissions } from '@/plugins/usePermissions';
|
||||
|
@ -8,9 +8,11 @@ import tw from 'twin.macro';
|
|||
import { debounce } from 'debounce';
|
||||
import updateStartupVariable from '@/api/server/updateStartupVariable';
|
||||
import useServer from '@/plugins/useServer';
|
||||
import { ServerContext } from '@/state/server';
|
||||
import useFlash from '@/plugins/useFlash';
|
||||
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||
import getServerStartup from '@/api/swr/getServerStartup';
|
||||
import isEqual from 'react-fast-compare';
|
||||
import { ServerContext } from '@/state/server';
|
||||
|
||||
interface Props {
|
||||
variable: ServerEggVariable;
|
||||
|
@ -19,22 +21,21 @@ interface Props {
|
|||
const VariableBox = ({ variable }: Props) => {
|
||||
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 [ canEdit ] = usePermissions([ 'startup.update' ]);
|
||||
const { clearFlashes, clearAndAddHttpError } = useFlash();
|
||||
|
||||
const setServer = ServerContext.useStoreActions(actions => actions.server.setServer);
|
||||
const { mutate } = getServerStartup(uuid);
|
||||
|
||||
const setVariableValue = debounce((value: string) => {
|
||||
setLoading(true);
|
||||
clearFlashes(FLASH_KEY);
|
||||
|
||||
updateStartupVariable(server.uuid, variable.envVariable, value)
|
||||
.then(response => setServer({
|
||||
...server,
|
||||
variables: server.variables.map(v => v.envVariable === response.envVariable ? response : v),
|
||||
}))
|
||||
updateStartupVariable(uuid, variable.envVariable, value)
|
||||
.then(([ response, invocation ]) => mutate(data => ({
|
||||
invocation,
|
||||
variables: data.variables.map(v => v.envVariable === response.envVariable ? response : v),
|
||||
}), false))
|
||||
.catch(error => {
|
||||
console.error(error);
|
||||
clearAndAddHttpError({ error, key: FLASH_KEY });
|
||||
|
@ -74,4 +75,4 @@ const VariableBox = ({ variable }: Props) => {
|
|||
);
|
||||
};
|
||||
|
||||
export default VariableBox;
|
||||
export default memo(VariableBox, isEqual);
|
||||
|
|
|
@ -15,7 +15,7 @@ import { httpErrorToHuman } from '@/api/http';
|
|||
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||
import Can from '@/components/elements/Can';
|
||||
import { usePermissions } from '@/plugins/usePermissions';
|
||||
import { useDeepMemo } from '@/plugins/useDeepMemo';
|
||||
import { useDeepCompareMemo } from '@/plugins/useDeepCompareMemo';
|
||||
import tw from 'twin.macro';
|
||||
import Button from '@/components/elements/Button';
|
||||
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);
|
||||
|
||||
// The permissions that can be modified by this user.
|
||||
const editablePermissions = useDeepMemo(() => {
|
||||
const editablePermissions = useDeepCompareMemo(() => {
|
||||
const cleaned = Object.keys(permissions)
|
||||
.map(key => Object.keys(permissions[key].keys).map(pkey => `${key}.${pkey}`));
|
||||
|
||||
|
|
|
@ -1,9 +1,14 @@
|
|||
import React from 'react';
|
||||
import Can from '@/components/elements/Can';
|
||||
import ScreenBlock from '@/components/screens/ScreenBlock';
|
||||
import isEqual from 'react-fast-compare';
|
||||
|
||||
const requireServerPermission = (Component: React.ComponentType<any>, permissions: string | string[]) => {
|
||||
return class extends React.Component<any, any> {
|
||||
shouldComponentUpdate (nextProps: Readonly<any>) {
|
||||
return !isEqual(nextProps, this.props);
|
||||
}
|
||||
|
||||
render () {
|
||||
return (
|
||||
<Can
|
||||
|
|
5
resources/scripts/plugins/useDeepCompareEffect.ts
Normal file
5
resources/scripts/plugins/useDeepCompareEffect.ts
Normal 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));
|
5
resources/scripts/plugins/useDeepCompareMemo.ts
Normal file
5
resources/scripts/plugins/useDeepCompareMemo.ts
Normal 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));
|
|
@ -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;
|
||||
};
|
12
resources/scripts/plugins/useDeepMemoize.ts
Normal file
12
resources/scripts/plugins/useDeepMemoize.ts
Normal 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;
|
||||
};
|
|
@ -1,10 +1,10 @@
|
|||
import { ServerContext } from '@/state/server';
|
||||
import { useDeepMemo } from '@/plugins/useDeepMemo';
|
||||
import { useDeepCompareMemo } from '@/plugins/useDeepCompareMemo';
|
||||
|
||||
export const usePermissions = (action: string | string[]): boolean[] => {
|
||||
const userPermissions = ServerContext.useStoreState(state => state.server.permissions);
|
||||
|
||||
return useDeepMemo(() => {
|
||||
return useDeepCompareMemo(() => {
|
||||
if (userPermissions[0] === '*') {
|
||||
return Array(Array.isArray(action) ? action.length : 1).fill(true);
|
||||
}
|
||||
|
|
|
@ -22,7 +22,6 @@ import ServerError from '@/components/screens/ServerError';
|
|||
import { httpErrorToHuman } from '@/api/http';
|
||||
import NotFound from '@/components/screens/NotFound';
|
||||
import { useStoreState } from 'easy-peasy';
|
||||
import useServer from '@/plugins/useServer';
|
||||
import ScreenBlock from '@/components/screens/ScreenBlock';
|
||||
import SubNavigation from '@/components/elements/SubNavigation';
|
||||
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 [ error, setError ] = useState('');
|
||||
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 clearServerState = ServerContext.useStoreActions(actions => actions.clearServerState);
|
||||
|
||||
|
@ -43,8 +45,8 @@ const ServerRouter = ({ match, location }: RouteComponentProps<{ id: string }>)
|
|||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setInstalling(server?.isInstalling !== false);
|
||||
}, [ server?.isInstalling ]);
|
||||
setInstalling(!!isInstalling);
|
||||
}, [ isInstalling ]);
|
||||
|
||||
useEffect(() => {
|
||||
setError('');
|
||||
|
@ -71,7 +73,7 @@ const ServerRouter = ({ match, location }: RouteComponentProps<{ id: string }>)
|
|||
return (
|
||||
<React.Fragment key={'server-router'}>
|
||||
<NavigationBar/>
|
||||
{!server ?
|
||||
{(!uuid || !id) ?
|
||||
error ?
|
||||
<ServerError message={error}/>
|
||||
:
|
||||
|
@ -111,7 +113,7 @@ const ServerRouter = ({ match, location }: RouteComponentProps<{ id: string }>)
|
|||
</CSSTransition>
|
||||
<InstallListener/>
|
||||
<WebsocketHandler/>
|
||||
{(installing && (!rootAdmin || (rootAdmin && !location.pathname.endsWith(`/server/${server.id}`)))) ?
|
||||
{(installing && (!rootAdmin || (rootAdmin && !location.pathname.endsWith(`/server/${id}`)))) ?
|
||||
<ScreenBlock
|
||||
title={'Your server is installing.'}
|
||||
image={'/assets/svgs/server_installing.svg'}
|
||||
|
@ -136,17 +138,37 @@ const ServerRouter = ({ match, location }: RouteComponentProps<{ id: string }>)
|
|||
)}
|
||||
exact
|
||||
/>
|
||||
<Route path={`${match.path}/databases`} component={requireServerPermission(DatabasesContainer, 'database.*')} exact/>
|
||||
<Route path={`${match.path}/schedules`} component={requireServerPermission(ScheduleContainer, 'schedule.*')} exact/>
|
||||
<Route
|
||||
path={`${match.path}/databases`}
|
||||
component={requireServerPermission(DatabasesContainer, 'database.*')}
|
||||
exact
|
||||
/>
|
||||
<Route
|
||||
path={`${match.path}/schedules`}
|
||||
component={requireServerPermission(ScheduleContainer, 'schedule.*')}
|
||||
exact
|
||||
/>
|
||||
<Route
|
||||
path={`${match.path}/schedules/:id`}
|
||||
component={ScheduleEditContainer}
|
||||
exact
|
||||
/>
|
||||
<Route path={`${match.path}/users`} component={requireServerPermission(UsersContainer, 'user.*')} 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={requireServerPermission(StartupContainer, 'startup.*')} exact/>
|
||||
<Route
|
||||
path={`${match.path}/users`}
|
||||
component={requireServerPermission(UsersContainer, 'user.*')}
|
||||
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={'*'} component={NotFound}/>
|
||||
</Switch>
|
||||
|
|
|
@ -6,6 +6,7 @@ import subusers, { ServerSubuserStore } from '@/state/server/subusers';
|
|||
import { composeWithDevTools } from 'redux-devtools-extension';
|
||||
import schedules, { ServerScheduleStore } from '@/state/server/schedules';
|
||||
import databases, { ServerDatabaseStore } from '@/state/server/databases';
|
||||
import isEqual from 'react-fast-compare';
|
||||
|
||||
export type ServerStatus = 'offline' | 'starting' | 'stopping' | 'running';
|
||||
|
||||
|
@ -15,6 +16,7 @@ interface ServerDataStore {
|
|||
|
||||
getServer: Thunk<ServerDataStore, string, Record<string, unknown>, ServerStore, Promise<void>>;
|
||||
setServer: Action<ServerDataStore, Server>;
|
||||
setServerFromState: Action<ServerDataStore, (s: Server) => Server>;
|
||||
setPermissions: Action<ServerDataStore, string[]>;
|
||||
}
|
||||
|
||||
|
@ -29,11 +31,22 @@ const server: ServerDataStore = {
|
|||
}),
|
||||
|
||||
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) => {
|
||||
state.permissions = payload;
|
||||
if (!isEqual(payload, state.permissions)) {
|
||||
state.permissions = payload;
|
||||
}
|
||||
}),
|
||||
};
|
||||
|
||||
|
|
Loading…
Reference in a new issue