From 0b2395da3fbf5a51e592967aaa90f3e26837223e Mon Sep 17 00:00:00 2001 From: Tedi Mitiku Date: Wed, 13 Mar 2024 17:03:31 -0400 Subject: [PATCH] feat: support package nodes in enclave builder ui (#2283) ## Description THIS PR IS A COPY OF THIS PR: https://github.com/kurtosis-tech/kurtosis/pull/2250 The frontend PR was initially made off the backend PR branch. I merged the backend PR branch to main. Then merged the frontend PR into the backend PR branch. Now merging into main again. --------- Co-authored-by: Ben Gazzard --- enclave-manager/web/packages/app/package.json | 2 +- .../client/enclaveManager/KurtosisClient.ts | 28 +- .../configuration/PackageSelector.tsx | 193 ++++++++++ .../drawer/bodies/PackageSelectBody.tsx | 192 +--------- .../enclaveBuilder/EnclaveBuilderDrawer.tsx | 4 +- .../enclaveBuilder/KurtosisArtifactNode.tsx | 24 -- .../enclaveBuilder/KurtosisNode.tsx | 236 ------------ .../enclaveBuilder/KurtosisServiceNode.tsx | 145 -------- .../components/enclaveBuilder/Toolbar.tsx | 250 +++++++++++++ .../VariableContextProvider.tsx | 19 +- .../components/enclaveBuilder/Visualiser.tsx | 211 +---------- .../input/FileTreeArgumentInput.tsx | 9 +- .../enclaveBuilder/input/ImageConfigInput.tsx | 37 +- .../input/MentionStringArgumentInput.css | 9 +- .../input/MentionStringArgumentInput.tsx | 12 +- .../input/MountArtifactFileInput.tsx | 4 +- .../input/PortConfigurationInput.tsx | 3 +- .../input/SelectServiceInput.tsx | 24 ++ .../input/StoreConfigurationInput.tsx | 29 ++ .../modals/ConfigurePackageNodeModal.tsx | 95 +++++ .../enclaveBuilder/modals/EditFileModal.tsx | 22 +- .../nodes/KurtosisArtifactNode.tsx | 44 +++ .../enclaveBuilder/nodes/KurtosisExecNode.tsx | 100 +++++ .../enclaveBuilder/nodes/KurtosisNode.tsx | 333 +++++++++++++++++ .../nodes/KurtosisPackageNode.tsx | 341 ++++++++++++++++++ .../{ => nodes}/KurtosisPythonNode.tsx | 84 +++-- .../nodes/KurtosisServiceNode.tsx | 142 ++++++++ .../{ => nodes}/KurtosisShellNode.tsx | 89 +++-- .../components/enclaveBuilder/types.ts | 125 ++++++- .../components/enclaveBuilder/utils.ts | 285 ++++++++++----- .../components/form/BooleanArgumentInput.tsx | 6 +- .../components/form/DictArgumentInput.tsx | 12 +- .../components/form/IntegerArgumentInput.tsx | 2 +- .../components/form/KurtosisFormControl.tsx | 12 +- .../components/form/ListArgumentInput.tsx | 22 +- .../components/form/OptionArgumentInput.tsx | 3 +- .../components/form/SelectArgumentInput.tsx | 2 +- .../components/form/StringArgumentInput.tsx | 3 +- .../components/widgets/PortsSummary.tsx | 7 +- .../web/packages/components/package.json | 2 +- enclave-manager/web/yarn.lock | 8 +- 41 files changed, 2149 insertions(+), 1021 deletions(-) create mode 100644 enclave-manager/web/packages/app/src/emui/enclaves/components/configuration/PackageSelector.tsx delete mode 100644 enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/KurtosisArtifactNode.tsx delete mode 100644 enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/KurtosisNode.tsx delete mode 100644 enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/KurtosisServiceNode.tsx create mode 100644 enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/Toolbar.tsx create mode 100644 enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/input/SelectServiceInput.tsx create mode 100644 enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/input/StoreConfigurationInput.tsx create mode 100644 enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/modals/ConfigurePackageNodeModal.tsx create mode 100644 enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/nodes/KurtosisArtifactNode.tsx create mode 100644 enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/nodes/KurtosisExecNode.tsx create mode 100644 enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/nodes/KurtosisNode.tsx create mode 100644 enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/nodes/KurtosisPackageNode.tsx rename enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/{ => nodes}/KurtosisPythonNode.tsx (63%) create mode 100644 enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/nodes/KurtosisServiceNode.tsx rename enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/{ => nodes}/KurtosisShellNode.tsx (55%) diff --git a/enclave-manager/web/packages/app/package.json b/enclave-manager/web/packages/app/package.json index 7fd8b5daec..0a8b790fab 100644 --- a/enclave-manager/web/packages/app/package.json +++ b/enclave-manager/web/packages/app/package.json @@ -9,7 +9,7 @@ "enclave-manager-sdk": "file:../../../api/typescript", "html-react-parser": "^4.2.2", "js-cookie": "^3.0.5", - "kurtosis-cloud-indexer-sdk": "^0.0.2", + "kurtosis-cloud-indexer-sdk": "^0.0.31", "kurtosis-ui-components": "0.88.5", "react-error-boundary": "^4.0.11", "react-hook-form": "^7.47.0", diff --git a/enclave-manager/web/packages/app/src/client/enclaveManager/KurtosisClient.ts b/enclave-manager/web/packages/app/src/client/enclaveManager/KurtosisClient.ts index 68074ecdab..bb84b06c6a 100644 --- a/enclave-manager/web/packages/app/src/client/enclaveManager/KurtosisClient.ts +++ b/enclave-manager/web/packages/app/src/client/enclaveManager/KurtosisClient.ts @@ -5,6 +5,7 @@ import { RunStarlarkPackageArgs, RunStarlarkScriptArgs, ServiceInfo, + StarlarkPackagePlanYamlArgs, } from "enclave-manager-sdk/build/api_container_service_pb"; import { CreateEnclaveArgs, @@ -24,6 +25,7 @@ import { InspectFilesArtifactContentsRequest, RunStarlarkPackageRequest, RunStarlarkScriptRequest, + StarlarkPackagePlanYamlArgs as StarlarkPackagePlanYamlArgsRequest, } from "enclave-manager-sdk/build/kurtosis_enclave_manager_api_pb"; import { assertDefined, asyncResult, isDefined, RemoveFunctions } from "kurtosis-ui-components"; import { EnclaveFullInfo } from "../../emui/enclaves/types"; @@ -184,15 +186,15 @@ export abstract class KurtosisClient { } async createEnclave( - enclaveName: string, - apiContainerLogLevel: string, + enclaveName?: string, + apiContainerLogLevel?: string, productionMode?: boolean, apiContainerVersionTag?: string, ) { return asyncResult(() => { const request = new CreateEnclaveArgs({ - enclaveName, - apiContainerLogLevel, + enclaveName: enclaveName || "", + apiContainerLogLevel: apiContainerLogLevel || "info", mode: productionMode ? EnclaveMode.PRODUCTION : EnclaveMode.TEST, apiContainerVersionTag: apiContainerVersionTag || "", }); @@ -237,4 +239,22 @@ export abstract class KurtosisClient { }); return this.client.runStarlarkScript(request, this.getHeaderOptions()); } + + async getStarlarkPackagePlanYaml( + apicInfo: RemoveFunctions, + packageId: string, + args: Record, + ) { + return asyncResult(() => { + const request = new StarlarkPackagePlanYamlArgsRequest({ + apicIpAddress: apicInfo.bridgeIpAddress, + apicPort: apicInfo.grpcPortInsideEnclave, + starlarkPackagePlanYamlArgs: new StarlarkPackagePlanYamlArgs({ + packageId: packageId, + serializedParams: JSON.stringify(args), + }), + }); + return this.client.getStarlarkPackagePlanYaml(request, this.getHeaderOptions()); + }); + } } diff --git a/enclave-manager/web/packages/app/src/emui/enclaves/components/configuration/PackageSelector.tsx b/enclave-manager/web/packages/app/src/emui/enclaves/components/configuration/PackageSelector.tsx new file mode 100644 index 0000000000..be0059a68c --- /dev/null +++ b/enclave-manager/web/packages/app/src/emui/enclaves/components/configuration/PackageSelector.tsx @@ -0,0 +1,193 @@ +import { SmallCloseIcon } from "@chakra-ui/icons"; +import { + Button, + Flex, + Icon, + Input, + InputGroup, + InputLeftElement, + InputRightElement, + Spinner, + Text, +} from "@chakra-ui/react"; +import { KurtosisPackage } from "kurtosis-cloud-indexer-sdk"; +import { + FindCommand, + isDefined, + KurtosisAlert, + KurtosisPackageCardHorizontal, + parsePackageUrl, + useKeyboardAction, + useSavedPackages, +} from "kurtosis-ui-components"; +import { debounce } from "lodash"; +import { ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { FiSearch } from "react-icons/fi"; +import { useCatalogContext } from "../../../catalog/CatalogContext"; + +type ExactMatchState = + | { type: "loading"; url: string } + | { type: "loaded"; package: KurtosisPackage } + | { type: "error"; error: string }; + +type PackageSelectorProps = { + onPackageSelected: (kurtosisPackage: KurtosisPackage) => void; +}; +export const PackageSelector = ({ onPackageSelected }: PackageSelectorProps) => { + const searchRef = useRef(null); + const [searchTerm, setSearchTerm] = useState(""); + + const [exactMatch, setExactMatch] = useState(); + const { catalog, getSinglePackage } = useCatalogContext(); + + const checkSinglePackageDebounced = useMemo( + () => + debounce( + async (packageName: string) => { + const singlePackageResult = await getSinglePackage(packageName); + if (singlePackageResult.isErr) { + setExactMatch({ type: "error", error: singlePackageResult.error }); + return; + } + if (isDefined(singlePackageResult.value.package)) { + setExactMatch({ type: "loaded", package: singlePackageResult.value.package }); + } + }, + 1000, + { trailing: true, leading: false }, + ), + [getSinglePackage], + ); + + const startCheckSinglePackage = useCallback( + async (packageName: string) => { + let isKurtosisPackageName = false; + try { + parsePackageUrl(packageName); + isKurtosisPackageName = true; + } catch (error: any) { + // This packageName isn't a kurtosis package url + } + if (isKurtosisPackageName) { + setExactMatch({ type: "loading", url: packageName }); + checkSinglePackageDebounced(packageName); + } else { + setExactMatch(undefined); + } + }, + [checkSinglePackageDebounced], + ); + + const searchResults = useMemo( + () => + catalog.map((catalog) => + catalog.packages.filter( + (kurtosisPackage) => kurtosisPackage.name.toLowerCase().indexOf(searchTerm.toLowerCase()) >= 0, + ), + ), + [catalog, searchTerm], + ); + + const { savedPackages } = useSavedPackages(); + + const handleSearchTermChange = async (e: ChangeEvent) => { + setSearchTerm(e.target.value); + }; + + useKeyboardAction(useMemo(() => ({ find: () => searchRef.current?.focus() }), [searchRef])); + + useEffect(() => { + startCheckSinglePackage(searchTerm); + }, [startCheckSinglePackage, searchTerm]); + + if (searchResults.isErr) { + return ; + } + + return ( + <> + + + + + + + {searchTerm.length > 0 ? ( + + ) : ( + + )} + + + {isDefined(exactMatch) && ( + + + Exact Match + + {exactMatch.type === "loading" && ( + + + Looking for a Kurtosis Package at {exactMatch.url} + + )} + {exactMatch.type === "loaded" && ( + onPackageSelected(exactMatch.package)} + /> + )} + {exactMatch.type === "error" && ( + + )} + + )} + {(searchTerm.length > 0 || savedPackages.length === 0) && ( + + + {searchTerm.length === 0 ? "All Packages" : "Search Results"} + + {searchResults.value.map((kurtosisPackage) => ( + onPackageSelected(kurtosisPackage)} + /> + ))} + + )} + {searchTerm.length === 0 && savedPackages.length > 0 && ( + + + Saved + + {savedPackages.map((kurtosisPackage) => ( + onPackageSelected(kurtosisPackage)} + /> + ))} + + All Packages + + {searchResults.value.map((kurtosisPackage) => ( + onPackageSelected(kurtosisPackage)} + /> + ))} + + )} + + ); +}; diff --git a/enclave-manager/web/packages/app/src/emui/enclaves/components/configuration/drawer/bodies/PackageSelectBody.tsx b/enclave-manager/web/packages/app/src/emui/enclaves/components/configuration/drawer/bodies/PackageSelectBody.tsx index 699e83c5d7..c02b7f5e19 100644 --- a/enclave-manager/web/packages/app/src/emui/enclaves/components/configuration/drawer/bodies/PackageSelectBody.tsx +++ b/enclave-manager/web/packages/app/src/emui/enclaves/components/configuration/drawer/bodies/PackageSelectBody.tsx @@ -1,40 +1,9 @@ -import { SmallCloseIcon } from "@chakra-ui/icons"; -import { - Button, - DrawerBody, - DrawerFooter, - DrawerHeader, - Flex, - Icon, - Input, - InputGroup, - InputLeftElement, - InputRightElement, - Spinner, - Text, -} from "@chakra-ui/react"; +import { Button, DrawerBody, DrawerFooter, DrawerHeader, Flex, Text } from "@chakra-ui/react"; import { KurtosisPackage } from "kurtosis-cloud-indexer-sdk"; -import { - FindCommand, - isDefined, - KurtosisAlert, - KurtosisPackageCardHorizontal, - parsePackageUrl, - useKeyboardAction, - useSavedPackages, -} from "kurtosis-ui-components"; -import { debounce } from "lodash"; -import { ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { FiSearch } from "react-icons/fi"; -import { useCatalogContext } from "../../../../../catalog/CatalogContext"; +import { PackageSelector } from "../../PackageSelector"; import { DrawerExpandCollapseButton } from "../DrawerExpandCollapseButton"; import { DrawerSizes } from "../types"; -type ExactMatchState = - | { type: "loading"; url: string } - | { type: "loaded"; package: KurtosisPackage } - | { type: "error"; error: string }; - type PackageSelectBodyProps = { onPackageSelected: (kurtosisPackage: KurtosisPackage) => void; onClose: () => void; @@ -47,80 +16,6 @@ export const PackageSelectBody = ({ drawerSize, onDrawerSizeClick, }: PackageSelectBodyProps) => { - const searchRef = useRef(null); - const [searchTerm, setSearchTerm] = useState(""); - - const [exactMatch, setExactMatch] = useState(); - const { catalog, getSinglePackage } = useCatalogContext(); - - const checkSinglePackageDebounced = useMemo( - () => - debounce( - async (packageName: string) => { - const singlePackageResult = await getSinglePackage(packageName); - if (singlePackageResult.isErr) { - setExactMatch({ type: "error", error: singlePackageResult.error }); - return; - } - if (isDefined(singlePackageResult.value.package)) { - setExactMatch({ type: "loaded", package: singlePackageResult.value.package }); - } - }, - 1000, - { trailing: true, leading: false }, - ), - [getSinglePackage], - ); - - const startCheckSinglePackage = useCallback( - async (packageName: string) => { - let isKurtosisPackageName = false; - try { - parsePackageUrl(packageName); - isKurtosisPackageName = true; - } catch (error: any) { - // This packageName isn't a kurtosis package url - } - if (isKurtosisPackageName) { - setExactMatch({ type: "loading", url: packageName }); - checkSinglePackageDebounced(packageName); - } else { - setExactMatch(undefined); - } - }, - [checkSinglePackageDebounced], - ); - - const searchResults = useMemo( - () => - catalog.map((catalog) => - catalog.packages.filter( - (kurtosisPackage) => kurtosisPackage.name.toLowerCase().indexOf(searchTerm.toLowerCase()) >= 0, - ), - ), - [catalog, searchTerm], - ); - - const { savedPackages } = useSavedPackages(); - - const handleSearchTermChange = async (e: ChangeEvent) => { - setSearchTerm(e.target.value); - }; - - useKeyboardAction(useMemo(() => ({ find: () => searchRef.current?.focus() }), [searchRef])); - - useEffect(() => { - startCheckSinglePackage(searchTerm); - }, [startCheckSinglePackage, searchTerm]); - - if (searchResults.isErr) { - return ( - - - - ); - } - return ( <> @@ -130,88 +25,7 @@ export const PackageSelectBody = ({ - - - - - - - {searchTerm.length > 0 ? ( - - ) : ( - - )} - - - {isDefined(exactMatch) && ( - - - Exact Match - - {exactMatch.type === "loading" && ( - - - Looking for a Kurtosis Package at {exactMatch.url} - - )} - {exactMatch.type === "loaded" && ( - onPackageSelected(exactMatch.package)} - /> - )} - {exactMatch.type === "error" && ( - - )} - - )} - {(searchTerm.length > 0 || savedPackages.length === 0) && ( - - - {searchTerm.length === 0 ? "All Packages" : "Search Results"} - - {searchResults.value.map((kurtosisPackage) => ( - onPackageSelected(kurtosisPackage)} - /> - ))} - - )} - {searchTerm.length === 0 && savedPackages.length > 0 && ( - - - Saved - - {savedPackages.map((kurtosisPackage) => ( - onPackageSelected(kurtosisPackage)} - /> - ))} - - All Packages - - {searchResults.value.map((kurtosisPackage) => ( - onPackageSelected(kurtosisPackage)} - /> - ))} - - )} + diff --git a/enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/EnclaveBuilderDrawer.tsx b/enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/EnclaveBuilderDrawer.tsx index 1c4af19c5f..781622f5a1 100644 --- a/enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/EnclaveBuilderDrawer.tsx +++ b/enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/EnclaveBuilderDrawer.tsx @@ -23,7 +23,7 @@ import { useEnclavesContext } from "../../EnclavesContext"; import { EnclaveFullInfo } from "../../types"; import { ViewStarlarkModal } from "./modals/ViewStarlarkModal"; import { KurtosisNodeData } from "./types"; -import { getInitialGraphStateFromEnclave, getNodeName } from "./utils"; +import { getInitialGraphStateFromEnclave } from "./utils"; import { useVariableContext, VariableContextProvider } from "./VariableContextProvider"; import { Visualiser, VisualiserImperativeAttributes } from "./Visualiser"; @@ -106,7 +106,7 @@ const EnclaveBuilderDrawerImpl = ({ () => Object.values(data) .filter((nodeData) => !nodeData.isValid) - .map((nodeData) => `${nodeData.type} ${getNodeName(nodeData)} has invalid data`), + .map((nodeData) => `${nodeData.type} ${nodeData.name} has invalid data`), [data], ); const [isLoading, setIsLoading] = useState(false); diff --git a/enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/KurtosisArtifactNode.tsx b/enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/KurtosisArtifactNode.tsx deleted file mode 100644 index 3cc2cc1871..0000000000 --- a/enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/KurtosisArtifactNode.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { memo } from "react"; -import { NodeProps } from "reactflow"; -import { KurtosisFormControl } from "../form/KurtosisFormControl"; -import { StringArgumentInput } from "../form/StringArgumentInput"; -import { FileTreeArgumentInput } from "./input/FileTreeArgumentInput"; -import { validateName } from "./input/validators"; -import { KurtosisNode } from "./KurtosisNode"; -import { KurtosisArtifactNodeData } from "./types"; - -export const KurtosisArtifactNode = memo( - ({ id, selected }: NodeProps) => { - return ( - - name={"artifactName"} label={"Artifact Name"} isRequired> - - - - - - - ); - }, - (oldProps, newProps) => oldProps.id === newProps.id && oldProps.selected === newProps.selected, -); diff --git a/enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/KurtosisNode.tsx b/enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/KurtosisNode.tsx deleted file mode 100644 index 60c27df1a2..0000000000 --- a/enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/KurtosisNode.tsx +++ /dev/null @@ -1,236 +0,0 @@ -import { Flex, Icon, IconButton, Text, useToken } from "@chakra-ui/react"; -import { isDefined } from "kurtosis-ui-components"; -import { debounce } from "lodash"; -import { FC, memo, PropsWithChildren, useCallback, useEffect, useMemo } from "react"; -import { DefaultValues, FormProvider, useForm } from "react-hook-form"; -import { FiCpu, FiFile, FiTerminal, FiTrash } from "react-icons/fi"; -import { RxCornerBottomRight } from "react-icons/rx"; -import { Handle, NodeResizeControl, Position, useReactFlow, useViewport } from "reactflow"; -import { KurtosisNodeData } from "./types"; -import { getNodeName } from "./utils"; -import { useVariableContext } from "./VariableContextProvider"; - -const colors: Record = { - service: "blue.900", - artifact: "yellow.900", - shell: "red.900", - python: "red.900", -}; - -export const nodeIcons: Record = { - service: FiCpu, - artifact: FiFile, - shell: FiTerminal, - python: FiTerminal, -}; - -const nodeTypeReadable: Record = { - service: "Service", - artifact: "Files", - shell: "Shell execution task", - python: "Python execution task", -}; - -type KurtosisNodeProps = PropsWithChildren<{ - id: string; - selected: boolean; - minWidth: number; - maxWidth: number; -}>; - -export const KurtosisNode = memo( - ({ id, selected, minWidth, maxWidth, children }: KurtosisNodeProps) => { - const { data } = useVariableContext(); - const nodeData = data[id] as DataType; - - if (!isDefined(nodeData)) { - return null; - } - - return ( - - id={id} - selected={selected} - minWidth={minWidth} - maxWidth={maxWidth} - nodeData={nodeData} - > - {children} - - ); - }, -); - -type KurtosisNodeImplProps = KurtosisNodeProps & { nodeData: DataType }; -const KurtosisNodeImpl = ({ - id, - nodeData, - selected, - minWidth, - maxWidth, - children, -}: KurtosisNodeImplProps) => { - const { updateData, removeData } = useVariableContext(); - const color = colors[nodeData.type]; - const chakraColor = useToken("colors", color); - const name = useMemo(() => getNodeName(nodeData), [nodeData]); - const formMethods = useForm({ - defaultValues: nodeData as DefaultValues, - mode: "onBlur", - shouldFocusError: false, - }); - - const { deleteElements } = useReactFlow(); - - const handleDeleteNode = (e: React.MouseEvent) => { - e.stopPropagation(); - e.preventDefault(); - deleteElements({ nodes: [{ id }] }); - removeData(id); - }; - - const handleChange = useMemo( - () => - debounce(async () => { - const isValid = await formMethods.trigger(); - updateData(id, { ...formMethods.getValues(), isValid }); - }, 500), - [updateData, formMethods, id], - ); - - useEffect(() => { - const watcher = formMethods.watch(handleChange); - return () => watcher.unsubscribe(); - }, [formMethods, handleChange]); - - if (!isDefined(nodeData)) { - return null; - } - - return ( - - - - - - - - - {children} - - - - ); -}; - -type ZoomAwareNodeContentProps = PropsWithChildren<{ - name: string; - type: KurtosisNodeData["type"]; - onDelete: (e: React.MouseEvent) => void; -}>; - -const ZoomAwareNodeContent = ({ name, type, onDelete, children }: ZoomAwareNodeContentProps) => { - const viewport = useViewport(); - return ( - - {children} - - ); -}; - -type ZoomAwareNodeContentImplProps = ZoomAwareNodeContentProps & { zoom: number }; - -const ZoomAwareNodeContentImpl = memo(({ name, type, onDelete, zoom, children }: ZoomAwareNodeContentImplProps) => { - const { zoomOut, zoomIn } = useReactFlow(); - const handleScroll = useCallback( - (e: React.WheelEvent) => { - if (e.currentTarget.scrollTop === 0 && e.deltaY < 0) { - zoomIn(); - } - if ( - Math.abs(e.currentTarget.scrollHeight - e.currentTarget.clientHeight - e.currentTarget.scrollTop) <= 1 && - e.deltaY > 0 - ) { - zoomOut(); - } - }, - [zoomOut, zoomIn], - ); - - if (zoom < 0.4) { - return ( - - - - {name || Unnamed} - - - ); - } - - return ( - <> - - - - {name || Unnamed} - - {nodeTypeReadable[type]} - - - } - colorScheme={"red"} - variant={"ghost"} - size={"sm"} - onClick={onDelete} - /> - - - {children} - - - ); -}); diff --git a/enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/KurtosisServiceNode.tsx b/enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/KurtosisServiceNode.tsx deleted file mode 100644 index 9afed72e56..0000000000 --- a/enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/KurtosisServiceNode.tsx +++ /dev/null @@ -1,145 +0,0 @@ -import { Flex, Tab, TabList, TabPanel, TabPanels, Tabs } from "@chakra-ui/react"; -import { isDefined } from "kurtosis-ui-components"; -import { memo } from "react"; -import { NodeProps } from "reactflow"; -import { BooleanArgumentInput } from "../form/BooleanArgumentInput"; -import { DictArgumentInput } from "../form/DictArgumentInput"; -import { IntegerArgumentInput } from "../form/IntegerArgumentInput"; -import { KurtosisFormControl } from "../form/KurtosisFormControl"; -import { ListArgumentInput } from "../form/ListArgumentInput"; -import { StringArgumentInput } from "../form/StringArgumentInput"; -import { KurtosisFormInputProps } from "../form/types"; -import { ImageConfigInput } from "./input/ImageConfigInput"; -import { MentionStringArgumentInput } from "./input/MentionStringArgumentInput"; -import { MountArtifactFileInput } from "./input/MountArtifactFileInput"; -import { PortConfigurationField } from "./input/PortConfigurationInput"; -import { validateName } from "./input/validators"; -import { KurtosisNode } from "./KurtosisNode"; -import { KurtosisFileMount, KurtosisPort, KurtosisServiceNodeData } from "./types"; -import { useVariableContext } from "./VariableContextProvider"; - -export const KurtosisServiceNode = memo( - ({ id, selected }: NodeProps) => { - const { data } = useVariableContext(); - const nodeData = data[id] as KurtosisServiceNodeData; - - if (!isDefined(nodeData)) { - return null; - } - - return ( - - - name={"serviceName"} label={"Service Name"} isRequired> - - - name={"image.image"} label={"Container Image"} isRequired> - - - - - - Environment - Ports - Files - Exec - - - - - name={"env"} label={"Environment Variables"}> - - name={"env"} - KeyFieldComponent={StringArgumentInput} - ValueFieldComponent={MentionStringArgumentInput} - /> - - - - name={"ports"} label={"Ports"}> - ({ - portName: "", - applicationProtocol: "", - transportProtocol: "TCP", - port: 0, - })} - /> - - - - - name={"files"} - label={"Files"} - helperText={"Choose where to mount artifacts on this services filesystem"} - > - ({ - mountPoint: "", - artifactName: "", - })} - /> - - - - - - name={"execStepEnabled"} - label={"Exec step enabled"} - isRequired - helperText={"Whether kurtosis should execute a command in this service once the service is ready."} - > - name={"execStepEnabled"} /> - - - name={"execStepCommand"} - label={"Command"} - isRequired={nodeData.execStepEnabled === "true"} - isDisabled={nodeData.execStepEnabled === "false"} - > - - - - name={"execStepAcceptableCodes"} - label={"Acceptable Exit Codes"} - isDisabled={nodeData.execStepEnabled === "false"} - helperText={ - "If the executed command returns a code not on this list starlark will fail. Defaults to [0]" - } - > - - FieldComponent={AcceptableCodeInput} - size={"sm"} - name={"execStepAcceptableCodes"} - createNewValue={() => ({ value: 0 })} - disabled={nodeData.execStepEnabled === "false"} - /> - - - - - - - ); - }, - (oldProps, newProps) => oldProps.id === newProps.id && oldProps.selected === newProps.selected, -); - -const AcceptableCodeInput = (props: KurtosisFormInputProps) => { - return ( - - {...props} - size={"sm"} - name={`${props.name as `execStepAcceptableCodes.${number}`}.value`} - /> - ); -}; diff --git a/enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/Toolbar.tsx b/enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/Toolbar.tsx new file mode 100644 index 0000000000..1c23d8f97f --- /dev/null +++ b/enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/Toolbar.tsx @@ -0,0 +1,250 @@ +import { Box, Button, ButtonGroup, Icon } from "@chakra-ui/react"; +import Dagre from "@dagrejs/dagre"; +import { useCallback, useRef } from "react"; +import { FiShare2 } from "react-icons/fi"; +import { Edge, Node, useOnViewportChange, useReactFlow, XYPosition } from "reactflow"; +import { v4 as uuidv4 } from "uuid"; +import { nodeIcons } from "./nodes/KurtosisNode"; +import { useVariableContext } from "./VariableContextProvider"; + +const g = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({})); +const getLayoutedElements = (nodes: Node[], edges: Edge[]) => { + if (nodes.length === 0) { + return { nodes, edges }; + } + g.setGraph({ rankdir: "LR", ranksep: 100 }); + + edges.forEach((edge) => g.setEdge(edge.source, edge.target)); + nodes.forEach((node) => + g.setNode(node.id, node as Node<{ label: string }, string | undefined> & { width?: number; height?: number }), + ); + + Dagre.layout(g); + + return { + nodes: nodes.map((node) => { + const { x, y } = g.node(node.id); + + return { ...node, position: { x, y } }; + }), + edges, + }; +}; + +export const Toolbar = () => { + const insertOffset = useRef(0); + const { updateData } = useVariableContext(); + const { fitView, getViewport, getNodes, getEdges, addNodes, setNodes, setEdges } = useReactFlow(); + + useOnViewportChange({ onEnd: () => (insertOffset.current = 1) }); + + const onLayout = useCallback(() => { + const nodes = getNodes(); + const edges = getEdges(); + const layouted = getLayoutedElements(nodes, edges); + + setNodes([...layouted.nodes]); + setEdges([...layouted.edges]); + + window.requestAnimationFrame(() => { + fitView(); + }); + }, [fitView, setEdges, setNodes, getEdges, getNodes]); + + const getNewNodePosition = (): XYPosition => { + const viewport = getViewport(); + insertOffset.current += 1; + return { x: -viewport.x + insertOffset.current * 20 + 400, y: -viewport.y + insertOffset.current * 20 }; + }; + + const handleAddServiceNode = () => { + const id = uuidv4(); + updateData(id, { + type: "service", + name: "", + image: { + image: "", + type: "image", + buildContextDir: "", + flakeLocationDir: "", + flakeOutput: "", + registry: "", + registryPassword: "", + registryUsername: "", + targetStage: "", + }, + ports: [], + env: [], + files: [], + cmd: "", + entrypoint: "", + isValid: false, + }); + addNodes({ + id, + position: getNewNodePosition(), + width: 650, + style: { width: "650px" }, + type: "serviceNode", + data: {}, + }); + }; + + const handleAddExecNode = () => { + const id = uuidv4(); + updateData(id, { + type: "exec", + name: "", + service: "", + command: "", + acceptableCodes: [], + isValid: false, + }); + addNodes({ + id, + position: getNewNodePosition(), + width: 650, + style: { width: "650px" }, + type: "execNode", + data: {}, + }); + }; + + const handleAddArtifactNode = () => { + const id = uuidv4(); + updateData(id, { type: "artifact", name: "", files: {}, isValid: false }); + addNodes({ + id, + position: getNewNodePosition(), + width: 400, + style: { width: "400px" }, + type: "artifactNode", + data: {}, + }); + }; + + const handleAddShellNode = () => { + const id = uuidv4(); + updateData(id, { + type: "shell", + name: "", + command: "", + image: { + image: "", + type: "image", + buildContextDir: "", + flakeLocationDir: "", + flakeOutput: "", + registry: "", + registryPassword: "", + registryUsername: "", + targetStage: "", + }, + env: [], + files: [], + store: [{ name: "", path: "" }], + wait_enabled: "true", + wait: "", + isValid: false, + }); + addNodes({ + id, + position: getNewNodePosition(), + width: 650, + style: { width: "650px" }, + type: "shellNode", + data: {}, + }); + }; + + const handleAddPythonNode = () => { + const id = uuidv4(); + updateData(id, { + type: "python", + name: "", + command: "", + packages: [], + image: { + image: "", + type: "image", + buildContextDir: "", + flakeLocationDir: "", + flakeOutput: "", + registry: "", + registryPassword: "", + registryUsername: "", + targetStage: "", + }, + args: [], + files: [], + store: [{ name: "", path: "" }], + wait_enabled: "true", + wait: "", + isValid: false, + }); + addNodes({ + id, + position: getNewNodePosition(), + width: 650, + style: { width: "650px" }, + type: "pythonNode", + data: {}, + }); + }; + + const handleAddPackageNode = () => { + const id = uuidv4(); + updateData(id, { + type: "package", + name: "", + packageId: "", + args: {}, + locator: "", + isValid: false, + }); + addNodes({ + id, + position: getNewNodePosition(), + width: 900, + style: { width: "900px" }, + type: "packageNode", + data: {}, + }); + }; + + return ( + + + + + + + + + + + + ); +}; diff --git a/enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/VariableContextProvider.tsx b/enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/VariableContextProvider.tsx index f071f4f782..a696ed1526 100644 --- a/enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/VariableContextProvider.tsx +++ b/enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/VariableContextProvider.tsx @@ -5,8 +5,8 @@ import { getVariablesFromNodes } from "./utils"; type VariableContextState = { data: Record; variables: Variable[]; - updateData: (id: string, data: KurtosisNodeData) => void; - removeData: (id: string) => void; + updateData: (id: string, data: KurtosisNodeData | ((oldData: KurtosisNodeData) => KurtosisNodeData)) => void; + removeData: (id: { id: string }[]) => void; }; const VariableContext = createContext({ @@ -27,14 +27,19 @@ export const VariableContextProvider = ({ initialData, children }: PropsWithChil return getVariablesFromNodes(data); }, [data]); - const updateData = useCallback((id: string, data: KurtosisNodeData) => { - setData((oldData) => ({ ...oldData, [id]: data })); - }, []); + const updateData = useCallback( + (id: string, data: KurtosisNodeData | ((oldData: KurtosisNodeData) => KurtosisNodeData)) => { + setData((oldData) => ({ ...oldData, [id]: typeof data === "object" ? data : data(oldData[id]) })); + }, + [], + ); - const removeData = useCallback((id: string) => { + const removeData = useCallback((ids: { id: string }[]) => { setData((oldData) => { const r = { ...oldData }; - delete r[id]; + for (const { id } of ids) { + delete r[id]; + } return r; }); }, []); diff --git a/enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/Visualiser.tsx b/enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/Visualiser.tsx index 73c0767588..c255ba2585 100644 --- a/enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/Visualiser.tsx +++ b/enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/Visualiser.tsx @@ -1,8 +1,6 @@ -import { Box, Button, ButtonGroup, Flex, Icon } from "@chakra-ui/react"; -import Dagre from "@dagrejs/dagre"; +import { Box, Flex } from "@chakra-ui/react"; import { RemoveFunctions } from "kurtosis-ui-components"; import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef } from "react"; -import { FiShare2 } from "react-icons/fi"; import { Background, BackgroundVariant, @@ -13,48 +11,26 @@ import { useEdgesState, useNodesState, useReactFlow, - XYPosition, } from "reactflow"; -import { v4 as uuidv4 } from "uuid"; import { EnclaveFullInfo } from "../../types"; -import { KurtosisArtifactNode } from "./KurtosisArtifactNode"; -import { nodeIcons } from "./KurtosisNode"; -import { KurtosisPythonNode } from "./KurtosisPythonNode"; -import { KurtosisServiceNode } from "./KurtosisServiceNode"; -import { KurtosisShellNode } from "./KurtosisShellNode"; +import { KurtosisArtifactNode } from "./nodes/KurtosisArtifactNode"; +import { KurtosisExecNode } from "./nodes/KurtosisExecNode"; +import { KurtosisPackageNode } from "./nodes/KurtosisPackageNode"; +import { KurtosisPythonNode } from "./nodes/KurtosisPythonNode"; +import { KurtosisServiceNode } from "./nodes/KurtosisServiceNode"; +import { KurtosisShellNode } from "./nodes/KurtosisShellNode"; +import { Toolbar } from "./Toolbar"; import { generateStarlarkFromGraph, getNodeDependencies } from "./utils"; import { useVariableContext } from "./VariableContextProvider"; import "./Visualiser.css"; -const g = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({})); -const getLayoutedElements = (nodes: Node[], edges: Edge[]) => { - if (nodes.length === 0) { - return { nodes, edges }; - } - g.setGraph({ rankdir: "LR", ranksep: 100 }); - - edges.forEach((edge) => g.setEdge(edge.source, edge.target)); - nodes.forEach((node) => - g.setNode(node.id, node as Node<{ label: string }, string | undefined> & { width?: number; height?: number }), - ); - - Dagre.layout(g); - - return { - nodes: nodes.map((node) => { - const { x, y } = g.node(node.id); - - return { ...node, position: { x, y } }; - }), - edges, - }; -}; - const nodeTypes = { serviceNode: KurtosisServiceNode, + execNode: KurtosisExecNode, artifactNode: KurtosisArtifactNode, shellNode: KurtosisShellNode, pythonNode: KurtosisPythonNode, + packageNode: KurtosisPackageNode, }; export type VisualiserImperativeAttributes = { @@ -67,145 +43,12 @@ type VisualiserProps = { }; export const Visualiser = forwardRef( ({ initialNodes, initialEdges, existingEnclave }, ref) => { - const { data, updateData } = useVariableContext(); + const { data } = useVariableContext(); const insertOffset = useRef(0); - const { fitView, addNodes, getViewport } = useReactFlow(); - const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes || []); + const { fitView } = useReactFlow(); + const [nodes, , onNodesChange] = useNodesState(initialNodes || []); const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges || []); - const onLayout = useCallback(() => { - const layouted = getLayoutedElements(nodes, edges); - - setNodes([...layouted.nodes]); - setEdges([...layouted.edges]); - - window.requestAnimationFrame(() => { - fitView(); - }); - }, [nodes, edges, fitView, setEdges, setNodes]); - - const getNewNodePosition = (): XYPosition => { - const viewport = getViewport(); - insertOffset.current += 1; - return { x: -viewport.x + insertOffset.current * 20 + 400, y: -viewport.y + insertOffset.current * 20 }; - }; - - const handleAddServiceNode = () => { - const id = uuidv4(); - updateData(id, { - type: "service", - serviceName: "", - image: { - image: "", - type: "image", - buildContextDir: "", - flakeLocationDir: "", - flakeOutput: "", - registry: "", - registryPassword: "", - registryUsername: "", - targetStage: "", - }, - ports: [], - env: [], - files: [], - execStepEnabled: "false", - execStepCommand: "", - execStepAcceptableCodes: [], - isValid: false, - }); - addNodes({ - id, - position: getNewNodePosition(), - width: 650, - style: { width: "650px" }, - type: "serviceNode", - data: {}, - }); - }; - - const handleAddArtifactNode = () => { - const id = uuidv4(); - updateData(id, { type: "artifact", artifactName: "", files: {}, isValid: false }); - addNodes({ - id, - position: getNewNodePosition(), - width: 400, - style: { width: "400px" }, - type: "artifactNode", - data: {}, - }); - }; - - const handleAddShellNode = () => { - const id = uuidv4(); - updateData(id, { - type: "shell", - shellName: "", - command: "", - image: { - image: "", - type: "image", - buildContextDir: "", - flakeLocationDir: "", - flakeOutput: "", - registry: "", - registryPassword: "", - registryUsername: "", - targetStage: "", - }, - env: [], - files: [], - store: "", - wait_enabled: "true", - wait: "", - isValid: false, - }); - addNodes({ - id, - position: getNewNodePosition(), - width: 650, - style: { width: "650px" }, - type: "shellNode", - data: {}, - }); - }; - - const handleAddPythonNode = () => { - const id = uuidv4(); - updateData(id, { - type: "python", - pythonName: "", - command: "", - packages: [], - image: { - image: "", - type: "image", - buildContextDir: "", - flakeLocationDir: "", - flakeOutput: "", - registry: "", - registryPassword: "", - registryUsername: "", - targetStage: "", - }, - args: [], - files: [], - store: "", - wait_enabled: "true", - wait: "", - isValid: false, - }); - addNodes({ - id, - position: getNewNodePosition(), - width: 650, - style: { width: "650px" }, - type: "pythonNode", - data: {}, - }); - }; - const handleNodeDoubleClick = useCallback( (e: React.MouseEvent, node: Node) => { fitView({ nodes: [node], maxZoom: 1, duration: 500 }); @@ -261,33 +104,7 @@ export const Visualiser = forwardRef - - - - - - - - - + ({ defaultValue={"" as any} rules={{ required: isRequired, validate: validate }} render={({ field, fieldState }) => { - return ; + return ; }} /> ); @@ -30,10 +30,11 @@ export const FileTreeArgumentInput = ({ type FileTreeInputProps = { files: Record; + isDisabled?: boolean; onUpdateFiles: (newFiles: Record) => void; }; -const FileTreeInput = ({ files, onUpdateFiles }: FileTreeInputProps) => { +const FileTreeInput = ({ files, isDisabled, onUpdateFiles }: FileTreeInputProps) => { const [selectedPath, setSelectedPath] = useState(); const [showNewFileInputDialog, setShowNewFileInputDialog] = useState(false); const [editingFilePath, setEditingFilePath] = useState(); @@ -94,7 +95,7 @@ const FileTreeInput = ({ files, onUpdateFiles }: FileTreeInputProps) => { return ( - + @@ -119,7 +120,7 @@ const FileTreeInput = ({ files, onUpdateFiles }: FileTreeInputProps) => { onClose={() => setEditingFilePath(undefined)} filePath={editingFilePath || []} file={files["/" + editingFilePath?.join("/")]} - onSave={handleSaveEditedFile} + onSave={isDisabled ? undefined : handleSaveEditedFile} /> ); diff --git a/enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/input/ImageConfigInput.tsx b/enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/input/ImageConfigInput.tsx index e144f21e55..24e17682d5 100644 --- a/enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/input/ImageConfigInput.tsx +++ b/enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/input/ImageConfigInput.tsx @@ -34,7 +34,10 @@ const tabs: { display: string; value: KurtosisImageType }[] = [ { display: "Nix", value: "nix" }, ]; -export const ImageConfigInput = () => { +type ImageConfigInputProps = { + disabled?: boolean; +}; +export const ImageConfigInput = ({ disabled }: ImageConfigInputProps) => { const { setValue, watch } = useFormContext<{ image: KurtosisImageConfig }>(); const imageName = watch("image.image"); const imageType = watch("image.type"); @@ -42,17 +45,20 @@ export const ImageConfigInput = () => { const handleTabsChange = (newTabIndex: number) => { setActiveTabIndex(newTabIndex); - setValue("image.type", tabs[activeTabIndex].value); + if (!disabled) { + setValue("image.type", tabs[activeTabIndex].value); + } }; return ( - {tabs[activeTabIndex].display} + {tabs.find((t) => t.value === imageType)?.display || "Unknown"} @@ -87,28 +93,37 @@ export const ImageConfigInput = () => { size={"xs"} name={"image.username"} label={"Username"} + isDisabled={disabled} helperText={"The username that will be used to pull the image from the given registry"} > - + - + @@ -120,21 +135,24 @@ export const ImageConfigInput = () => { helperText={ "Locator to build context within the Kurtosis package. As of now, Kurtosis expects a Dockerfile at the root of the build context" } + isDisabled={disabled} isRequired={activeTabIndex === 1} > - + @@ -143,12 +161,14 @@ export const ImageConfigInput = () => { name={"image.buildContextDir"} label={"Build Context Dir"} helperText={"Locator to build context within the Kurtosis package."} + isDisabled={disabled} isRequired={activeTabIndex === 2} > @@ -159,12 +179,14 @@ export const ImageConfigInput = () => { helperText={ "The relative path (from the `build_context_dir`) to the folder containing the flake.nix file" } + isDisabled={disabled} isRequired={activeTabIndex === 2} > @@ -172,11 +194,12 @@ export const ImageConfigInput = () => { name={"image.flakeOutput"} label={"Flake Output"} size={"xs"} + isDisabled={disabled} helperText={ "The selector for the Flake output with the image derivation. Fallbacks to the default package." } > - + diff --git a/enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/input/MentionStringArgumentInput.css b/enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/input/MentionStringArgumentInput.css index 65a156a440..d2bcb7735a 100644 --- a/enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/input/MentionStringArgumentInput.css +++ b/enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/input/MentionStringArgumentInput.css @@ -3,7 +3,7 @@ height: var(--input-height); font-size: var(--input-font-size); border: 1px solid; - border-color: inherit; + border-color: var(--chakra-colors-chakra-border-color); border-radius: var(--input-border-radius); outline: 2px solid transparent; outline-offset: 2px; @@ -14,8 +14,9 @@ --input-border-radius: var(--chakra-radii-sm); --input-height: var(--chakra-sizes-8); padding-top: 4px; + background-color: var(--chakra-colors-gray-850); - &[aria-invalid="true"] { + &:has(input[aria-invalid="true"]) { border-color: var(--chakra-colors-red-300); box-shadow: 0 0 0 1px var(--chakra-colors-red-300); } @@ -40,6 +41,10 @@ &:focus-visible { outline: none; } + + &[disabled=""] { + color: var(--chakra-colors-gray-200); + } } .mentions__suggestions__list { diff --git a/enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/input/MentionStringArgumentInput.tsx b/enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/input/MentionStringArgumentInput.tsx index f639361482..b90a5f55c2 100644 --- a/enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/input/MentionStringArgumentInput.tsx +++ b/enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/input/MentionStringArgumentInput.tsx @@ -7,7 +7,9 @@ import { KurtosisFormInputProps } from "../../form/types"; import { useVariableContext } from "../VariableContextProvider"; import "./MentionStringArgumentInput.css"; -type MentionStringArgumentInputProps = KurtosisFormInputProps; +type MentionStringArgumentInputProps = KurtosisFormInputProps & { + multiline?: boolean; +}; export const MentionStringArgumentInput = ({ name, @@ -17,6 +19,7 @@ export const MentionStringArgumentInput = ({ disabled, width, tabIndex, + multiline, }: MentionStringArgumentInputProps) => { const { variables, data } = useVariableContext(); const nodeId = useNodeId(); @@ -59,10 +62,15 @@ export const MentionStringArgumentInput = ({ "&singleLine": { width: width, }, + "&multiLine": { + minHeight: "90px", + overflow: "scroll", + }, + maxWidth: "600px", }} aria-invalid={fieldState.invalid} tabIndex={tabIndex} - singleLine + singleLine={!multiline} value={field.value} disabled={disabled} onChange={(e, newValue, newPlainTextValue, mentions) => field.onChange(newValue)} diff --git a/enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/input/MountArtifactFileInput.tsx b/enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/input/MountArtifactFileInput.tsx index ca12376444..aca0e4d217 100644 --- a/enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/input/MountArtifactFileInput.tsx +++ b/enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/input/MountArtifactFileInput.tsx @@ -10,7 +10,7 @@ export const MountArtifactFileInput = (props: KurtosisFormInputProps { return variables - .filter((variable) => variable.id.match(/^(?:artifact|shell|python)\.[^.]+$/)) + .filter((variable) => variable.id.match(/^(?:artifact|shell|python)\.[^.]+.store/)) .map((variable) => ({ display: variable.displayName, value: `{{${variable.id}}}` })); }, [variables]); @@ -30,7 +30,7 @@ export const MountArtifactFileInput = (props: KurtosisFormInputProps diff --git a/enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/input/PortConfigurationInput.tsx b/enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/input/PortConfigurationInput.tsx index f3e84c7b4e..f6bcd2f51b 100644 --- a/enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/input/PortConfigurationInput.tsx +++ b/enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/input/PortConfigurationInput.tsx @@ -12,7 +12,7 @@ export const PortConfigurationField = (props: KurtosisFormInputProps @@ -25,6 +25,7 @@ export const PortConfigurationField = (props: KurtosisFormInputProps {...props} + isRequired={false} size={"sm"} placeholder={"Application Protocol (eg postgresql)"} name={`${props.name as `ports.${number}`}.applicationProtocol`} diff --git a/enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/input/SelectServiceInput.tsx b/enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/input/SelectServiceInput.tsx new file mode 100644 index 0000000000..125fc81d27 --- /dev/null +++ b/enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/input/SelectServiceInput.tsx @@ -0,0 +1,24 @@ +import { useMemo } from "react"; +import { SelectArgumentInput, SelectOption } from "../../form/SelectArgumentInput"; +import { KurtosisFormInputProps } from "../../form/types"; +import { KurtosisExecNodeData } from "../types"; +import { useVariableContext } from "../VariableContextProvider"; + +export const SelectServiceInput = (props: KurtosisFormInputProps) => { + const { variables } = useVariableContext(); + const serviceVariableOptions = useMemo((): SelectOption[] => { + return variables + .filter((variable) => variable.id.match(/^(?:service)\.[^.]+\.name$/)) + .map((variable) => ({ display: variable.displayName, value: `{{${variable.id}}}` })); + }, [variables]); + + return ( + + options={serviceVariableOptions} + {...props} + size={"sm"} + placeholder={"Select a Service"} + name={props.name} + /> + ); +}; diff --git a/enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/input/StoreConfigurationInput.tsx b/enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/input/StoreConfigurationInput.tsx new file mode 100644 index 0000000000..da315c853e --- /dev/null +++ b/enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/input/StoreConfigurationInput.tsx @@ -0,0 +1,29 @@ +import { Grid, GridItem } from "@chakra-ui/react"; +import { Path } from "react-hook-form"; +import { StringArgumentInput } from "../../form/StringArgumentInput"; +import { KurtosisFormInputProps } from "../../form/types"; +import { KurtosisStore } from "../types"; +import { MentionStringArgumentInput } from "./MentionStringArgumentInput"; + +export const StoreConfigurationInput = (props: KurtosisFormInputProps) => { + return ( + + + } + /> + + + + {...props} + size={"sm"} + placeholder={"/some/path"} + name={`${props.name as `store.${number}`}.path` as Path} + /> + + + ); +}; diff --git a/enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/modals/ConfigurePackageNodeModal.tsx b/enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/modals/ConfigurePackageNodeModal.tsx new file mode 100644 index 0000000000..bbcf70d56b --- /dev/null +++ b/enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/modals/ConfigurePackageNodeModal.tsx @@ -0,0 +1,95 @@ +import { + Button, + Flex, + Modal, + ModalBody, + ModalCloseButton, + ModalContent, + ModalFooter, + ModalHeader, + ModalOverlay, + Text, +} from "@chakra-ui/react"; +import { KurtosisPackage } from "kurtosis-cloud-indexer-sdk"; +import { isDefined, PackageLogo, readablePackageName } from "kurtosis-ui-components"; +import { useEffect, useState } from "react"; +import { FormProvider, useForm, useFormContext } from "react-hook-form"; +import { CatalogContextProvider } from "../../../../catalog/CatalogContext"; +import { KurtosisPackageArgumentInput } from "../../configuration/KurtosisPackageArgumentInput"; +import { PackageSelector } from "../../configuration/PackageSelector"; +import { transformFormArgsToKurtosisArgs, transformKurtosisArgsToFormArgs } from "../../configuration/utils"; +import { KurtosisPackageNodeData } from "../types"; + +type ConfigurePackageNodeModalProps = { + isOpen: boolean; + initialValues: Record; + onClose: () => void; +}; +export const ConfigurePackageNodeModal = ({ isOpen, onClose, initialValues }: ConfigurePackageNodeModalProps) => { + const [kurtosisPackage, setKurtosisPackage] = useState(); + const parentFormMethods = useFormContext(); + const formMethods = useForm>(); + + const onValidSubmit = (data: Record) => { + if (isDefined(kurtosisPackage)) { + parentFormMethods.setValue("args", transformFormArgsToKurtosisArgs(data.args, kurtosisPackage)); + parentFormMethods.setValue("packageId", kurtosisPackage.name); + parentFormMethods.setValue("locator", kurtosisPackage.locator); + onClose(); + } + }; + + useEffect(() => { + if (isDefined(kurtosisPackage)) { + formMethods.setValue("args", transformKurtosisArgsToFormArgs(initialValues, kurtosisPackage)); + } + }, [kurtosisPackage, initialValues, formMethods]); + + return ( + + + + + + {isDefined(kurtosisPackage) ? ( + + + {readablePackageName(kurtosisPackage.name)} + + ) : ( + "Choose a package" + )} + + + + + {!isDefined(kurtosisPackage) && } + {isDefined(kurtosisPackage) && ( + + {kurtosisPackage.args.map((arg, i) => ( + + ))} + + )} + + + + + + + + + + + + ); +}; diff --git a/enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/modals/EditFileModal.tsx b/enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/modals/EditFileModal.tsx index 65361a7928..8945843d79 100644 --- a/enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/modals/EditFileModal.tsx +++ b/enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/modals/EditFileModal.tsx @@ -9,7 +9,7 @@ import { ModalHeader, ModalOverlay, } from "@chakra-ui/react"; -import { CodeEditor, CodeEditorImperativeAttributes } from "kurtosis-ui-components"; +import { CodeEditor, CodeEditorImperativeAttributes, isDefined } from "kurtosis-ui-components"; import { useMemo, useRef } from "react"; type EditFileModalProps = { @@ -17,7 +17,7 @@ type EditFileModalProps = { onClose: () => void; filePath: string[]; file: string; - onSave: (newContents: string) => void; + onSave?: (newContents: string) => void; }; export const EditFileModal = ({ isOpen, onClose, filePath, file, onSave }: EditFileModalProps) => { const codeEditorRef = useRef(null); @@ -33,20 +33,22 @@ export const EditFileModal = ({ isOpen, onClose, filePath, file, onSave }: EditF - + - + {isDefined(onSave) && ( + + )} diff --git a/enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/nodes/KurtosisArtifactNode.tsx b/enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/nodes/KurtosisArtifactNode.tsx new file mode 100644 index 0000000000..e204cbc0d8 --- /dev/null +++ b/enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/nodes/KurtosisArtifactNode.tsx @@ -0,0 +1,44 @@ +import { isDefined } from "kurtosis-ui-components"; +import { memo } from "react"; +import { NodeProps } from "reactflow"; +import { KurtosisFormControl } from "../../form/KurtosisFormControl"; +import { StringArgumentInput } from "../../form/StringArgumentInput"; +import { FileTreeArgumentInput } from "../input/FileTreeArgumentInput"; +import { validateName } from "../input/validators"; +import { KurtosisArtifactNodeData, KurtosisPythonNodeData } from "../types"; +import { useVariableContext } from "../VariableContextProvider"; +import { KurtosisNode } from "./KurtosisNode"; + +export const KurtosisArtifactNode = memo( + ({ id, selected }: NodeProps) => { + const { data } = useVariableContext(); + const nodeData = data[id] as KurtosisPythonNodeData; + + if (!isDefined(nodeData)) { + return null; + } + + return ( + + + name={"name"} + label={"Artifact Name"} + isRequired + isDisabled={nodeData.isFromPackage} + > + + + + + + + ); + }, + (oldProps, newProps) => oldProps.id === newProps.id && oldProps.selected === newProps.selected, +); diff --git a/enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/nodes/KurtosisExecNode.tsx b/enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/nodes/KurtosisExecNode.tsx new file mode 100644 index 0000000000..33a60257dd --- /dev/null +++ b/enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/nodes/KurtosisExecNode.tsx @@ -0,0 +1,100 @@ +import { Flex } from "@chakra-ui/react"; +import { isDefined } from "kurtosis-ui-components"; +import { memo, useEffect } from "react"; +import { useFormContext } from "react-hook-form"; +import { NodeProps } from "reactflow"; +import { IntegerArgumentInput } from "../../form/IntegerArgumentInput"; +import { KurtosisFormControl } from "../../form/KurtosisFormControl"; +import { ListArgumentInput } from "../../form/ListArgumentInput"; +import { KurtosisFormInputProps } from "../../form/types"; +import { MentionStringArgumentInput } from "../input/MentionStringArgumentInput"; +import { SelectServiceInput } from "../input/SelectServiceInput"; +import { KurtosisExecNodeData } from "../types"; +import { useVariableContext } from "../VariableContextProvider"; +import { KurtosisNode } from "./KurtosisNode"; + +export const KurtosisExecNode = memo( + ({ id, selected }: NodeProps) => { + const { data } = useVariableContext(); + const nodeData = data[id] as KurtosisExecNodeData; + + if (!isDefined(nodeData)) { + return null; + } + + return ( + + + + + name={"service"} + label={"Service"} + isRequired + isDisabled={nodeData.isFromPackage} + > + + + + + + name={"command"} + label={"Command"} + isRequired + isDisabled={nodeData.isFromPackage} + > + + + + name={"acceptableCodes"} + label={"Acceptable Exit Codes"} + isDisabled={nodeData.isFromPackage} + helperText={"If the executed command returns a code not on this list starlark will fail. Defaults to [0]"} + > + + FieldComponent={AcceptableCodeInput} + size={"sm"} + name={"acceptableCodes"} + createNewValue={() => ({ value: 0 })} + disabled={nodeData.isFromPackage} + /> + + + + ); + }, + (oldProps, newProps) => oldProps.id === newProps.id && oldProps.selected === newProps.selected, +); + +const AcceptableCodeInput = (props: KurtosisFormInputProps) => { + return ( + + {...props} + size={"sm"} + name={`${props.name as `acceptableCodes.${number}`}.value`} + /> + ); +}; + +const ExecNameUpdater = memo(() => { + const { variables } = useVariableContext(); + const { watch, setValue } = useFormContext(); + + const service = watch("service"); + const name = watch("name"); + + useEffect(() => { + const serviceVariableId = service.replace(/\{\{(.*)}}/, "$1"); + const serviceName = variables.find((v) => v.id === serviceVariableId)?.displayName || "Unknown"; + if (name !== `${serviceName} exec`) { + setValue("name", `${serviceName} exec`); + } + }, [name, service, setValue, variables]); + + return null; +}); diff --git a/enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/nodes/KurtosisNode.tsx b/enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/nodes/KurtosisNode.tsx new file mode 100644 index 0000000000..7c31b8d599 --- /dev/null +++ b/enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/nodes/KurtosisNode.tsx @@ -0,0 +1,333 @@ +import { Box, Flex, Icon, IconButton, Text, useToken } from "@chakra-ui/react"; +import { isDefined } from "kurtosis-ui-components"; +import { debounce } from "lodash"; +import { FC, memo, PropsWithChildren, ReactElement, useCallback, useEffect, useMemo } from "react"; +import { DefaultValues, FormProvider, useForm } from "react-hook-form"; +import { FiCpu, FiFile, FiPackage, FiTerminal, FiTrash } from "react-icons/fi"; +import { RxCornerBottomRight } from "react-icons/rx"; +import { Handle, NodeResizeControl, Position, useReactFlow, useViewport } from "reactflow"; +import { KurtosisNodeData } from "../types"; +import { useVariableContext } from "../VariableContextProvider"; + +const colors: Record = { + service: "blue.900", + artifact: "yellow.900", + shell: "red.900", + python: "red.900", + exec: "red.900", + package: "kurtosisGreen.700", +}; + +export const nodeIcons: Record = { + service: FiCpu, + artifact: FiFile, + shell: FiTerminal, + python: FiTerminal, + exec: FiTerminal, + package: FiPackage, +}; + +const nodeTypeReadable: Record = { + service: "Service", + artifact: "Files", + exec: "Service execution task", + shell: "Shell execution task", + python: "Python execution task", + package: "Package", +}; + +type KurtosisNodeProps = PropsWithChildren<{ + id: string; + selected: boolean; + minWidth: number; + maxWidth: number; + // Optional element to show outside of the zoom aware behaviour + portalContent?: ReactElement; + backgroundColor?: string; +}>; + +export const KurtosisNode = memo( + ({ + id, + selected, + minWidth, + maxWidth, + portalContent, + backgroundColor, + children, + }: KurtosisNodeProps) => { + const { data } = useVariableContext(); + const nodeData = data[id] as DataType; + + if (!isDefined(nodeData)) { + return null; + } + + return ( + + id={id} + selected={selected} + minWidth={minWidth} + maxWidth={maxWidth} + nodeData={nodeData} + portalContent={portalContent} + backgroundColor={backgroundColor} + > + {children} + + ); + }, +); + +type KurtosisNodeImplProps = KurtosisNodeProps & { nodeData: DataType }; +const KurtosisNodeImpl = ({ + id, + nodeData, + selected, + minWidth, + maxWidth, + portalContent, + backgroundColor, + children, +}: KurtosisNodeImplProps) => { + if (!selected) { + return ( + <> + {" "} + + + + + ); + } + + return ( + + {children} + + ); +}; + +const KurtosisFormNode = ({ + id, + nodeData, + selected, + minWidth, + maxWidth, + portalContent, + backgroundColor, + children, +}: KurtosisNodeImplProps) => { + const { updateData, removeData } = useVariableContext(); + const { getNodes } = useReactFlow(); + const color = colors[nodeData.type]; + const chakraColor = useToken("colors", color); + const formMethods = useForm({ + defaultValues: nodeData as DefaultValues, + mode: "onBlur", + shouldFocusError: false, + }); + + const { deleteElements } = useReactFlow(); + + const handleDeleteNode = (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + const nodesToRemove = [ + { id }, + ...getNodes() + .filter((n) => n.parentNode === id) + .map((n) => ({ id: n.id })), + ]; + deleteElements({ nodes: nodesToRemove }); + removeData(nodesToRemove); + }; + + const handleChange = useMemo( + () => + debounce(async () => { + const isValid = await formMethods.trigger(); + updateData(id, (oldData) => ({ ...oldData, ...formMethods.getValues(), isValid })); + }, 500), + [updateData, formMethods, id], + ); + + useEffect(() => { + const watcher = formMethods.watch(handleChange); + return () => watcher.unsubscribe(); + }, [formMethods, handleChange]); + + if (!isDefined(nodeData)) { + return null; + } + + return ( + + + + + + + + + + {children} + + {isDefined(portalContent) && portalContent} + + + + ); +}; + +type ZoomAwareNodeContentProps = PropsWithChildren<{ + name: string; + type: KurtosisNodeData["type"]; + isDisabled?: boolean; + onDelete: (e: React.MouseEvent) => void; +}>; + +const ZoomAwareNodeContent = ({ name, type, isDisabled, onDelete, children }: ZoomAwareNodeContentProps) => { + const viewport = useViewport(); + return ( + + {children} + + ); +}; + +type ZoomAwareNodeContentImplProps = ZoomAwareNodeContentProps & { zoom: number }; + +const ZoomAwareNodeContentImpl = memo( + ({ name, type, isDisabled, onDelete, zoom, children }: ZoomAwareNodeContentImplProps) => { + const { zoomOut, zoomIn } = useReactFlow(); + const handleScroll = useCallback( + (e: React.WheelEvent) => { + if (e.currentTarget.scrollTop === 0 && e.deltaY < 0) { + zoomIn(); + } + if ( + Math.abs(e.currentTarget.scrollHeight - e.currentTarget.clientHeight - e.currentTarget.scrollTop) <= 1 && + e.deltaY > 0 + ) { + zoomOut(); + } + }, + [zoomOut, zoomIn], + ); + + if (zoom < 0.4) { + return ; + } + + return ( + <> + + + + {name || Unnamed} + + {nodeTypeReadable[type]} + + + } + colorScheme={"red"} + variant={"ghost"} + size={"sm"} + onClick={onDelete} + isDisabled={isDisabled} + /> + + + {children} + + + + ); + }, +); + +type BasicKurtosisNodeProps = { + type: KurtosisNodeData["type"]; + name?: string; +}; +const BasicKurtosisNode = ({ type, name }: BasicKurtosisNodeProps) => { + return ( + + + + {name || Unnamed} + + + ); +}; diff --git a/enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/nodes/KurtosisPackageNode.tsx b/enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/nodes/KurtosisPackageNode.tsx new file mode 100644 index 0000000000..6d8ad5106c --- /dev/null +++ b/enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/nodes/KurtosisPackageNode.tsx @@ -0,0 +1,341 @@ +import { Button, Flex } from "@chakra-ui/react"; +import { isDefined } from "kurtosis-ui-components"; +import { memo, useEffect, useState } from "react"; +import { FiEdit } from "react-icons/fi"; +import { NodeProps, useReactFlow } from "reactflow"; +import YAML from "yaml"; +import { useKurtosisClient } from "../../../../../client/enclaveManager/KurtosisClientContext"; +import { KurtosisFormControl } from "../../form/KurtosisFormControl"; +import { StringArgumentInput } from "../../form/StringArgumentInput"; +import { validateName } from "../input/validators"; +import { ConfigurePackageNodeModal } from "../modals/ConfigurePackageNodeModal"; +import { KurtosisPackageNodeData, PlanFileArtifact, PlanTask, PlanYaml } from "../types"; +import { useVariableContext } from "../VariableContextProvider"; +import { KurtosisNode } from "./KurtosisNode"; + +type Mode = { type: "loading" } | { type: "error"; error: string } | { type: "ready" }; + +export const KurtosisPackageNode = memo( + ({ id, selected }: NodeProps) => { + const { getNodes, deleteElements, setNodes } = useReactFlow(); + const [showPackageConfigModal, setShowPackageConfigModal] = useState(false); + const [mode, setMode] = useState({ type: "ready" }); + const kurtosisClient = useKurtosisClient(); + const { data, updateData, removeData } = useVariableContext(); + const nodeData = data[id] as KurtosisPackageNodeData | undefined; + + useEffect(() => { + const packageId = nodeData?.packageId; + const args = nodeData?.args; + if (isDefined(packageId) && isDefined(args) && packageId !== "") { + let cancelled = false; + (async () => { + setMode({ type: "loading" }); + const enclave = await kurtosisClient.createEnclave("", "info"); + if (enclave.isErr) { + setMode({ type: "error", error: enclave.error }); + return; + } + if (!isDefined(enclave.value.enclaveInfo) || !isDefined(enclave.value.enclaveInfo.apiContainerInfo)) { + setMode({ type: "error", error: "APIC info missing from temporary enclave" }); + return; + } + const plan = await kurtosisClient.getStarlarkPackagePlanYaml( + enclave.value.enclaveInfo.apiContainerInfo, + packageId, + args, + ); + await kurtosisClient.destroy(enclave.value.enclaveInfo?.enclaveUuid); + if (cancelled) { + return; + } + if (plan.isErr) { + setMode({ type: "error", error: plan.error }); + return; + } + console.log(plan.value.planYaml); + const parsedPlan = YAML.parse(plan.value.planYaml) as PlanYaml; + + // Remove current children + const nodesToRemove = getNodes().filter((node) => node.parentNode === id); + deleteElements({ nodes: nodesToRemove }); + removeData(nodesToRemove); + + const serviceNamesToId = (parsedPlan.services || []).reduce( + (acc: Record, service) => ({ ...acc, [service.name]: `${id}:${service.uuid}` }), + {}, + ); + const taskLookup = (parsedPlan.tasks || []).reduce( + (acc: Record, task) => ({ ...acc, [task.uuid]: task }), + {}, + ); + const artifactLookup = (parsedPlan.filesArtifacts || []).reduce( + (acc: Record, filesArtifact) => ({ ...acc, [filesArtifact.uuid]: filesArtifact }), + {}, + ); + + const plannedArtifacts = (parsedPlan.filesArtifacts || []).filter( + (artifact) => + !(parsedPlan.tasks || []).some( + (task) => task.taskType !== "exec" && (task.store || []).some((store) => store.uuid === artifact.uuid), + ), + ); + + const artifactToNodeId = (parsedPlan.filesArtifacts || []).reduce( + (acc: Record, artifact) => ({ + ...acc, + [artifact.uuid]: + parsedPlan.tasks?.find( + (task) => task.taskType !== "exec" && task.store?.some((store) => store.uuid === artifact.uuid), + )?.uuid || artifact.uuid, + }), + {}, + ); + + const artifactTypes = (parsedPlan.filesArtifacts || []).reduce( + (acc: Record, artifact) => ({ + ...acc, + [artifact.uuid]: + taskLookup[artifactToNodeId[artifact.uuid]]?.taskType === "sh" + ? "shell" + : taskLookup[artifactToNodeId[artifact.uuid]]?.taskType === "python" + ? "python" + : "artifact", + }), + {}, + ); + + const nodesToAdd: { type: string; id: string }[] = [ + ...(parsedPlan.services || []).map((service, i) => ({ + type: "serviceNode", + id: `${id}:${service.uuid}`, + })), + ...(parsedPlan.tasks || []).map((task, i) => ({ + type: task.taskType === "exec" ? "execNode" : task.taskType === "python" ? "pythonNode" : "shellNode", + id: `${id}:${task.uuid}`, + })), + ...plannedArtifacts.map((artifact, i) => ({ + type: "artifactNode", + id: `${id}:${artifact.uuid}`, + })), + ]; + + const futureReferencePattern = /\{\{\s*kurtosis\.([^.]+)\.(\S+?)\s*}}/; + const convertFutureReferences = (input: string): string => { + // All future references are assumed to be for services + let result = input; + let match = result.match(futureReferencePattern); + while (isDefined(match)) { + result = result.replaceAll(match[0], `{{service.${id}:${match[1]}.${match[2]}}}`); + match = result.match(futureReferencePattern); + } + return result; + }; + + (parsedPlan.services || []).forEach((service) => + updateData(`${id}:${service.uuid}`, { + type: "service", + name: service.name, + isFromPackage: true, + env: (service.envVars || []).map(({ key, value }) => ({ + key: convertFutureReferences(key), + value: convertFutureReferences(value), + })), + image: { + type: "image", + image: service.image.name, + registryUsername: "", + registryPassword: "", + registry: "", + buildContextDir: "", + targetStage: "", + flakeLocationDir: "", + flakeOutput: "", + }, + ports: (service.ports || []).map((port) => ({ + name: port.name, + port: port.number, + applicationProtocol: port.applicationProtocol || "", + transportProtocol: port.transportProtocol, + })), + isValid: true, + files: (service.files || []).flatMap((file) => + file.filesArtifacts.map((artifact) => ({ + name: `{{${artifactTypes[artifact.uuid]}.${id}:${artifactToNodeId[artifact.uuid]}.store.${ + artifact.name + }}}`, + mountPoint: file.mountPath, + })), + ), + cmd: convertFutureReferences((service.command || []).join(" ")), + entrypoint: convertFutureReferences((service.entrypoint || []).join(" ")), + }), + ); + (parsedPlan.tasks || []).forEach((task) => { + if (task.taskType === "exec") { + const serviceVariable = `{{service.${serviceNamesToId[task.serviceName]}.name}}`; + updateData(`${id}:${task.uuid}`, { + type: "exec", + name: "", + isValid: true, + isFromPackage: true, + service: serviceVariable, + command: (task.command || []).join(" "), + acceptableCodes: (task.acceptableCodes || []).map((code) => ({ value: code })), + }); + } + if (task.taskType === "python") { + updateData(`${id}:${task.uuid}`, { + type: "python", + name: `Python ${task.uuid}`, + isValid: true, + isFromPackage: true, + command: (task.command || []).join(" "), + image: { + type: "image", + image: task.image, + registryUsername: "", + registryPassword: "", + registry: "", + buildContextDir: "", + targetStage: "", + flakeLocationDir: "", + flakeOutput: "", + }, + packages: [], + args: task.pythonArgs.map((arg) => ({ arg })), + files: (task.files || []).flatMap((file) => + file.filesArtifacts.map((artifact) => ({ + name: `{{${artifactTypes[artifact.uuid]}.${id}:${artifactToNodeId[artifact.uuid]}.store.${ + artifact.name + }}}`, + mountPoint: file.mountPath, + })), + ), + store: (task.store || []).map((store) => ({ + name: store.name, + path: artifactLookup[store.uuid].files[0], + })), + wait_enabled: "false", + wait: "", + }); + } + if (task.taskType === "sh") { + updateData(`${id}:${task.uuid}`, { + type: "shell", + name: `Shell ${task.uuid}`, + isValid: true, + isFromPackage: true, + command: (task.command || []).join(" "), + image: { + type: "image", + image: task.image, + registryUsername: "", + registryPassword: "", + registry: "", + buildContextDir: "", + targetStage: "", + flakeLocationDir: "", + flakeOutput: "", + }, + env: [], + files: (task.files || []).flatMap((file) => + file.filesArtifacts.map((artifact) => ({ + name: `{{${artifactTypes[artifact.uuid]}.${id}:${artifactToNodeId[artifact.uuid]}.store.${ + artifact.name + }}}`, + mountPoint: file.mountPath, + })), + ), + store: (task.store || []).map((store) => ({ + name: store.name, + path: artifactLookup[store.uuid].files[0], + })), + wait_enabled: "false", + wait: "", + }); + } + }); + plannedArtifacts.forEach((artifact) => + updateData(`${id}:${artifact.uuid}`, { + type: "artifact", + name: artifact.name, + isFromPackage: true, + isValid: true, + files: artifact.files.reduce((acc, file) => ({ ...acc, [file]: "" }), {}), + }), + ); + + setNodes((nodes) => [ + ...nodes, + ...nodesToAdd.map((node, i) => ({ + ...node, + parentNode: id, + data: {}, + position: { x: 50 + 700 * (i % 3), y: 200 + 700 * Math.floor(i / 3) }, + })), + ]); + + setMode({ type: "ready" }); + })(); + return () => { + cancelled = true; + }; + } + }, [ + nodeData?.packageId, + nodeData?.args, + deleteElements, + getNodes, + id, + kurtosisClient, + removeData, + setNodes, + updateData, + ]); + + if (!isDefined(nodeData)) { + return null; + } + + return ( + setShowPackageConfigModal(false)} + initialValues={nodeData.args} + /> + } + backgroundColor={"transparent"} + > + + name={"name"} label={"Node Name"} isRequired flex={"1"}> + + + + name={"packageId"} + label={`Package ${nodeData.packageId}`} + isRequired + flex={"1"} + > + + + + + ); + }, + (oldProps, newProps) => oldProps.id === newProps.id && oldProps.selected === newProps.selected, +); diff --git a/enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/KurtosisPythonNode.tsx b/enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/nodes/KurtosisPythonNode.tsx similarity index 63% rename from enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/KurtosisPythonNode.tsx rename to enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/nodes/KurtosisPythonNode.tsx index 9dad24819e..a5c2bdd139 100644 --- a/enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/KurtosisPythonNode.tsx +++ b/enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/nodes/KurtosisPythonNode.tsx @@ -2,19 +2,20 @@ import { Flex, Tab, TabList, TabPanel, TabPanels, Tabs } from "@chakra-ui/react" import { isDefined } from "kurtosis-ui-components"; import { memo } from "react"; import { NodeProps } from "reactflow"; -import { BooleanArgumentInput } from "../form/BooleanArgumentInput"; -import { CodeEditorInput } from "../form/CodeEditorInput"; -import { KurtosisFormControl } from "../form/KurtosisFormControl"; -import { ListArgumentInput } from "../form/ListArgumentInput"; -import { StringArgumentInput } from "../form/StringArgumentInput"; -import { KurtosisFormInputProps } from "../form/types"; -import { ImageConfigInput } from "./input/ImageConfigInput"; -import { MentionStringArgumentInput } from "./input/MentionStringArgumentInput"; -import { MountArtifactFileInput } from "./input/MountArtifactFileInput"; -import { validateDurationString, validateName } from "./input/validators"; +import { BooleanArgumentInput } from "../../form/BooleanArgumentInput"; +import { CodeEditorInput } from "../../form/CodeEditorInput"; +import { KurtosisFormControl } from "../../form/KurtosisFormControl"; +import { ListArgumentInput } from "../../form/ListArgumentInput"; +import { StringArgumentInput } from "../../form/StringArgumentInput"; +import { KurtosisFormInputProps } from "../../form/types"; +import { ImageConfigInput } from "../input/ImageConfigInput"; +import { MentionStringArgumentInput } from "../input/MentionStringArgumentInput"; +import { MountArtifactFileInput } from "../input/MountArtifactFileInput"; +import { StoreConfigurationInput } from "../input/StoreConfigurationInput"; +import { validateDurationString, validateName } from "../input/validators"; +import { KurtosisFileMount, KurtosisPythonNodeData } from "../types"; +import { useVariableContext } from "../VariableContextProvider"; import { KurtosisNode } from "./KurtosisNode"; -import { KurtosisFileMount, KurtosisPythonNodeData } from "./types"; -import { useVariableContext } from "./VariableContextProvider"; export const KurtosisPythonNode = memo( ({ id, selected }: NodeProps) => { @@ -28,14 +29,29 @@ export const KurtosisPythonNode = memo( return ( - name={"pythonName"} label={"Python Name"} isRequired> - + + name={"name"} + label={"Python Name"} + isRequired + isDisabled={nodeData.isFromPackage} + > + - name={"image.image"} label={"Container Image"}> - + + name={"image.image"} + label={"Container Image"} + isDisabled={nodeData.isFromPackage} + > + - + Code Packages @@ -46,8 +62,13 @@ export const KurtosisPythonNode = memo( - name={"command"} label={"Code to run"} isRequired> - + + name={"command"} + label={"Code to run"} + isRequired + isDisabled={nodeData.isFromPackage} + > + @@ -55,6 +76,7 @@ export const KurtosisPythonNode = memo( name={"packages"} label={"Packages"} isRequired + isDisabled={nodeData.isFromPackage} helperText={"Names of packages that need to be installed prior to running this code"} > @@ -63,6 +85,7 @@ export const KurtosisPythonNode = memo( name={"packages"} size={"sm"} isRequired + disabled={nodeData.isFromPackage} validate={validateName} /> @@ -71,12 +94,14 @@ export const KurtosisPythonNode = memo( name={"args"} label={"Arguments"} + isDisabled={nodeData.isFromPackage} helperText={"Arguments to be passed to the Python script"} > name={"args"} FieldComponent={PythonArgInput} createNewValue={() => ({ arg: "" })} + disabled={nodeData.isFromPackage} isRequired /> @@ -85,27 +110,32 @@ export const KurtosisPythonNode = memo( name={"files"} label={"Input Files"} + isDisabled={nodeData.isFromPackage} helperText={"Choose where to mount artifacts on this execution tasks filesystem"} > ({ mountPoint: "", - artifactName: "", + name: "", })} /> name={"store"} label={"Output File/Directory"} + isDisabled={nodeData.isFromPackage} helperText={ "Choose which files to expose from this execution task. You can use either an absolute path, a directory, or a glob." } > - + ({ name: "", path: "" })} + minLength={1} /> @@ -115,19 +145,23 @@ export const KurtosisPythonNode = memo( name={"wait_enabled"} label={"Wait enabled"} isRequired + isDisabled={nodeData.isFromPackage} helperText={"Whether kurtosis should wait a preset time for this step to complete."} > - name={"wait_enabled"} /> + + name={"wait_enabled"} + disabled={nodeData.isFromPackage} + /> name={"wait"} label={"Wait"} - isDisabled={nodeData.wait_enabled === "false"} + isDisabled={nodeData.wait_enabled === "false" || nodeData.isFromPackage} helperText={"Whether kurtosis should wait a preset time for this step to complete."} > name={"wait"} - isDisabled={nodeData.wait_enabled === "false"} + disabled={nodeData.wait_enabled === "false" || nodeData.isFromPackage} size={"sm"} placeholder={"180s"} validate={nodeData.wait_enabled === "false" ? undefined : validateDurationString} diff --git a/enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/nodes/KurtosisServiceNode.tsx b/enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/nodes/KurtosisServiceNode.tsx new file mode 100644 index 0000000000..06f6912dcd --- /dev/null +++ b/enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/nodes/KurtosisServiceNode.tsx @@ -0,0 +1,142 @@ +import { Flex, Tab, TabList, TabPanel, TabPanels, Tabs } from "@chakra-ui/react"; +import { isDefined } from "kurtosis-ui-components"; +import { memo } from "react"; +import { NodeProps } from "reactflow"; +import { DictArgumentInput } from "../../form/DictArgumentInput"; +import { KurtosisFormControl } from "../../form/KurtosisFormControl"; +import { ListArgumentInput } from "../../form/ListArgumentInput"; +import { StringArgumentInput } from "../../form/StringArgumentInput"; +import { ImageConfigInput } from "../input/ImageConfigInput"; +import { MentionStringArgumentInput } from "../input/MentionStringArgumentInput"; +import { MountArtifactFileInput } from "../input/MountArtifactFileInput"; +import { PortConfigurationField } from "../input/PortConfigurationInput"; +import { validateName } from "../input/validators"; +import { KurtosisFileMount, KurtosisPort, KurtosisServiceNodeData } from "../types"; +import { useVariableContext } from "../VariableContextProvider"; +import { KurtosisNode } from "./KurtosisNode"; + +export const KurtosisServiceNode = memo( + ({ id, selected }: NodeProps) => { + const { data } = useVariableContext(); + const nodeData = data[id] as KurtosisServiceNodeData; + + if (!isDefined(nodeData)) { + return null; + } + + return ( + + + + name={"name"} + label={"Service Name"} + isRequired + isDisabled={nodeData.isFromPackage} + > + + + + name={"image.image"} + label={"Container Image"} + isRequired + isDisabled={nodeData.isFromPackage} + > + + + + + + Environment + Ports + Files + Advanced + + + + + name={"env"} + label={"Environment Variables"} + isDisabled={nodeData.isFromPackage} + > + + name={"env"} + disabled={nodeData.isFromPackage} + KeyFieldComponent={StringArgumentInput} + ValueFieldComponent={MentionStringArgumentInput} + /> + + + + + name={"ports"} + label={"Ports"} + isDisabled={nodeData.isFromPackage} + > + ({ + name: "", + applicationProtocol: "", + transportProtocol: "TCP", + port: 0, + })} + /> + + + + + name={"files"} + label={"Files"} + helperText={"Choose where to mount artifacts on this services filesystem"} + isDisabled={nodeData.isFromPackage} + > + ({ + mountPoint: "", + name: "", + })} + /> + + + + + + name={"entrypoint"} + label={"Entrypoint"} + helperText={ + "The ENTRYPOINT statement hardcoded in a container image's Dockerfile might not be suitable for your needs." + } + isDisabled={nodeData.isFromPackage} + > + + + + name={"cmd"} + label={"CMD"} + helperText={ + "The CMD statement hardcoded in a container image's Dockerfile might not be suitable for your needs." + } + isDisabled={nodeData.isFromPackage} + > + + + + + + + + ); + }, + (oldProps, newProps) => oldProps.id === newProps.id && oldProps.selected === newProps.selected, +); diff --git a/enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/KurtosisShellNode.tsx b/enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/nodes/KurtosisShellNode.tsx similarity index 55% rename from enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/KurtosisShellNode.tsx rename to enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/nodes/KurtosisShellNode.tsx index f144c4f1e4..bb309e4e95 100644 --- a/enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/KurtosisShellNode.tsx +++ b/enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/nodes/KurtosisShellNode.tsx @@ -2,19 +2,20 @@ import { Flex, Tab, TabList, TabPanel, TabPanels, Tabs } from "@chakra-ui/react" import { isDefined } from "kurtosis-ui-components"; import { memo } from "react"; import { NodeProps } from "reactflow"; -import { BooleanArgumentInput } from "../form/BooleanArgumentInput"; -import { CodeEditorInput } from "../form/CodeEditorInput"; -import { DictArgumentInput } from "../form/DictArgumentInput"; -import { KurtosisFormControl } from "../form/KurtosisFormControl"; -import { ListArgumentInput } from "../form/ListArgumentInput"; -import { StringArgumentInput } from "../form/StringArgumentInput"; -import { ImageConfigInput } from "./input/ImageConfigInput"; -import { MentionStringArgumentInput } from "./input/MentionStringArgumentInput"; -import { MountArtifactFileInput } from "./input/MountArtifactFileInput"; -import { validateDurationString, validateName } from "./input/validators"; +import { BooleanArgumentInput } from "../../form/BooleanArgumentInput"; +import { CodeEditorInput } from "../../form/CodeEditorInput"; +import { DictArgumentInput } from "../../form/DictArgumentInput"; +import { KurtosisFormControl } from "../../form/KurtosisFormControl"; +import { ListArgumentInput } from "../../form/ListArgumentInput"; +import { StringArgumentInput } from "../../form/StringArgumentInput"; +import { ImageConfigInput } from "../input/ImageConfigInput"; +import { MentionStringArgumentInput } from "../input/MentionStringArgumentInput"; +import { MountArtifactFileInput } from "../input/MountArtifactFileInput"; +import { StoreConfigurationInput } from "../input/StoreConfigurationInput"; +import { validateDurationString, validateName } from "../input/validators"; +import { KurtosisFileMount, KurtosisShellNodeData } from "../types"; +import { useVariableContext } from "../VariableContextProvider"; import { KurtosisNode } from "./KurtosisNode"; -import { KurtosisFileMount, KurtosisShellNodeData } from "./types"; -import { useVariableContext } from "./VariableContextProvider"; export const KurtosisShellNode = memo( ({ id, selected }: NodeProps) => { @@ -28,33 +29,57 @@ export const KurtosisShellNode = memo( return ( - name={"shellName"} label={"Shell Name"} isRequired> - + + name={"name"} + label={"Shell Name"} + isRequired + isDisabled={nodeData.isFromPackage} + > + - name={"image.image"} label={"Container Image"}> - + + name={"image.image"} + label={"Container Image"} + isDisabled={nodeData.isFromPackage} + > + - + Script Environment Files Advanced - - name={"command"} label={"Script to run"} isRequired> - + + name={"command"} + label={"Script to run"} + isRequired + isDisabled={nodeData.isFromPackage} + > + - name={"env"} label={"Environment Variables"}> + + name={"env"} + label={"Environment Variables"} + isDisabled={nodeData.isFromPackage} + > name={"env"} KeyFieldComponent={StringArgumentInput} ValueFieldComponent={MentionStringArgumentInput} + disabled={nodeData.isFromPackage} /> @@ -63,14 +88,16 @@ export const KurtosisShellNode = memo( name={"files"} label={"Input Files"} helperText={"Choose where to mount artifacts on this execution tasks filesystem"} + isDisabled={nodeData.isFromPackage} > ({ mountPoint: "", - artifactName: "", + name: "", })} + disabled={nodeData.isFromPackage} /> @@ -80,11 +107,13 @@ export const KurtosisShellNode = memo( "Choose which files to expose from this execution task. You can use either an absolute path, a directory, or a glob." } isRequired + isDisabled={nodeData.isFromPackage} > - + ({ name: "", path: "" })} + minLength={1} /> @@ -94,19 +123,23 @@ export const KurtosisShellNode = memo( name={"wait_enabled"} label={"Wait enabled"} isRequired + isDisabled={nodeData.isFromPackage} helperText={"Whether kurtosis should wait a preset time for this step to complete."} > - name={"wait_enabled"} /> + + name={"wait_enabled"} + disabled={nodeData.isFromPackage} + /> name={"wait"} label={"Wait"} - isDisabled={nodeData.wait_enabled === "false"} + isDisabled={nodeData.wait_enabled === "false" || nodeData.isFromPackage} helperText={"Whether kurtosis should wait a preset time for this step to complete."} > name={"wait"} - isDisabled={nodeData.wait_enabled === "false"} + disabled={nodeData.wait_enabled === "false" || nodeData.isFromPackage} size={"sm"} placeholder={"180s"} validate={nodeData.wait_enabled === "false" ? undefined : validateDurationString} diff --git a/enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/types.ts b/enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/types.ts index aec5580094..7af5302381 100644 --- a/enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/types.ts +++ b/enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/types.ts @@ -5,7 +5,7 @@ export type Variable = { }; export type KurtosisPort = { - portName: string; + name: string; port: number; transportProtocol: "TCP" | "UDP"; applicationProtocol: string; @@ -15,7 +15,7 @@ export type KurtosisEnvironmentVar = { key: string; value: string }; export type KurtosisFileMount = { mountPoint: string; - artifactName: string; + name: string; }; export type KurtosisAcceptableCode = { @@ -38,31 +38,49 @@ export type KurtosisImageConfig = { export type KurtosisServiceNodeData = { type: "service"; - serviceName: string; + name: string; + isFromPackage?: boolean; image: KurtosisImageConfig; env: KurtosisEnvironmentVar[]; ports: KurtosisPort[]; files: KurtosisFileMount[]; - execStepEnabled: "true" | "false"; - execStepCommand: string; - execStepAcceptableCodes: KurtosisAcceptableCode[]; + entrypoint: string; + cmd: string; isValid: boolean; }; + +export type KurtosisExecNodeData = { + type: "exec"; + name: string; + isFromPackage?: boolean; + isValid: boolean; + service: string; + command: string; + acceptableCodes: KurtosisAcceptableCode[]; +}; + export type KurtosisArtifactNodeData = { type: "artifact"; - artifactName: string; + name: string; + isFromPackage?: boolean; files: Record; isValid: boolean; }; +export type KurtosisStore = { + name: string; + path: string; +}; + export type KurtosisShellNodeData = { type: "shell"; - shellName: string; + name: string; + isFromPackage?: boolean; command: string; image: KurtosisImageConfig; env: KurtosisEnvironmentVar[]; files: KurtosisFileMount[]; - store: string; + store: KurtosisStore[]; wait_enabled: "true" | "false"; wait: string; isValid: boolean; @@ -73,20 +91,103 @@ export type KurtosisPythonArg = { arg: string }; export type KurtosisPythonNodeData = { type: "python"; - pythonName: string; + name: string; + isFromPackage?: boolean; command: string; image: KurtosisImageConfig; packages: KurtosisPythonPackage[]; args: KurtosisPythonArg[]; files: KurtosisFileMount[]; - store: string; + store: KurtosisStore[]; wait_enabled: "true" | "false"; wait: string; isValid: boolean; }; +export type KurtosisPackageNodeData = { + type: "package"; + name: string; + isFromPackage?: boolean; + packageId: string; + locator: string; + args: Record; + isValid: boolean; +}; + export type KurtosisNodeData = | KurtosisArtifactNodeData | KurtosisServiceNodeData + | KurtosisExecNodeData | KurtosisShellNodeData - | KurtosisPythonNodeData; + | KurtosisPythonNodeData + | KurtosisPackageNodeData; + +export type PlanPort = { + name: string; + number: number; + transportProtocol: "TCP" | "UDP"; + applicationProtocol?: string; +}; + +type PlanArtifactReference = { + name: string; + uuid: string; +}; + +type PlanFile = { + mountPath: string; + filesArtifacts: PlanArtifactReference[]; +}; + +export type PlanService = { + name: string; + uuid: string; + image: { name: string }; + envVars?: KurtosisEnvironmentVar[]; + ports?: PlanPort[]; + command?: string[]; + entrypoint?: string[]; + files: PlanFile[]; +}; + +type PlanExecTask = { + taskType: "exec"; + uuid: string; + command: string[]; + serviceName: string; + acceptableCodes?: number[]; +}; + +type PlanPythonTask = { + taskType: "python"; + uuid: string; + command?: string[]; + image: string; + files?: PlanFile[]; + store?: PlanArtifactReference[]; + pythonArgs: string[]; +}; + +type PlanShTask = { + taskType: "sh"; + uuid: string; + command?: string[]; + image: string; + files?: PlanFile[]; + store?: PlanArtifactReference[]; +}; + +export type PlanTask = PlanExecTask | PlanPythonTask | PlanShTask; + +export type PlanFileArtifact = { + name: string; + uuid: string; + files: string[]; +}; + +export type PlanYaml = { + packageId: string; + services?: PlanService[]; + tasks?: PlanTask[]; + filesArtifacts?: PlanFileArtifact[]; +}; diff --git a/enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/utils.ts b/enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/utils.ts index b9d7c7f84e..26d451ecd1 100644 --- a/enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/utils.ts +++ b/enclave-manager/web/packages/app/src/emui/enclaves/components/enclaveBuilder/utils.ts @@ -2,7 +2,13 @@ import { isDefined, RemoveFunctions, stringifyError } from "kurtosis-ui-componen import { Edge, Node } from "reactflow"; import { Result } from "true-myth"; import { EnclaveFullInfo } from "../../types"; -import { KurtosisImageConfig, KurtosisNodeData, KurtosisServiceNodeData, Variable } from "./types"; +import { + KurtosisImageConfig, + KurtosisNodeData, + KurtosisPackageNodeData, + KurtosisServiceNodeData, + Variable, +} from "./types"; export const EMUI_BUILD_STATE_KEY = "EMUI_BUILD_STATE"; @@ -35,22 +41,6 @@ export function getInitialGraphStateFromEnclave( } } -export function getNodeName(kurtosisNodeData: KurtosisNodeData): string { - if (kurtosisNodeData.type === "service") { - return kurtosisNodeData.serviceName; - } - if (kurtosisNodeData.type === "artifact") { - return kurtosisNodeData.artifactName; - } - if (kurtosisNodeData.type === "shell") { - return kurtosisNodeData.shellName; - } - if (kurtosisNodeData.type === "python") { - return kurtosisNodeData.pythonName; - } - throw new Error(`Unknown node type.`); -} - function normaliseNameToStarlarkVariable(name: string) { return name.replace(/\s|-/g, "_").toLowerCase(); } @@ -70,40 +60,55 @@ export function getVariablesFromNodes(nodes: Record): return [ { id: `service.${id}.name`, - displayName: `${data.serviceName}.name`, - value: `${normaliseNameToStarlarkVariable(data.serviceName)}.name`, + displayName: `${data.name}`, + value: `${ + data.isFromPackage ? `plan.get_service(name="${data.name}")` : normaliseNameToStarlarkVariable(data.name) + }.name`, }, { id: `service.${id}.hostname`, - displayName: `${data.serviceName}.hostname`, - value: `${normaliseNameToStarlarkVariable(data.serviceName)}.hostname`, + displayName: `${data.name}.hostname`, + value: `${ + data.isFromPackage ? `plan.get_service(name="${data.name}")` : normaliseNameToStarlarkVariable(data.name) + }.hostname`, + }, + { + id: `service.${id}.ip_address`, + displayName: `${data.name}.ip_address`, + value: `${ + data.isFromPackage ? `plan.get_service(name="${data.name}")` : normaliseNameToStarlarkVariable(data.name) + }.ip_address`, }, ...data.ports.flatMap((port, i) => [ { id: `service.${id}.ports.${i}`, - displayName: `${data.serviceName}.ports.${port.portName}`, - value: `"{}://{}:{}".format(${normaliseNameToStarlarkVariable(data.serviceName)}.ports["${ - port.portName - }"].application_protocol, ${normaliseNameToStarlarkVariable( - data.serviceName, - )}.hostname, ${normaliseNameToStarlarkVariable(data.serviceName)}.ports["${port.portName}"].number)`, + displayName: `${data.name}.ports.${port.name}`, + value: `"{}://{}:{}".format(${ + data.isFromPackage ? `plan.get_service(name="${data.name}")` : normaliseNameToStarlarkVariable(data.name) + }.ports["${port.name}"].application_protocol, ${ + data.isFromPackage ? `plan.get_service(name="${data.name}")` : normaliseNameToStarlarkVariable(data.name) + }.hostname, ${ + data.isFromPackage ? `plan.get_service(name="${data.name}")` : normaliseNameToStarlarkVariable(data.name) + }.ports["${port.name}"].number)`, }, { id: `service.${id}.ports.${i}.port`, - displayName: `${data.serviceName}.ports.${port.portName}.port`, - value: `str(${normaliseNameToStarlarkVariable(data.serviceName)}.ports["${port.portName}"].number)`, + displayName: `${data.name}.ports.${port.name}.port`, + value: `str(${ + data.isFromPackage ? `plan.get_service(name="${data.name}")` : normaliseNameToStarlarkVariable(data.name) + }.ports["${port.name}"].number)`, }, { id: `service.${id}.ports.${i}.applicationProtocol`, - displayName: `${data.serviceName}.ports.${port.portName}.application_protocol`, - value: `${normaliseNameToStarlarkVariable(data.serviceName)}.ports["${ - port.portName - }"].application_protocol`, + displayName: `${data.name}.ports.${port.name}.application_protocol`, + value: `${ + data.isFromPackage ? `plan.get_service(name="${data.name}")` : normaliseNameToStarlarkVariable(data.name) + }.ports["${port.name}"].application_protocol`, }, ]), ...data.env.map((env, i) => ({ id: `service.${id}.env.${i}.value`, - displayName: `${data.serviceName}.env.${env.key}`, + displayName: `${data.name}.env.${env.key}`, value: `"${env.value}"`, })), ]; @@ -111,23 +116,25 @@ export function getVariablesFromNodes(nodes: Record): if (data.type === "artifact") { return [ { - id: `artifact.${id}`, - displayName: `${data.artifactName}`, - value: `${normaliseNameToStarlarkVariable(data.artifactName)}`, + id: `artifact.${id}.store.${data.name}`, + displayName: `${data.name}`, + value: `${normaliseNameToStarlarkVariable(data.name)}`, }, ]; } if (data.type === "shell") { return [ - { - id: `shell.${id}`, - displayName: `${data.shellName}`, - value: `${normaliseNameToStarlarkVariable(data.shellName)}.files_artifacts[0]`, - }, + ...data.store.map((store, i) => ({ + id: `shell.${id}.store.${store.name}`, + displayName: `${data.name}.${store.name}`, + value: data.isFromPackage + ? `"${store.name}"` + : `${normaliseNameToStarlarkVariable(data.name)}.files_artifacts[${i}]`, + })), ...data.env.map((env, i) => ({ id: `shell.${id}.env.${i}.value`, - displayName: `${data.shellName}.env.${env.key}`, + displayName: `${data.name}.env.${env.key}`, value: `"${env.value}"`, })), ]; @@ -135,14 +142,16 @@ export function getVariablesFromNodes(nodes: Record): if (data.type === "python") { return [ - { - id: `python.${id}`, - displayName: `${data.pythonName}`, - value: `${normaliseNameToStarlarkVariable(data.pythonName)}.files_artifacts[0]`, - }, + ...data.store.map((store, i) => ({ + id: `python.${id}.store.${store.name}`, + displayName: `${data.name}.${store.name}`, + value: data.isFromPackage + ? `"${store.name}"` + : `${normaliseNameToStarlarkVariable(data.name)}.files_artifacts[${i}]`, + })), ...data.args.map((arg, i) => ({ id: `python.${id}.args.${i}.arg`, - displayName: `${data.pythonName}.args[${i}]`, + displayName: `${data.name}.args[${i}]`, value: `"${arg.arg}"`, })), ]; @@ -162,10 +171,18 @@ export function getNodeDependencies(nodes: Record): Re }; Object.entries(nodes).forEach(([id, data]) => { if (data.type === "service") { - const nameMatches = data.serviceName.match(variablePattern); + const nameMatches = data.name.match(variablePattern); if (nameMatches) { getDependenciesFor(id).add(nameMatches[2]); } + const cmdMatches = data.cmd.match(variablePattern); + if (cmdMatches) { + getDependenciesFor(id).add(cmdMatches[2]); + } + const entrypointMatches = data.entrypoint.match(variablePattern); + if (entrypointMatches) { + getDependenciesFor(id).add(entrypointMatches[2]); + } data.env.forEach((env) => { const envMatches = env.key.match(variablePattern) || env.value.match(variablePattern); if (envMatches) { @@ -173,26 +190,30 @@ export function getNodeDependencies(nodes: Record): Re } }); data.ports.forEach((port) => { - const portMatches = port.portName.match(variablePattern) || port.applicationProtocol.match(variablePattern); + const portMatches = port.name.match(variablePattern) || port.applicationProtocol.match(variablePattern); if (portMatches) { getDependenciesFor(id).add(portMatches[2]); } }); data.files.forEach((file) => { - const fileMatches = file.mountPoint.match(variablePattern) || file.artifactName.match(variablePattern); + const fileMatches = file.mountPoint.match(variablePattern) || file.name.match(variablePattern); if (fileMatches) { getDependenciesFor(id).add(fileMatches[2]); } }); - if (data.execStepEnabled === "true") { - const commandMatches = data.execStepCommand.match(variablePattern); - if (commandMatches) { - getDependenciesFor(id).add(commandMatches[2]); - } + } + if (data.type === "exec") { + const serviceMatches = data.service.match(variablePattern); + if (serviceMatches) { + getDependenciesFor(id).add(serviceMatches[2]); + } + const commandMatches = data.command.match(variablePattern); + if (commandMatches) { + getDependenciesFor(id).add(commandMatches[2]); } } if (data.type === "shell") { - const nameMatches = data.shellName.match(variablePattern); + const nameMatches = data.name.match(variablePattern); if (nameMatches) { getDependenciesFor(id).add(nameMatches[2]); } @@ -203,14 +224,14 @@ export function getNodeDependencies(nodes: Record): Re } }); data.files.forEach((file) => { - const fileMatches = file.mountPoint.match(variablePattern) || file.artifactName.match(variablePattern); + const fileMatches = file.mountPoint.match(variablePattern) || file.name.match(variablePattern); if (fileMatches) { getDependenciesFor(id).add(fileMatches[2]); } }); } if (data.type === "python") { - const nameMatches = data.pythonName.match(variablePattern); + const nameMatches = data.name.match(variablePattern); if (nameMatches) { getDependenciesFor(id).add(nameMatches[2]); } @@ -221,7 +242,7 @@ export function getNodeDependencies(nodes: Record): Re } }); data.files.forEach((file) => { - const fileMatches = file.mountPoint.match(variablePattern) || file.artifactName.match(variablePattern); + const fileMatches = file.mountPoint.match(variablePattern) || file.name.match(variablePattern); if (fileMatches) { getDependenciesFor(id).add(fileMatches[2]); } @@ -237,11 +258,17 @@ export function generateStarlarkFromGraph( data: Record, existingEnclave?: RemoveFunctions, ): string { + const nodeLookup = nodes.reduce((acc: Record, cur) => ({ ...acc, [cur.id]: cur }), {}); + const primaryNodes = nodes.filter((node) => !isDefined(node.parentNode)); + const primaryEdges = edges + .map((edge) => ({ ...edge, source: nodeLookup[edge.source].parentNode || edge.source })) + .filter((e) => e.target !== e.source); + // Topological sort const sortedNodes: Node[] = []; - let remainingEdges = [...edges].filter((e) => e.target !== e.source); - while (remainingEdges.length > 0 || sortedNodes.length !== nodes.length) { - const nodesToRemove = nodes + let remainingEdges = [...primaryEdges]; + while (remainingEdges.length > 0 || sortedNodes.length !== primaryNodes.length) { + const nodesToRemove = primaryNodes .filter((node) => remainingEdges.every((edge) => edge.target !== node.id)) // eslint-disable-line no-loop-func .filter((node) => !sortedNodes.includes(node)); @@ -258,7 +285,7 @@ export function generateStarlarkFromGraph( {} as Record, ); const interpolateValue = (input: string): string => { - let formatString = input; + let formatString = input.replaceAll('"', '\\"'); let variableMatches = formatString.match(variablePattern); if (!isDefined(variableMatches)) { return `"${formatString}"`; @@ -277,6 +304,40 @@ export function generateStarlarkFromGraph( return `"${formatString}".format(${references.join(", ")})`; }; + function objectToStarlark(o: any, indent: number) { + const padLeft = "".padStart(indent, " "); + if (!isDefined(o)) { + return "None"; + } + if (Array.isArray(o)) { + let result = `[`; + o.forEach((arrayValue) => { + result += `${objectToStarlark(arrayValue, indent + 4)},\n`; + }); + result += `${padLeft}],\n`; + return result; + } + if (typeof o === "number") { + return `${o}`; + } + if (typeof o === "string") { + return interpolateValue(o); + } + if (typeof o === "boolean") { + return o ? "True" : "False"; + } + if (typeof o === "object") { + let result = "{"; + Object.entries(o).forEach(([key, value]) => { + result += `\n${padLeft}${interpolateValue(key)}: ${objectToStarlark(value, indent + 4)},`; + }); + result += `${padLeft}}`; + return result; + } + + throw new Error(`Unable to convert the object ${o} to starlark`); + } + const renderImageConfig = (config: KurtosisImageConfig): string => { switch (config.type) { case "image": @@ -299,18 +360,31 @@ export function generateStarlarkFromGraph( } }; - let starlark = "def run(plan):\n"; + let starlark = ""; + const packageNodeData = sortedNodes + .map((n) => data[n.id]) + .filter((d) => d.type === "package") as KurtosisPackageNodeData[]; + for (const nodeData of packageNodeData) { + const module_name = `${normaliseNameToStarlarkVariable(nodeData.name)}_module`; + // Todo handle other paths + starlark += `${module_name} = import_module(${interpolateValue(nodeData.locator)})\n`; + } + if (packageNodeData.length > 0) { + starlark += "\n"; + } + + starlark += "def run(plan):\n"; for (const node of sortedNodes) { const nodeData = data[node.id]; if (nodeData.type === "service") { - const serviceName = normaliseNameToStarlarkVariable(nodeData.serviceName); + const serviceName = normaliseNameToStarlarkVariable(nodeData.name); starlark += ` ${serviceName} = plan.add_service(\n`; - starlark += ` name = ${interpolateValue(nodeData.serviceName)},\n`; + starlark += ` name = ${interpolateValue(nodeData.name)},\n`; starlark += ` config = ServiceConfig (\n`; starlark += ` image = ${renderImageConfig(nodeData.image)},\n`; starlark += ` ports = {\n`; - for (const { portName, port, applicationProtocol, transportProtocol } of nodeData.ports) { - starlark += ` ${interpolateValue(portName)}: PortSpec(\n`; + for (const { name, port, applicationProtocol, transportProtocol } of nodeData.ports) { + starlark += ` ${interpolateValue(name)}: PortSpec(\n`; starlark += ` number = ${port},\n`; starlark += ` transport_protocol = "${transportProtocol}",\n`; starlark += ` application_protocol = ${interpolateValue(applicationProtocol)},\n`; @@ -323,33 +397,32 @@ export function generateStarlarkFromGraph( } starlark += ` },\n`; starlark += ` files = {\n`; - for (const { mountPoint, artifactName } of nodeData.files) { - starlark += ` ${interpolateValue(mountPoint)}: ${interpolateValue(artifactName)},\n`; + for (const { mountPoint, name } of nodeData.files) { + starlark += ` ${interpolateValue(mountPoint)}: ${interpolateValue(name)},\n`; } starlark += ` },\n`; starlark += ` ),\n`; starlark += ` )\n\n`; + } - if (nodeData.execStepEnabled === "true") { - const execName = `${serviceName}_exec`; - starlark += ` ${execName} = plan.exec(\n`; - starlark += ` service_name = ${interpolateValue(nodeData.serviceName)},\n`; - starlark += ` recipe = ExecRecipe(\n`; - starlark += ` command = [${nodeData.execStepCommand.split(" ").map(interpolateValue).join(", ")}],`; - starlark += ` ),\n`; - if (nodeData.execStepAcceptableCodes.length > 0) { - starlark += ` acceptable_codes = [${nodeData.execStepAcceptableCodes - .map(({ value }) => value) - .join(", ")}],\n`; - } - starlark += ` )\n\n`; + if (nodeData.type === "exec") { + const serviceName = normaliseNameToStarlarkVariable(interpolateValue(nodeData.service).replace(/\.name$/, "")); + const execName = `${serviceName}_exec`; + starlark += ` ${execName} = plan.exec(\n`; + starlark += ` service_name = ${interpolateValue(nodeData.service)},\n`; + starlark += ` recipe = ExecRecipe(\n`; + starlark += ` command = [${nodeData.command.split(" ").map(interpolateValue).join(", ")}],`; + starlark += ` ),\n`; + if (nodeData.acceptableCodes.length > 0) { + starlark += ` acceptable_codes = [${nodeData.acceptableCodes.map(({ value }) => value).join(", ")}],\n`; } + starlark += ` )\n\n`; } if (nodeData.type === "artifact") { - const artifactName = normaliseNameToStarlarkVariable(nodeData.artifactName); + const artifactName = normaliseNameToStarlarkVariable(nodeData.name); starlark += ` ${artifactName} = plan.render_templates(\n`; - starlark += ` name = "${nodeData.artifactName}",\n`; + starlark += ` name = "${nodeData.name}",\n`; starlark += ` config = {\n`; for (const [fileName, fileText] of Object.entries(nodeData.files)) { starlark += ` "${fileName}": struct(\n`; @@ -362,7 +435,7 @@ export function generateStarlarkFromGraph( } if (nodeData.type === "shell") { - const shellName = normaliseNameToStarlarkVariable(nodeData.shellName); + const shellName = normaliseNameToStarlarkVariable(nodeData.name); starlark += ` ${shellName} = plan.run_sh(\n`; starlark += ` run = """${escapeString(nodeData.command)}""",\n`; const image = renderImageConfig(nodeData.image); @@ -375,13 +448,17 @@ export function generateStarlarkFromGraph( } starlark += ` },\n`; starlark += ` files = {\n`; - for (const { mountPoint, artifactName } of nodeData.files) { - starlark += ` ${interpolateValue(mountPoint)}: ${interpolateValue(artifactName)},\n`; + for (const { mountPoint, name } of nodeData.files) { + starlark += ` ${interpolateValue(mountPoint)}: ${interpolateValue(name)},\n`; } starlark += ` },\n`; - starlark += ` store = [\n`; - starlark += ` StoreSpec(src = ${interpolateValue(nodeData.store)}, name="${shellName}"),\n`; - starlark += ` ],\n`; + if (nodeData.store.length > 0) { + starlark += ` store = [\n`; + for (const { name, path } of nodeData.store) { + starlark += ` StoreSpec(src = ${interpolateValue(path)}, name="${name}"),\n`; + } + starlark += ` ],\n`; + } const wait = interpolateValue(nodeData.wait); if (nodeData.wait_enabled === "false" || wait !== '""') { starlark += ` wait=${nodeData.wait_enabled === "true" ? wait : "None"},\n`; @@ -390,7 +467,7 @@ export function generateStarlarkFromGraph( } if (nodeData.type === "python") { - const pythonName = normaliseNameToStarlarkVariable(nodeData.pythonName); + const pythonName = normaliseNameToStarlarkVariable(nodeData.name); starlark += ` ${pythonName} = plan.run_python(\n`; starlark += ` run = """${escapeString(nodeData.command)}""",\n`; const image = renderImageConfig(nodeData.image); @@ -408,13 +485,15 @@ export function generateStarlarkFromGraph( } starlark += ` ],\n`; starlark += ` files = {\n`; - for (const { mountPoint, artifactName } of nodeData.files) { - starlark += ` ${interpolateValue(mountPoint)}: ${interpolateValue(artifactName)},\n`; + for (const { mountPoint, name } of nodeData.files) { + starlark += ` ${interpolateValue(mountPoint)}: ${interpolateValue(name)},\n`; } starlark += ` },\n`; - if (nodeData.store !== "") { + if (nodeData.store.length > 0) { starlark += ` store = [\n`; - starlark += ` StoreSpec(src = ${interpolateValue(nodeData.store)}, name="${pythonName}"),\n`; + for (const { name, path } of nodeData.store) { + starlark += ` StoreSpec(src = ${interpolateValue(path)}, name="${name}"),\n`; + } starlark += ` ],\n`; } const wait = interpolateValue(nodeData.wait); @@ -423,14 +502,20 @@ export function generateStarlarkFromGraph( } starlark += ` )\n\n`; } + + if (nodeData.type === "package") { + const packageName = normaliseNameToStarlarkVariable(nodeData.name); + starlark += ` ${packageName} = ${packageName}_module.run(plan, **${objectToStarlark(nodeData.args, 8)}`; + starlark += ` )\n\n`; + } } // Delete any services from any existing enclave that aren't defined anymore if (isDefined(existingEnclave) && existingEnclave.services?.isOk) { for (const existingService of Object.values(existingEnclave.services.value.serviceInfo)) { - const serviceNoLongerExists = sortedNodes.every((node) => { + const serviceNoLongerExists = nodes.every((node) => { const nodeData = data[node.id]; - return nodeData.type !== "service" || nodeData.serviceName !== existingService.name; + return !isDefined(nodeData) || nodeData.type !== "service" || nodeData.name !== existingService.name; }); if (serviceNoLongerExists) { starlark += ` plan.remove_service(name = "${existingService.name}")\n`; diff --git a/enclave-manager/web/packages/app/src/emui/enclaves/components/form/BooleanArgumentInput.tsx b/enclave-manager/web/packages/app/src/emui/enclaves/components/form/BooleanArgumentInput.tsx index aaeddad9b1..1197a3b114 100644 --- a/enclave-manager/web/packages/app/src/emui/enclaves/components/form/BooleanArgumentInput.tsx +++ b/enclave-manager/web/packages/app/src/emui/enclaves/components/form/BooleanArgumentInput.tsx @@ -19,21 +19,20 @@ export const BooleanArgumentInput = ({ return ( ); } else { return ( - + ({ ({ isRequired={true} size={"sm"} width={"222px"} + disabled={otherProps.disabled} /> ({ name={`${otherProps.name}.${i}.value` as any} validate={otherProps.validate} isRequired={true} + disabled={otherProps.disabled} size={"sm"} width={"222px"} /> - @@ -88,6 +97,7 @@ export const DictArgumentInput = ({ leftIcon={} size={"sm"} colorScheme={"kurtosisGreen"} + isDisabled={otherProps.disabled} variant={"outline"} > Add diff --git a/enclave-manager/web/packages/app/src/emui/enclaves/components/form/IntegerArgumentInput.tsx b/enclave-manager/web/packages/app/src/emui/enclaves/components/form/IntegerArgumentInput.tsx index cbbdd7fb9f..a4c37987be 100644 --- a/enclave-manager/web/packages/app/src/emui/enclaves/components/form/IntegerArgumentInput.tsx +++ b/enclave-manager/web/packages/app/src/emui/enclaves/components/form/IntegerArgumentInput.tsx @@ -10,7 +10,6 @@ export const IntegerArgumentInput = (props: KurtosisFo return ( { if (isNaN(value)) { @@ -23,6 +22,7 @@ export const IntegerArgumentInput = (props: KurtosisFo } }, })} + isReadOnly={props.disabled} placeholder={props.placeholder} width={props.width} size={props.size || "lg"} diff --git a/enclave-manager/web/packages/app/src/emui/enclaves/components/form/KurtosisFormControl.tsx b/enclave-manager/web/packages/app/src/emui/enclaves/components/form/KurtosisFormControl.tsx index 78ac90cc92..ab6c2ad8c7 100644 --- a/enclave-manager/web/packages/app/src/emui/enclaves/components/form/KurtosisFormControl.tsx +++ b/enclave-manager/web/packages/app/src/emui/enclaves/components/form/KurtosisFormControl.tsx @@ -66,12 +66,14 @@ type KurtosisSubtypeFormControlProps = PropsWithChildr name: FieldPath; disabled?: boolean; isRequired?: boolean; -}>; +}> & + FormControlProps; export const KurtosisSubtypeFormControl = ({ name, disabled, isRequired, children, + ...formControlProps }: KurtosisSubtypeFormControlProps) => { const { formState: { errors }, @@ -82,7 +84,13 @@ export const KurtosisSubtypeFormControl = ({ .reduce((e, part) => (isDefined(e) ? e[part] : undefined), errors as Record) as FieldError | undefined; return ( - + {children} ); diff --git a/enclave-manager/web/packages/app/src/emui/enclaves/components/form/ListArgumentInput.tsx b/enclave-manager/web/packages/app/src/emui/enclaves/components/form/ListArgumentInput.tsx index 6fa774def5..19842642a3 100644 --- a/enclave-manager/web/packages/app/src/emui/enclaves/components/form/ListArgumentInput.tsx +++ b/enclave-manager/web/packages/app/src/emui/enclaves/components/form/ListArgumentInput.tsx @@ -10,16 +10,18 @@ import { KurtosisFormInputProps } from "./types"; type ListArgumentInputProps = KurtosisFormInputProps & { FieldComponent: FC>; createNewValue: () => object; + minLength?: number; }; export const ListArgumentInput = ({ FieldComponent, createNewValue, + minLength, ...otherProps }: ListArgumentInputProps) => { const toast = useToast(); const { getValues, setValue } = useFormContext(); - const { fields, append, remove } = useFieldArray({ name: otherProps.name }); + const { fields, append, remove } = useFieldArray({ name: otherProps.name, rules: { minLength: minLength } }); const handleValuePaste = (value: string) => { try { @@ -45,10 +47,23 @@ export const ListArgumentInput = ({ disabled={otherProps.disabled} isRequired={otherProps.isRequired} name={`${otherProps.name as `args.${string}`}.${i}`} + flex={1} > - + - @@ -60,6 +75,7 @@ export const ListArgumentInput = ({ colorScheme={"kurtosisGreen"} size={"sm"} variant={"outline"} + disabled={otherProps.disabled} > Add diff --git a/enclave-manager/web/packages/app/src/emui/enclaves/components/form/OptionArgumentInput.tsx b/enclave-manager/web/packages/app/src/emui/enclaves/components/form/OptionArgumentInput.tsx index 309adb5551..1c57387ee9 100644 --- a/enclave-manager/web/packages/app/src/emui/enclaves/components/form/OptionArgumentInput.tsx +++ b/enclave-manager/web/packages/app/src/emui/enclaves/components/form/OptionArgumentInput.tsx @@ -22,8 +22,7 @@ export const OptionsArgumentInput = ({ ({ const { register } = useFormContext(); return ( - {options.map((option) => (