diff --git a/resources/scripts/api/server/updateStartupVariable.ts b/resources/scripts/api/server/updateStartupVariable.ts index 88231eccc..74498caa8 100644 --- a/resources/scripts/api/server/updateStartupVariable.ts +++ b/resources/scripts/api/server/updateStartupVariable.ts @@ -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 => { +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 ]; }; diff --git a/resources/scripts/api/swr/getServerStartup.ts b/resources/scripts/api/swr/getServerStartup.ts new file mode 100644 index 000000000..fff0263f9 --- /dev/null +++ b/resources/scripts/api/swr/getServerStartup.ts @@ -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 => { + 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 }); diff --git a/resources/scripts/components/elements/Can.tsx b/resources/scripts/components/elements/Can.tsx index 4ea140d3e..dd9d4845f 100644 --- a/resources/scripts/components/elements/Can.tsx +++ b/resources/scripts/components/elements/Can.tsx @@ -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); diff --git a/resources/scripts/components/elements/ServerContentBlock.tsx b/resources/scripts/components/elements/ServerContentBlock.tsx index d2e9bebc3..0457d34a5 100644 --- a/resources/scripts/components/elements/ServerContentBlock.tsx +++ b/resources/scripts/components/elements/ServerContentBlock.tsx @@ -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 = ({ title, children, ...props }) => { - const { name } = useServer(); + const name = ServerContext.useStoreState(state => state.server.data!.name); return ( diff --git a/resources/scripts/components/elements/Spinner.tsx b/resources/scripts/components/elements/Spinner.tsx index 127ab6524..d2f7ffb24 100644 --- a/resources/scripts/components/elements/Spinner.tsx +++ b/resources/scripts/components/elements/Spinner.tsx @@ -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; diff --git a/resources/scripts/components/server/startup/StartupContainer.tsx b/resources/scripts/components/server/startup/StartupContainer.tsx index 2df1e44e0..bf3b7651c 100644 --- a/resources/scripts/components/server/startup/StartupContainer.tsx +++ b/resources/scripts/components/server/startup/StartupContainer.tsx @@ -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 ( - - -
-

- {invocation} -

+ !data ? + (!error || (error && isValidating)) ? + + : + mutate()} + /> + : + + +
+

+ {data.invocation} +

+
+
+
+ {data.variables.map(variable => )}
- -
- {variables.map(variable => )} -
-
+ ); }; diff --git a/resources/scripts/components/server/startup/VariableBox.tsx b/resources/scripts/components/server/startup/VariableBox.tsx index ffc589329..27c2cfa8f 100644 --- a/resources/scripts/components/server/startup/VariableBox.tsx +++ b/resources/scripts/components/server/startup/VariableBox.tsx @@ -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); diff --git a/resources/scripts/components/server/users/EditSubuserModal.tsx b/resources/scripts/components/server/users/EditSubuserModal.tsx index 02776932e..f3e0f81c7 100644 --- a/resources/scripts/components/server/users/EditSubuserModal.tsx +++ b/resources/scripts/components/server/users/EditSubuserModal.tsx @@ -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(({ 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}`)); diff --git a/resources/scripts/hoc/requireServerPermission.tsx b/resources/scripts/hoc/requireServerPermission.tsx index fc3917da3..2cafa8fcb 100644 --- a/resources/scripts/hoc/requireServerPermission.tsx +++ b/resources/scripts/hoc/requireServerPermission.tsx @@ -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, permissions: string | string[]) => { return class extends React.Component { + shouldComponentUpdate (nextProps: Readonly) { + return !isEqual(nextProps, this.props); + } + render () { return ( + useEffect(callback, useDeepMemoize(dependencies)); diff --git a/resources/scripts/plugins/useDeepCompareMemo.ts b/resources/scripts/plugins/useDeepCompareMemo.ts new file mode 100644 index 000000000..635f09fe2 --- /dev/null +++ b/resources/scripts/plugins/useDeepCompareMemo.ts @@ -0,0 +1,5 @@ +import { DependencyList, useMemo } from 'react'; +import { useDeepMemoize } from '@/plugins/useDeepMemoize'; + +export const useDeepCompareMemo = (callback: () => T, dependencies: DependencyList) => + useMemo(callback, useDeepMemoize(dependencies)); diff --git a/resources/scripts/plugins/useDeepMemo.ts b/resources/scripts/plugins/useDeepMemo.ts deleted file mode 100644 index ccf602853..000000000 --- a/resources/scripts/plugins/useDeepMemo.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { useRef } from 'react'; -import isEqual from 'react-fast-compare'; - -export const useDeepMemo = (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; -}; diff --git a/resources/scripts/plugins/useDeepMemoize.ts b/resources/scripts/plugins/useDeepMemoize.ts new file mode 100644 index 000000000..228c402aa --- /dev/null +++ b/resources/scripts/plugins/useDeepMemoize.ts @@ -0,0 +1,12 @@ +import { DependencyList, MutableRefObject, useRef } from 'react'; +import isEqual from 'react-fast-compare'; + +export const useDeepMemoize = (value: T): T => { + const ref: MutableRefObject = useRef(); + + if (!isEqual(value, ref.current)) { + ref.current = value; + } + + return ref.current as T; +}; diff --git a/resources/scripts/plugins/usePermissions.ts b/resources/scripts/plugins/usePermissions.ts index 1b383114c..09e1a705d 100644 --- a/resources/scripts/plugins/usePermissions.ts +++ b/resources/scripts/plugins/usePermissions.ts @@ -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); } diff --git a/resources/scripts/routers/ServerRouter.tsx b/resources/scripts/routers/ServerRouter.tsx index 7e0e30685..b830a3c7a 100644 --- a/resources/scripts/routers/ServerRouter.tsx +++ b/resources/scripts/routers/ServerRouter.tsx @@ -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 ( - {!server ? + {(!uuid || !id) ? error ? : @@ -111,7 +113,7 @@ const ServerRouter = ({ match, location }: RouteComponentProps<{ id: string }>) - {(installing && (!rootAdmin || (rootAdmin && !location.pathname.endsWith(`/server/${server.id}`)))) ? + {(installing && (!rootAdmin || (rootAdmin && !location.pathname.endsWith(`/server/${id}`)))) ? ) )} exact /> - - + + - - - - + + + + diff --git a/resources/scripts/state/server/index.ts b/resources/scripts/state/server/index.ts index 87023a020..f6c8136e4 100644 --- a/resources/scripts/state/server/index.ts +++ b/resources/scripts/state/server/index.ts @@ -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, ServerStore, Promise>; setServer: Action; + setServerFromState: Action Server>; setPermissions: Action; } @@ -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; + } }), };