Serverless function follow up (#9924)

- remove asynchronous serverless function build
- build serverless function synchronously instead on activate workflow
or execute
- add a loader on workflow code step test tab test button
- add a new `ServerlessFunctionSyncStatus` `BUILDING`
- add a new route to build a serverless function draft version 
- delay artificially execution to avoid UI flashing



https://github.com/user-attachments/assets/8d958d9a-ef41-4261-999e-6ea374191e33
This commit is contained in:
martmull
2025-01-31 17:12:42 +01:00
committed by GitHub
parent f47c0d45e3
commit ae62789159
28 changed files with 430 additions and 224 deletions

View File

@ -9,9 +9,11 @@ import { FeatureFlagKey } from '~/generated/graphql';
export const CmdEnterActionButton = ({
title,
onClick,
disabled = false,
}: {
title: string;
onClick: () => void;
disabled?: boolean;
}) => {
const isCommandMenuV2Enabled = useIsFeatureEnabled(
FeatureFlagKey.IsCommandMenuV2Enabled,
@ -32,6 +34,7 @@ export const CmdEnterActionButton = ({
accent="blue"
size="medium"
onClick={onClick}
disabled={disabled}
hotkeys={[getOsControlSymbol(), '⏎']}
/>
);

View File

@ -1,15 +1,16 @@
import styled from '@emotion/styled';
import { LightCopyIconButton } from '@/object-record/record-field/components/LightCopyIconButton';
import {
DEFAULT_OUTPUT_VALUE,
ServerlessFunctionTestData,
} from '@/workflow/states/serverlessFunctionTestDataFamilyState';
import { ServerlessFunctionTestData } from '@/workflow/states/serverlessFunctionTestDataFamilyState';
import { useTheme } from '@emotion/react';
import {
CodeEditor,
CoreEditorHeader,
IconSquareRoundedCheck,
IconSquareRoundedX,
IconLoader,
IconSettings,
AnimatedCircleLoading,
} from 'twenty-ui';
import { ServerlessFunctionExecutionStatus } from '~/generated-metadata/graphql';
@ -18,20 +19,33 @@ const StyledContainer = styled.div`
flex-direction: column;
`;
const StyledOutput = styled.div<{ status?: ServerlessFunctionExecutionStatus }>`
type OutputAccent = 'default' | 'success' | 'error';
const StyledInfoContainer = styled.div`
display: flex;
font-size: ${({ theme }) => theme.font.size.md};
`;
const StyledOutput = styled.div<{ accent?: OutputAccent }>`
align-items: center;
gap: ${({ theme }) => theme.spacing(1)};
color: ${({ theme, status }) =>
status === ServerlessFunctionExecutionStatus.SUCCESS
color: ${({ theme, accent }) =>
accent === 'success'
? theme.color.turquoise
: theme.color.red};
: accent === 'error'
? theme.color.red
: theme.font.color.secondary};
display: flex;
`;
export const ServerlessFunctionExecutionResult = ({
serverlessFunctionTestData,
isTesting = false,
isBuilding = false,
}: {
serverlessFunctionTestData: ServerlessFunctionTestData;
isTesting?: boolean;
isBuilding?: boolean;
}) => {
const theme = useTheme();
@ -40,25 +54,60 @@ export const ServerlessFunctionExecutionResult = ({
serverlessFunctionTestData.output.error ||
'';
const leftNode =
serverlessFunctionTestData.output.data === DEFAULT_OUTPUT_VALUE ? (
'Output'
) : (
<StyledOutput status={serverlessFunctionTestData.output.status}>
<IconSquareRoundedCheck size={theme.icon.size.md} />
{serverlessFunctionTestData.output.status ===
ServerlessFunctionExecutionStatus.SUCCESS
? '200 OK'
: '500 Error'}
{' - '}
{serverlessFunctionTestData.output.duration}ms
</StyledOutput>
);
const SuccessLeftNode = (
<StyledOutput accent="success">
<IconSquareRoundedCheck size={theme.icon.size.md} />
200 OK - {serverlessFunctionTestData.output.duration}ms
</StyledOutput>
);
const ErrorLeftNode = (
<StyledOutput accent="error">
<IconSquareRoundedX size={theme.icon.size.md} />
500 Error - {serverlessFunctionTestData.output.duration}ms
</StyledOutput>
);
const IdleLeftNode = 'Output';
const PendingLeftNode = (isTesting || isBuilding) && (
<StyledOutput>
<AnimatedCircleLoading>
{isTesting ? (
<IconLoader size={theme.icon.size.md} />
) : (
<IconSettings size={theme.icon.size.md} />
)}
</AnimatedCircleLoading>
<StyledInfoContainer>
{isTesting ? 'Running function' : 'Building function'}
</StyledInfoContainer>
</StyledOutput>
);
const computeLeftNode = () => {
if (isTesting || isBuilding) {
return PendingLeftNode;
}
if (
serverlessFunctionTestData.output.status ===
ServerlessFunctionExecutionStatus.ERROR
) {
return ErrorLeftNode;
}
if (
serverlessFunctionTestData.output.status ===
ServerlessFunctionExecutionStatus.SUCCESS
) {
return SuccessLeftNode;
}
return IdleLeftNode;
};
return (
<StyledContainer>
<CoreEditorHeader
leftNodes={[leftNode]}
leftNodes={[computeLeftNode()]}
rightNodes={[<LightCopyIconButton copyText={result} />]}
/>
<CodeEditor
@ -66,6 +115,7 @@ export const ServerlessFunctionExecutionResult = ({
language={serverlessFunctionTestData.language}
height={serverlessFunctionTestData.height}
options={{ readOnly: true, domReadOnly: true }}
isLoading={isTesting || isBuilding}
withHeader
/>
</StyledContainer>

View File

@ -2,55 +2,76 @@ import { useExecuteOneServerlessFunction } from '@/settings/serverless-functions
import { useRecoilState } from 'recoil';
import { serverlessFunctionTestDataFamilyState } from '@/workflow/states/serverlessFunctionTestDataFamilyState';
import { isDefined } from 'twenty-ui';
import { useState } from 'react';
import { useBuildDraftServerlessFunction } from '@/settings/serverless-functions/hooks/useBuildDraftServerlessFunction';
import { sleep } from '~/utils/sleep';
export const useTestServerlessFunction = ({
serverlessFunctionId,
serverlessFunctionVersion = 'draft',
callback,
}: {
serverlessFunctionId: string;
serverlessFunctionVersion?: string;
callback?: (testResult: object) => void;
}) => {
const [isTesting, setIsTesting] = useState(false);
const [isBuilding, setIsBuilding] = useState(false);
const { executeOneServerlessFunction } = useExecuteOneServerlessFunction();
const { buildDraftServerlessFunction } = useBuildDraftServerlessFunction();
const [serverlessFunctionTestData, setServerlessFunctionTestData] =
useRecoilState(serverlessFunctionTestDataFamilyState(serverlessFunctionId));
const testServerlessFunction = async () => {
const result = await executeOneServerlessFunction({
id: serverlessFunctionId,
payload: serverlessFunctionTestData.input,
version: serverlessFunctionVersion,
});
const testServerlessFunction = async (shouldBuild = true) => {
try {
if (shouldBuild) {
setIsBuilding(true);
await buildDraftServerlessFunction({
id: serverlessFunctionId,
});
setIsBuilding(false);
}
setIsTesting(true);
await sleep(200); // Delay artificially to avoid flashing the UI
const result = await executeOneServerlessFunction({
id: serverlessFunctionId,
payload: serverlessFunctionTestData.input,
version: 'draft',
});
if (isDefined(result?.data?.executeOneServerlessFunction?.data)) {
callback?.(result?.data?.executeOneServerlessFunction?.data);
setIsTesting(false);
if (isDefined(result?.data?.executeOneServerlessFunction?.data)) {
callback?.(result?.data?.executeOneServerlessFunction?.data);
}
setServerlessFunctionTestData((prev) => ({
...prev,
language: 'json',
height: 300,
output: {
data: result?.data?.executeOneServerlessFunction?.data
? JSON.stringify(
result?.data?.executeOneServerlessFunction?.data,
null,
4,
)
: undefined,
duration: result?.data?.executeOneServerlessFunction?.duration,
status: result?.data?.executeOneServerlessFunction?.status,
error: result?.data?.executeOneServerlessFunction?.error
? JSON.stringify(
result?.data?.executeOneServerlessFunction?.error,
null,
4,
)
: undefined,
},
}));
} catch (error) {
setIsBuilding(false);
setIsTesting(false);
throw error;
}
setServerlessFunctionTestData((prev) => ({
...prev,
language: 'json',
height: 300,
output: {
data: result?.data?.executeOneServerlessFunction?.data
? JSON.stringify(
result?.data?.executeOneServerlessFunction?.data,
null,
4,
)
: undefined,
duration: result?.data?.executeOneServerlessFunction?.duration,
status: result?.data?.executeOneServerlessFunction?.status,
error: result?.data?.executeOneServerlessFunction?.error
? JSON.stringify(
result?.data?.executeOneServerlessFunction?.error,
null,
4,
)
: undefined,
},
}));
};
return { testServerlessFunction };
return { testServerlessFunction, isTesting, isBuilding };
};

View File

@ -0,0 +1,13 @@
import { gql } from '@apollo/client';
import { SERVERLESS_FUNCTION_FRAGMENT } from '@/settings/serverless-functions/graphql/fragments/serverlessFunctionFragment';
export const BUILD_DRAFT_SERVERLESS_FUNCTION = gql`
${SERVERLESS_FUNCTION_FRAGMENT}
mutation BuildDraftServerlessFunction(
$input: BuildDraftServerlessFunctionInput!
) {
buildDraftServerlessFunction(input: $input) {
...ServerlessFunctionFields
}
}
`;

View File

@ -0,0 +1,29 @@
import { useApolloMetadataClient } from '@/object-metadata/hooks/useApolloMetadataClient';
import { useMutation } from '@apollo/client';
import { BUILD_DRAFT_SERVERLESS_FUNCTION } from '@/settings/serverless-functions/graphql/mutations/buildDraftServerlessFunction';
import {
BuildDraftServerlessFunctionMutation,
BuildDraftServerlessFunctionMutationVariables,
BuildDraftServerlessFunctionInput,
} from '~/generated-metadata/graphql';
export const useBuildDraftServerlessFunction = () => {
const apolloMetadataClient = useApolloMetadataClient();
const [mutate] = useMutation<
BuildDraftServerlessFunctionMutation,
BuildDraftServerlessFunctionMutationVariables
>(BUILD_DRAFT_SERVERLESS_FUNCTION, {
client: apolloMetadataClient,
});
const buildDraftServerlessFunction = async (
input: BuildDraftServerlessFunctionInput,
) => {
return await mutate({
variables: {
input,
},
});
};
return { buildDraftServerlessFunction };
};

View File

@ -25,6 +25,7 @@ export const useGetOneServerlessFunctionSourceCode = ({
input: { id, version },
},
onCompleted,
fetchPolicy: 'network-only',
});
return { code: data?.getServerlessFunctionSourceCode, loading };
};

View File

@ -6,9 +6,6 @@ import {
UpdateOneServerlessFunctionMutationVariables,
UpdateServerlessFunctionInput,
} from '~/generated-metadata/graphql';
import { useEffect, useState } from 'react';
import { FIND_ONE_SERVERLESS_FUNCTION } from '@/settings/serverless-functions/graphql/queries/findOneServerlessFunction';
import { sleep } from '~/utils/sleep';
import { getOperationName } from '@apollo/client/utilities';
import { FIND_ONE_SERVERLESS_FUNCTION_SOURCE_CODE } from '@/settings/serverless-functions/graphql/queries/findOneServerlessFunctionSourceCode';
@ -16,7 +13,6 @@ export const useUpdateOneServerlessFunction = (
serverlessFunctionId: string,
) => {
const apolloMetadataClient = useApolloMetadataClient();
const [isReady, setIsReady] = useState(false);
const [mutate] = useMutation<
UpdateOneServerlessFunctionMutation,
UpdateOneServerlessFunctionMutationVariables
@ -27,7 +23,7 @@ export const useUpdateOneServerlessFunction = (
const updateOneServerlessFunction = async (
input: Omit<UpdateServerlessFunctionInput, 'id'>,
) => {
const result = await mutate({
return await mutate({
variables: {
input: { ...input, id: serverlessFunctionId },
},
@ -35,37 +31,7 @@ export const useUpdateOneServerlessFunction = (
getOperationName(FIND_ONE_SERVERLESS_FUNCTION_SOURCE_CODE) ?? '',
],
});
setIsReady(false);
return result;
};
useEffect(() => {
let isMounted = true;
const pollFunctionStatus = async () => {
while (isMounted && !isReady) {
const { data } = await apolloMetadataClient.query({
query: FIND_ONE_SERVERLESS_FUNCTION,
variables: { input: { id: serverlessFunctionId } },
fetchPolicy: 'network-only', // Always fetch fresh data
});
const serverlessFunction = data?.findOneServerlessFunction;
if (serverlessFunction?.syncStatus === 'READY') {
setIsReady(true);
break;
}
await sleep(500);
}
};
pollFunctionStatus();
return () => {
isMounted = false; // Cleanup when the component unmounts
};
}, [serverlessFunctionId, apolloMetadataClient, isReady]);
return { updateOneServerlessFunction, isReady };
return { updateOneServerlessFunction };
};

View File

@ -13,7 +13,10 @@ export type ServerlessFunctionTestData = {
height: number;
};
export const DEFAULT_OUTPUT_VALUE = 'Enter an input above then press "Test"';
export const DEFAULT_OUTPUT_VALUE = {
data: 'Enter an input above then press "Test"',
status: ServerlessFunctionExecutionStatus.IDLE,
};
export const serverlessFunctionTestDataFamilyState = createFamilyState<
ServerlessFunctionTestData,
@ -24,6 +27,6 @@ export const serverlessFunctionTestDataFamilyState = createFamilyState<
language: 'plaintext',
height: 64,
input: {},
output: { data: DEFAULT_OUTPUT_VALUE },
output: DEFAULT_OUTPUT_VALUE,
},
});

View File

@ -86,12 +86,14 @@ export const WorkflowEditActionFormServerlessFunction = ({
}: WorkflowEditActionFormServerlessFunctionProps) => {
const theme = useTheme();
const { getIcon } = useIcons();
const [shouldBuildServerlessFunction, setShouldBuildServerlessFunction] =
useState(false);
const serverlessFunctionId = action.settings.input.serverlessFunctionId;
const serverlessFunctionVersion =
action.settings.input.serverlessFunctionVersion;
const tabListId = `${WORKFLOW_SERVERLESS_FUNCTION_TAB_LIST_COMPONENT_ID}_${serverlessFunctionId}`;
const { activeTabId, setActiveTabId } = useTabList(tabListId);
const { updateOneServerlessFunction, isReady } =
const { activeTabId } = useTabList(tabListId);
const { updateOneServerlessFunction } =
useUpdateOneServerlessFunction(serverlessFunctionId);
const { getUpdatableWorkflowVersion } = useGetUpdatableWorkflowVersion();
@ -126,13 +128,14 @@ export const WorkflowEditActionFormServerlessFunction = ({
});
};
const { testServerlessFunction } = useTestServerlessFunction({
serverlessFunctionId,
serverlessFunctionVersion,
callback: updateOutputSchemaFromTestResult,
});
const { testServerlessFunction, isTesting, isBuilding } =
useTestServerlessFunction({
serverlessFunctionId,
callback: updateOutputSchemaFromTestResult,
});
const handleSave = useDebouncedCallback(async () => {
setShouldBuildServerlessFunction(true);
await updateOneServerlessFunction({
name: formValues.name,
description: formValues.description,
@ -231,8 +234,10 @@ export const WorkflowEditActionFormServerlessFunction = ({
};
const handleRunFunction = async () => {
await testServerlessFunction();
setActiveTabId('test');
if (!isTesting) {
await testServerlessFunction(shouldBuildServerlessFunction);
setShouldBuildServerlessFunction(false);
}
};
const handleEditorDidMount = async (
@ -313,7 +318,6 @@ export const WorkflowEditActionFormServerlessFunction = ({
readonly={actionOptions.readonly}
/>
<StyledCodeEditorContainer>
<InputLabel>Code {!isReady && <span></span>}</InputLabel>
<CodeEditor
height={343}
value={formValues.code?.[INDEX_FILE_PATH]}
@ -339,6 +343,8 @@ export const WorkflowEditActionFormServerlessFunction = ({
<InputLabel>Result</InputLabel>
<ServerlessFunctionExecutionResult
serverlessFunctionTestData={serverlessFunctionTestData}
isBuilding={isBuilding}
isTesting={isTesting}
/>
</StyledCodeEditorContainer>
</>
@ -347,7 +353,11 @@ export const WorkflowEditActionFormServerlessFunction = ({
{activeTabId === 'test' && (
<RightDrawerFooter
actions={[
<CmdEnterActionButton title="Test" onClick={handleRunFunction} />,
<CmdEnterActionButton
title="Test"
onClick={handleRunFunction}
disabled={isTesting || isBuilding}
/>,
]}
/>
)}