From 8a26a6cc38e832f92e552b5f32fd6a5c73cf2869 Mon Sep 17 00:00:00 2001 From: Ben Gazzard Date: Thu, 8 Feb 2024 19:16:49 +0000 Subject: [PATCH] feat: experimental enclave building in the EMUI (#2137) ## Description: This PR adds an experimental switch to the 'about' dialog, which allows a 'Build Enclave' option to be enabled in the EMUI. This option presents a graphical interface for drawing out an enclave of services and artifacts, which can then be repeatedly executed to populate/modify an enclave. ## Is this change user facing? YES (but behind a feature switch) ## References (if applicable): * Brief on slack. --- enclave-manager/web/packages/app/package.json | 6 + .../client/enclaveManager/KurtosisClient.ts | 24 +- .../web/packages/app/src/emui/App.tsx | 19 +- .../web/packages/app/src/emui/Navbar.tsx | 32 +- .../app/src/emui/catalog/CatalogContext.tsx | 44 +- .../packages/app/src/emui/catalog/storage.ts | 26 -- .../app/src/emui/enclaves/EnclavesContext.tsx | 32 +- .../emui/enclaves/components/BuildEnclave.tsx | 31 ++ .../enclaves/components/EditEnclaveButton.tsx | 75 ++- .../KurtosisArgumentTypeInput.tsx | 97 ++++ .../KurtosisPackageArgumentInput.tsx | 8 +- .../drawer/bodies/EnclaveConfigureBody.tsx | 10 +- .../drawer/bodies/PackageSelectBody.tsx | 13 + .../configuration/drawer/constants.ts | 1 + .../inputs/KurtosisArgumentTypeInput.tsx | 80 ---- .../inputs => form}/BooleanArgumentInput.tsx | 17 +- .../inputs => form}/DictArgumentInput.tsx | 52 +-- .../inputs => form}/IntegerArgumentInput.tsx | 9 +- .../inputs => form}/JSONArgumentInput.tsx | 7 +- .../KurtosisFormControl.tsx} | 30 +- .../inputs => form}/ListArgumentInput.tsx | 47 +- .../components/form/OptionArgumentInput.tsx | 37 ++ .../components/form/SelectArgumentInput.tsx | 30 ++ .../inputs => form}/StringArgumentInput.tsx | 13 +- .../emui/enclaves/components/form/types.ts | 13 + .../components/modals/EnclaveBuilderModal.tsx | 302 ++++++++++++ .../enclaveBuilder/KurtosisArtifactNode.tsx | 109 +++++ .../enclaveBuilder/KurtosisServiceNode.tsx | 241 ++++++++++ .../VariableContextProvider.tsx | 78 ++++ .../input/FileTreeArgumentInput.tsx | 126 +++++ .../input/MentionStringArgumentInput.css | 63 +++ .../input/MentionStringArgumentInput.tsx | 71 +++ .../input/modals/EditFileModal.tsx | 55 +++ .../input/modals/NewFileModal.tsx | 61 +++ .../components/modals/enclaveBuilder/types.ts | 5 + .../components/modals/enclaveBuilder/utils.ts | 245 ++++++++++ .../widgets/CreateEnclaveButton.tsx | 49 +- .../web/packages/app/src/emui/settings.tsx | 66 +++ .../packages/components/src/CodeEditor.tsx | 11 +- .../web/packages/components/src/FileTree.tsx | 30 +- .../components/src/KurtosisThemeProvider.tsx | 2 +- .../widgets/SaveKurtosisPackageButton.tsx | 1 + .../packages/components/src/utils/index.ts | 9 + enclave-manager/web/yarn.lock | 437 +++++++++++++++++- 44 files changed, 2437 insertions(+), 277 deletions(-) delete mode 100644 enclave-manager/web/packages/app/src/emui/catalog/storage.ts create mode 100644 enclave-manager/web/packages/app/src/emui/enclaves/components/BuildEnclave.tsx create mode 100644 enclave-manager/web/packages/app/src/emui/enclaves/components/configuration/KurtosisArgumentTypeInput.tsx delete mode 100644 enclave-manager/web/packages/app/src/emui/enclaves/components/configuration/inputs/KurtosisArgumentTypeInput.tsx rename enclave-manager/web/packages/app/src/emui/enclaves/components/{configuration/inputs => form}/BooleanArgumentInput.tsx (68%) rename enclave-manager/web/packages/app/src/emui/enclaves/components/{configuration/inputs => form}/DictArgumentInput.tsx (62%) rename enclave-manager/web/packages/app/src/emui/enclaves/components/{configuration/inputs => form}/IntegerArgumentInput.tsx (69%) rename enclave-manager/web/packages/app/src/emui/enclaves/components/{configuration/inputs => form}/JSONArgumentInput.tsx (94%) rename enclave-manager/web/packages/app/src/emui/enclaves/components/{configuration/KurtosisArgumentFormControl.tsx => form/KurtosisFormControl.tsx} (71%) rename enclave-manager/web/packages/app/src/emui/enclaves/components/{configuration/inputs => form}/ListArgumentInput.tsx (51%) create mode 100644 enclave-manager/web/packages/app/src/emui/enclaves/components/form/OptionArgumentInput.tsx create mode 100644 enclave-manager/web/packages/app/src/emui/enclaves/components/form/SelectArgumentInput.tsx rename enclave-manager/web/packages/app/src/emui/enclaves/components/{configuration/inputs => form}/StringArgumentInput.tsx (52%) create mode 100644 enclave-manager/web/packages/app/src/emui/enclaves/components/form/types.ts create mode 100644 enclave-manager/web/packages/app/src/emui/enclaves/components/modals/EnclaveBuilderModal.tsx create mode 100644 enclave-manager/web/packages/app/src/emui/enclaves/components/modals/enclaveBuilder/KurtosisArtifactNode.tsx create mode 100644 enclave-manager/web/packages/app/src/emui/enclaves/components/modals/enclaveBuilder/KurtosisServiceNode.tsx create mode 100644 enclave-manager/web/packages/app/src/emui/enclaves/components/modals/enclaveBuilder/VariableContextProvider.tsx create mode 100644 enclave-manager/web/packages/app/src/emui/enclaves/components/modals/enclaveBuilder/input/FileTreeArgumentInput.tsx create mode 100644 enclave-manager/web/packages/app/src/emui/enclaves/components/modals/enclaveBuilder/input/MentionStringArgumentInput.css create mode 100644 enclave-manager/web/packages/app/src/emui/enclaves/components/modals/enclaveBuilder/input/MentionStringArgumentInput.tsx create mode 100644 enclave-manager/web/packages/app/src/emui/enclaves/components/modals/enclaveBuilder/input/modals/EditFileModal.tsx create mode 100644 enclave-manager/web/packages/app/src/emui/enclaves/components/modals/enclaveBuilder/input/modals/NewFileModal.tsx create mode 100644 enclave-manager/web/packages/app/src/emui/enclaves/components/modals/enclaveBuilder/types.ts create mode 100644 enclave-manager/web/packages/app/src/emui/enclaves/components/modals/enclaveBuilder/utils.ts create mode 100644 enclave-manager/web/packages/app/src/emui/settings.tsx diff --git a/enclave-manager/web/packages/app/package.json b/enclave-manager/web/packages/app/package.json index 0c707dd7f3..4a1bba17f1 100644 --- a/enclave-manager/web/packages/app/package.json +++ b/enclave-manager/web/packages/app/package.json @@ -4,6 +4,7 @@ "private": true, "homepage": ".", "dependencies": { + "@dagrejs/dagre": "^1.0.4", "ansi-to-html": "^0.7.2", "enclave-manager-sdk": "file:../../../api/typescript", "html-react-parser": "^4.2.2", @@ -12,11 +13,16 @@ "kurtosis-ui-components": "0.86.16", "react-error-boundary": "^4.0.11", "react-hook-form": "^7.47.0", + "react-mentions": "^4.4.10", + "reactflow": "^11.10.2", + "uuid": "^9.0.1", "yaml": "^2.3.4" }, "devDependencies": { "@types/js-cookie": "^3.0.6", + "@types/react-mentions": "^4.1.13", "@types/streamsaver": "^2.0.4", + "@types/uuid": "^9.0.8", "serve": "^14.2.1", "source-map-explorer": "^2.5.3" }, 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 53f7c97b10..68074ecdab 100644 --- a/enclave-manager/web/packages/app/src/client/enclaveManager/KurtosisClient.ts +++ b/enclave-manager/web/packages/app/src/client/enclaveManager/KurtosisClient.ts @@ -3,6 +3,7 @@ import { DownloadFilesArtifactArgs, FilesArtifactNameAndUuid, RunStarlarkPackageArgs, + RunStarlarkScriptArgs, ServiceInfo, } from "enclave-manager-sdk/build/api_container_service_pb"; import { @@ -22,6 +23,7 @@ import { GetStarlarkRunRequest, InspectFilesArtifactContentsRequest, RunStarlarkPackageRequest, + RunStarlarkScriptRequest, } 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"; @@ -202,17 +204,37 @@ export abstract class KurtosisClient { apicInfo: RemoveFunctions, packageId: string, args: Record, + dryRun: boolean = false, ) { // Not currently using asyncResult as the return type here is an asyncIterable const request = new RunStarlarkPackageRequest({ apicIpAddress: apicInfo.bridgeIpAddress, apicPort: apicInfo.grpcPortInsideEnclave, RunStarlarkPackageArgs: new RunStarlarkPackageArgs({ - dryRun: false, + dryRun, packageId: packageId, serializedParams: JSON.stringify(args), }), }); return this.client.runStarlarkPackage(request, this.getHeaderOptions()); } + + async runStarlarkScript( + apicInfo: RemoveFunctions, + serializedScript: string, + args: Record = {}, + dryRun: boolean = false, + ) { + // Not currently using asyncResult as the return type here is an asyncIterable + const request = new RunStarlarkScriptRequest({ + apicIpAddress: apicInfo.bridgeIpAddress, + apicPort: apicInfo.grpcPortInsideEnclave, + RunStarlarkScriptArgs: new RunStarlarkScriptArgs({ + dryRun, + serializedScript, + serializedParams: JSON.stringify(args), + }), + }); + return this.client.runStarlarkScript(request, this.getHeaderOptions()); + } } diff --git a/enclave-manager/web/packages/app/src/emui/App.tsx b/enclave-manager/web/packages/app/src/emui/App.tsx index ad7979afc8..10475abd39 100644 --- a/enclave-manager/web/packages/app/src/emui/App.tsx +++ b/enclave-manager/web/packages/app/src/emui/App.tsx @@ -5,10 +5,12 @@ import { KurtosisClientProvider, useKurtosisClient } from "../client/enclaveMana import { KurtosisPackageIndexerProvider } from "../client/packageIndexer/KurtosisPackageIndexerClientContext"; import { CatalogContextProvider } from "./catalog/CatalogContext"; import { catalogRoutes } from "./catalog/CatalogRoutes"; +import { BuildEnclave } from "./enclaves/components/BuildEnclave"; import { CreateEnclave } from "./enclaves/components/CreateEnclave"; import { enclaveRoutes } from "./enclaves/EnclaveRoutes"; import { EnclavesContextProvider } from "./enclaves/EnclavesContext"; import { Navbar } from "./Navbar"; +import { SettingsContextProvider } from "./settings"; const logLogo = (t: string) => console.log(`%c ${t}`, "background: black; color: #00C223"); logLogo(` @@ -35,13 +37,15 @@ console.log(`Kurtosis web UI version: ${process.env.REACT_APP_VERSION || "Unknow export const EmuiApp = () => { return ( - - - - - - - + + + + + + + + + ); }; @@ -65,6 +69,7 @@ const KurtosisRouter = () => { + ), children: enclaveRoutes(), diff --git a/enclave-manager/web/packages/app/src/emui/Navbar.tsx b/enclave-manager/web/packages/app/src/emui/Navbar.tsx index 3176b6c93f..ae4e647cf3 100644 --- a/enclave-manager/web/packages/app/src/emui/Navbar.tsx +++ b/enclave-manager/web/packages/app/src/emui/Navbar.tsx @@ -1,5 +1,7 @@ import { Button, + FormControl, + FormLabel, Input, InputGroup, InputRightElement, @@ -10,6 +12,7 @@ import { ModalFooter, ModalHeader, ModalOverlay, + Switch, Text, } from "@chakra-ui/react"; import { CopyButton, NavButton, Navigation, NavigationDivider } from "kurtosis-ui-components"; @@ -21,8 +24,10 @@ import { PiLinkSimpleBold } from "react-icons/pi"; import { Link, useLocation } from "react-router-dom"; import { KURTOSIS_CLOUD_CONNECT_URL } from "../client/constants"; import { useKurtosisClient } from "../client/enclaveManager/KurtosisClientContext"; +import { settingKeys, useSettings } from "./settings"; export const Navbar = () => { + const { updateSetting, settings } = useSettings(); const location = useLocation(); const kurtosisClient = useKurtosisClient(); const [showAboutDialog, setShowAboutDialog] = useState(false); @@ -78,7 +83,32 @@ export const Navbar = () => { /> - + + Settings: + + + + Enable experimental enclave builder interface? + + + updateSetting( + settingKeys.ENABLE_EXPERIMENTAL_BUILD_ENCLAVE, + !settings.ENABLE_EXPERIMENTAL_BUILD_ENCLAVE, + ) + } + isChecked={settings.ENABLE_EXPERIMENTAL_BUILD_ENCLAVE} + /> + diff --git a/enclave-manager/web/packages/app/src/emui/catalog/CatalogContext.tsx b/enclave-manager/web/packages/app/src/emui/catalog/CatalogContext.tsx index 577f13d542..fb253de90e 100644 --- a/enclave-manager/web/packages/app/src/emui/catalog/CatalogContext.tsx +++ b/enclave-manager/web/packages/app/src/emui/catalog/CatalogContext.tsx @@ -2,10 +2,10 @@ import { Flex, Heading, Spinner } from "@chakra-ui/react"; import { GetPackagesResponse, KurtosisPackage } from "kurtosis-cloud-indexer-sdk"; import { ReadPackageResponse } from "kurtosis-cloud-indexer-sdk/build/kurtosis_package_indexer_pb"; import { isDefined, SavedPackagesProvider } from "kurtosis-ui-components"; -import { createContext, PropsWithChildren, useCallback, useContext, useEffect, useState } from "react"; +import { createContext, PropsWithChildren, useCallback, useContext, useEffect, useLayoutEffect, useState } from "react"; import { Result } from "true-myth"; import { useKurtosisPackageIndexerClient } from "../../client/packageIndexer/KurtosisPackageIndexerClientContext"; -import { loadSavedPackageNames, storeSavedPackages } from "./storage"; +import { settingKeys, useSettings } from "../settings"; export type CatalogState = { catalog: Result; @@ -16,6 +16,7 @@ export type CatalogState = { const CatalogContext = createContext(null as any); export const CatalogContextProvider = ({ children }: PropsWithChildren) => { + const { settings, updateSetting } = useSettings(); const packageIndexerClient = useKurtosisPackageIndexerClient(); const [catalog, setCatalog] = useState>(); const [savedPackages, setSavedPackages] = useState([]); @@ -25,24 +26,25 @@ export const CatalogContextProvider = ({ children }: PropsWithChildren) => { const catalog = await packageIndexerClient.getPackages(); setCatalog(catalog); - if (catalog.isOk) { - const savedPackageNames = new Set(loadSavedPackageNames()); - setSavedPackages(catalog.value.packages.filter((kurtosisPackage) => savedPackageNames.has(kurtosisPackage.name))); - } - return catalog; }, [packageIndexerClient]); - const togglePackageSaved = useCallback((kurtosisPackage: KurtosisPackage) => { - setSavedPackages((savedPackages) => { - const packageSavedAlready = savedPackages.some((p) => p.name === kurtosisPackage.name); - const newSavedPackages: KurtosisPackage[] = packageSavedAlready - ? savedPackages.filter((p) => p.name !== kurtosisPackage.name) - : [...savedPackages, kurtosisPackage]; - storeSavedPackages(newSavedPackages); - return newSavedPackages; - }); - }, []); + const togglePackageSaved = useCallback( + (kurtosisPackage: KurtosisPackage) => { + setSavedPackages((savedPackages) => { + const packageSavedAlready = savedPackages.some((p) => p.name === kurtosisPackage.name); + const newSavedPackages: KurtosisPackage[] = packageSavedAlready + ? savedPackages.filter((p) => p.name !== kurtosisPackage.name) + : [...savedPackages, kurtosisPackage]; + updateSetting( + settingKeys.SAVED_PACKAGES, + newSavedPackages.map((kurtosisPackage) => kurtosisPackage.name), + ); + return newSavedPackages; + }); + }, + [updateSetting], + ); const getSinglePackage = useCallback( async (packageName: string) => { @@ -55,6 +57,14 @@ export const CatalogContextProvider = ({ children }: PropsWithChildren) => { refreshCatalog(); }, [refreshCatalog]); + // Use a Layout effect so that the saved packages are set before first render. + useLayoutEffect(() => { + if (isDefined(catalog) && catalog.isOk) { + const savedPackageNames = new Set(settings.SAVED_PACKAGES); + setSavedPackages(catalog.value.packages.filter((kurtosisPackage) => savedPackageNames.has(kurtosisPackage.name))); + } + }, [catalog, settings.SAVED_PACKAGES]); + if (!isDefined(catalog)) { return ( diff --git a/enclave-manager/web/packages/app/src/emui/catalog/storage.ts b/enclave-manager/web/packages/app/src/emui/catalog/storage.ts deleted file mode 100644 index 988b02e552..0000000000 --- a/enclave-manager/web/packages/app/src/emui/catalog/storage.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { KurtosisPackage } from "kurtosis-cloud-indexer-sdk"; -import { isDefined, stringifyError } from "kurtosis-ui-components"; - -const SAVED_PACKAGES_LOCAL_STORAGE_KEY = "kurtosis-saved-packages"; - -export const storeSavedPackages = (kurtosisPackages: KurtosisPackage[]) => { - localStorage.setItem( - SAVED_PACKAGES_LOCAL_STORAGE_KEY, - JSON.stringify(kurtosisPackages.map((kurtosisPackage) => kurtosisPackage.name)), - ); -}; - -export const loadSavedPackageNames = () => { - try { - const savedRawValue = localStorage.getItem(SAVED_PACKAGES_LOCAL_STORAGE_KEY); - - if (!isDefined(savedRawValue)) { - return []; - } - - return JSON.parse(savedRawValue); - } catch (error: any) { - console.error(`Unable to load saved package names. Got error: ${stringifyError(error)}`); - return []; - } -}; diff --git a/enclave-manager/web/packages/app/src/emui/enclaves/EnclavesContext.tsx b/enclave-manager/web/packages/app/src/emui/enclaves/EnclavesContext.tsx index 0e91aac480..e1b8c63af4 100644 --- a/enclave-manager/web/packages/app/src/emui/enclaves/EnclavesContext.tsx +++ b/enclave-manager/web/packages/app/src/emui/enclaves/EnclavesContext.tsx @@ -47,6 +47,13 @@ export type EnclavesState = { enclave: RemoveFunctions, packageId: string, args: Record, + dryRun?: boolean, + ) => Promise>; + runStarlarkScript: ( + enclave: RemoveFunctions, + script: string, + args: Record, + dryRun?: boolean, ) => Promise>; updateStarlarkFinishedInEnclave: (enclave: RemoveFunctions) => void; }; @@ -168,10 +175,30 @@ export const EnclavesContextProvider = ({ skipInitialLoad, children }: EnclavesC ); const runStarlarkPackage = useCallback( - async (enclave: RemoveFunctions, packageId: string, args: Record) => { + async ( + enclave: RemoveFunctions, + packageId: string, + args: Record, + dryRun: boolean = false, + ) => { + setState((state) => ({ ...state, starlarkRunningInEnclaves: [...state.starlarkRunningInEnclaves, enclave] })); + assertDefined(enclave.apiContainerInfo, `apic info not defined in enclave ${enclave.name}`); + const resp = await kurtosisClient.runStarlarkPackage(enclave.apiContainerInfo, packageId, args, dryRun); + return resp; + }, + [kurtosisClient], + ); + + const runStarlarkScript = useCallback( + async ( + enclave: RemoveFunctions, + script: string, + args: Record, + dryRun: boolean = false, + ) => { setState((state) => ({ ...state, starlarkRunningInEnclaves: [...state.starlarkRunningInEnclaves, enclave] })); assertDefined(enclave.apiContainerInfo, `apic info not defined in enclave ${enclave.name}`); - const resp = await kurtosisClient.runStarlarkPackage(enclave.apiContainerInfo, packageId, args); + const resp = await kurtosisClient.runStarlarkScript(enclave.apiContainerInfo, script, args, dryRun); return resp; }, [kurtosisClient], @@ -217,6 +244,7 @@ export const EnclavesContextProvider = ({ skipInitialLoad, children }: EnclavesC createEnclave, destroyEnclaves, runStarlarkPackage, + runStarlarkScript, updateStarlarkFinishedInEnclave, }} > diff --git a/enclave-manager/web/packages/app/src/emui/enclaves/components/BuildEnclave.tsx b/enclave-manager/web/packages/app/src/emui/enclaves/components/BuildEnclave.tsx new file mode 100644 index 0000000000..1e3dfa0880 --- /dev/null +++ b/enclave-manager/web/packages/app/src/emui/enclaves/components/BuildEnclave.tsx @@ -0,0 +1,31 @@ +import { isDefined } from "kurtosis-ui-components"; +import { useEffect, useState } from "react"; +import { useLocation, useNavigate } from "react-router-dom"; +import { useSettings } from "../../settings"; +import { KURTOSIS_BUILD_ENCLAVE_URL_ARG } from "./configuration/drawer/constants"; +import { EnclaveBuilderModal } from "./modals/EnclaveBuilderModal"; + +export const BuildEnclave = () => { + const { settings } = useSettings(); + const navigate = useNavigate(); + const location = useLocation(); + + const [buildEnclaveOpen, setBuildEnclaveOpen] = useState(false); + + useEffect(() => { + setBuildEnclaveOpen(location.hash === `#${KURTOSIS_BUILD_ENCLAVE_URL_ARG}`); + }, [location]); + + const handleCloseBuildEnclave = () => { + setBuildEnclaveOpen(false); + if (isDefined(location.hash)) { + navigate(`${location.pathname}${location.search}`); + } + }; + + if (!settings.ENABLE_EXPERIMENTAL_BUILD_ENCLAVE) { + return null; + } + + return ; +}; diff --git a/enclave-manager/web/packages/app/src/emui/enclaves/components/EditEnclaveButton.tsx b/enclave-manager/web/packages/app/src/emui/enclaves/components/EditEnclaveButton.tsx index 994a1fea76..d7ae8eacb9 100644 --- a/enclave-manager/web/packages/app/src/emui/enclaves/components/EditEnclaveButton.tsx +++ b/enclave-manager/web/packages/app/src/emui/enclaves/components/EditEnclaveButton.tsx @@ -3,8 +3,11 @@ import { KurtosisPackage } from "kurtosis-cloud-indexer-sdk"; import { isDefined } from "kurtosis-ui-components"; import { useState } from "react"; import { FiEdit2 } from "react-icons/fi"; +import { useSettings } from "../../settings"; import { EnclaveFullInfo } from "../types"; import { CreateOrConfigureEnclaveDrawer } from "./configuration/drawer/CreateOrConfigureEnclaveDrawer"; +import { starlarkScriptContainsEMUIBuildState } from "./modals/enclaveBuilder/utils"; +import { EnclaveBuilderModal } from "./modals/EnclaveBuilderModal"; import { PackageLoadingModal } from "./modals/PackageLoadingModal"; type EditEnclaveButtonProps = ButtonProps & { @@ -12,15 +15,7 @@ type EditEnclaveButtonProps = ButtonProps & { }; export const EditEnclaveButton = ({ enclave, ...buttonProps }: EditEnclaveButtonProps) => { - const [showPackageLoader, setShowPackageLoader] = useState(false); - const [showEnclaveConfiguration, setShowEnclaveConfiguration] = useState(false); - const [kurtosisPackage, setKurtosisPackage] = useState(); - - const handlePackageLoaded = (kurtosisPackage: KurtosisPackage) => { - setShowPackageLoader(false); - setKurtosisPackage(kurtosisPackage); - setShowEnclaveConfiguration(true); - }; + const { settings } = useSettings(); if (!isDefined(enclave.starlarkRun)) { return ( @@ -40,6 +35,32 @@ export const EditEnclaveButton = ({ enclave, ...buttonProps }: EditEnclaveButton ); } + if ( + settings.ENABLE_EXPERIMENTAL_BUILD_ENCLAVE && + starlarkScriptContainsEMUIBuildState(enclave.starlarkRun.value.serializedScript) + ) { + return ; + } + + return ; +}; + +type EditFromPackageButtonProps = ButtonProps & { + enclave: EnclaveFullInfo; + packageId: string; +}; + +const EditFromPackageButton = ({ enclave, packageId, ...buttonProps }: EditFromPackageButtonProps) => { + const [showPackageLoader, setShowPackageLoader] = useState(false); + const [showEnclaveConfiguration, setShowEnclaveConfiguration] = useState(false); + const [kurtosisPackage, setKurtosisPackage] = useState(); + + const handlePackageLoaded = (kurtosisPackage: KurtosisPackage) => { + setShowPackageLoader(false); + setKurtosisPackage(kurtosisPackage); + setShowEnclaveConfiguration(true); + }; + return ( <> - {showPackageLoader && ( - - )} + {showPackageLoader && } { @@ -71,3 +90,35 @@ export const EditEnclaveButton = ({ enclave, ...buttonProps }: EditEnclaveButton ); }; + +type EditFromScriptButtonProps = ButtonProps & { + enclave: EnclaveFullInfo; +}; + +const EditFromScriptButton = ({ enclave, ...buttonProps }: EditFromScriptButtonProps) => { + const [showBuilderModal, setShowBuilderModal] = useState(false); + + return ( + <> + + + + setShowBuilderModal(false)} + existingEnclave={enclave} + /> + + ); +}; diff --git a/enclave-manager/web/packages/app/src/emui/enclaves/components/configuration/KurtosisArgumentTypeInput.tsx b/enclave-manager/web/packages/app/src/emui/enclaves/components/configuration/KurtosisArgumentTypeInput.tsx new file mode 100644 index 0000000000..cc7c8e3fd3 --- /dev/null +++ b/enclave-manager/web/packages/app/src/emui/enclaves/components/configuration/KurtosisArgumentTypeInput.tsx @@ -0,0 +1,97 @@ +import { ArgumentValueType } from "kurtosis-cloud-indexer-sdk"; +import { assertDefined } from "kurtosis-ui-components"; +import { BooleanArgumentInput } from "../form/BooleanArgumentInput"; +import { DictArgumentInput } from "../form/DictArgumentInput"; +import { IntegerArgumentInput } from "../form/IntegerArgumentInput"; +import { JSONArgumentInput } from "../form/JSONArgumentInput"; +import { ListArgumentInput } from "../form/ListArgumentInput"; +import { StringArgumentInput } from "../form/StringArgumentInput"; +import { KurtosisFormInputProps } from "../form/types"; +import { ConfigureEnclaveForm } from "./types"; + +type KurtosisArgumentTypeInputProps = KurtosisFormInputProps & { + type?: ArgumentValueType; + subType1?: ArgumentValueType; + subType2?: ArgumentValueType; +}; + +export const KurtosisArgumentTypeInput = ({ + type, + subType1, + subType2, + name, + placeholder, + isRequired, + validate, + disabled, + width, + size, + tabIndex, +}: KurtosisArgumentTypeInputProps) => { + const childProps: KurtosisFormInputProps = { + name, + placeholder, + isRequired, + validate, + disabled, + width, + size, + tabIndex, + }; + + switch (type) { + case ArgumentValueType.INTEGER: + return ; + case ArgumentValueType.DICT: + assertDefined( + subType1, + `innerType1 was not defined on DICT argument ${name}, check the format used matches https://docs.kurtosis.com/api-reference/starlark-reference/docstring-syntax#types`, + ); + assertDefined( + subType2, + `innerType2 was not defined on DICT argument ${name}, check the format used matches https://docs.kurtosis.com/api-reference/starlark-reference/docstring-syntax#types`, + ); + return ( + ( + + )} + ValueFieldComponent={(props) => ( + + )} + {...childProps} + /> + ); + case ArgumentValueType.LIST: + assertDefined( + subType1, + `innerType1 was not defined on DICT argument ${name}, check the format used matches https://docs.kurtosis.com/api-reference/starlark-reference/docstring-syntax#types`, + ); + return ( + ( + + )} + createNewValue={() => ({ value: "" })} + {...childProps} + /> + ); + case ArgumentValueType.BOOL: + return ; + case ArgumentValueType.STRING: + return ; + case ArgumentValueType.JSON: + default: + return ; + } +}; diff --git a/enclave-manager/web/packages/app/src/emui/enclaves/components/configuration/KurtosisPackageArgumentInput.tsx b/enclave-manager/web/packages/app/src/emui/enclaves/components/configuration/KurtosisPackageArgumentInput.tsx index 8694d79ec9..8a5aac7824 100644 --- a/enclave-manager/web/packages/app/src/emui/enclaves/components/configuration/KurtosisPackageArgumentInput.tsx +++ b/enclave-manager/web/packages/app/src/emui/enclaves/components/configuration/KurtosisPackageArgumentInput.tsx @@ -1,6 +1,6 @@ import { PackageArg } from "kurtosis-cloud-indexer-sdk"; -import { KurtosisArgumentTypeInput } from "./inputs/KurtosisArgumentTypeInput"; -import { KurtosisArgumentFormControl } from "./KurtosisArgumentFormControl"; +import { KurtosisFormControl } from "../form/KurtosisFormControl"; +import { KurtosisArgumentTypeInput } from "./KurtosisArgumentTypeInput"; import { argToTypeString } from "./utils"; type KurtosisPackageArgumentInputProps = { @@ -22,7 +22,7 @@ export const KurtosisPackageArgumentInput = ({ argument, disabled }: KurtosisPac .join(" "); return ( - - + ); }; diff --git a/enclave-manager/web/packages/app/src/emui/enclaves/components/configuration/drawer/bodies/EnclaveConfigureBody.tsx b/enclave-manager/web/packages/app/src/emui/enclaves/components/configuration/drawer/bodies/EnclaveConfigureBody.tsx index ba86b69e3f..b8c8ca78bd 100644 --- a/enclave-manager/web/packages/app/src/emui/enclaves/components/configuration/drawer/bodies/EnclaveConfigureBody.tsx +++ b/enclave-manager/web/packages/app/src/emui/enclaves/components/configuration/drawer/bodies/EnclaveConfigureBody.tsx @@ -38,11 +38,11 @@ import { useNavigate } from "react-router-dom"; import { useKurtosisClient } from "../../../../../../client/enclaveManager/KurtosisClientContext"; import { useEnclavesContext } from "../../../../EnclavesContext"; import { EnclaveFullInfo } from "../../../../types"; +import { BooleanArgumentInput } from "../../../form/BooleanArgumentInput"; +import { KurtosisFormControl } from "../../../form/KurtosisFormControl"; +import { StringArgumentInput } from "../../../form/StringArgumentInput"; import { allowedEnclaveNamePattern, isEnclaveNameAllowed } from "../../../utils"; import { EnclaveConfigurationForm, EnclaveConfigurationFormImperativeAttributes } from "../../EnclaveConfigurationForm"; -import { BooleanArgumentInput } from "../../inputs/BooleanArgumentInput"; -import { StringArgumentInput } from "../../inputs/StringArgumentInput"; -import { KurtosisArgumentFormControl } from "../../KurtosisArgumentFormControl"; import { KurtosisPackageArgumentInput } from "../../KurtosisPackageArgumentInput"; import { ConfigureEnclaveForm } from "../../types"; import { transformKurtosisArgsToFormArgs } from "../../utils"; @@ -272,7 +272,7 @@ export const EnclaveConfigureBody = forwardRef - + - + ({ find: () => searchRef.current?.focus() }), [searchRef])); + useEffect(() => { startCheckSinglePackage(searchTerm); }, [startCheckSinglePackage, searchTerm]); @@ -197,6 +200,16 @@ export const PackageSelectBody = ({ onClick={() => onPackageSelected(kurtosisPackage)} /> ))} + + All Packages + + {searchResults.value.map((kurtosisPackage) => ( + onPackageSelected(kurtosisPackage)} + /> + ))} )} diff --git a/enclave-manager/web/packages/app/src/emui/enclaves/components/configuration/drawer/constants.ts b/enclave-manager/web/packages/app/src/emui/enclaves/components/configuration/drawer/constants.ts index 24c20621ee..f4db1f11bc 100644 --- a/enclave-manager/web/packages/app/src/emui/enclaves/components/configuration/drawer/constants.ts +++ b/enclave-manager/web/packages/app/src/emui/enclaves/components/configuration/drawer/constants.ts @@ -1,3 +1,4 @@ export const KURTOSIS_PACKAGE_PARAMS_URL_ARG = "package-args"; export const KURTOSIS_PACKAGE_ID_URL_ARG = "package-id"; export const KURTOSIS_CREATE_ENCLAVE_URL_ARG = "create-enclave"; +export const KURTOSIS_BUILD_ENCLAVE_URL_ARG = "build-enclave"; diff --git a/enclave-manager/web/packages/app/src/emui/enclaves/components/configuration/inputs/KurtosisArgumentTypeInput.tsx b/enclave-manager/web/packages/app/src/emui/enclaves/components/configuration/inputs/KurtosisArgumentTypeInput.tsx deleted file mode 100644 index cda736a1de..0000000000 --- a/enclave-manager/web/packages/app/src/emui/enclaves/components/configuration/inputs/KurtosisArgumentTypeInput.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import * as CSS from "csstype"; -import { ArgumentValueType } from "kurtosis-cloud-indexer-sdk"; -import { assertDefined } from "kurtosis-ui-components"; -import { FieldPath } from "react-hook-form"; -import { ConfigureEnclaveForm } from "../types"; -import { BooleanArgumentInput } from "./BooleanArgumentInput"; -import { DictArgumentInput } from "./DictArgumentInput"; -import { IntegerArgumentInput } from "./IntegerArgumentInput"; -import { JSONArgumentInput } from "./JSONArgumentInput"; -import { ListArgumentInput } from "./ListArgumentInput"; -import { StringArgumentInput } from "./StringArgumentInput"; - -type KurtosisArgumentTypeInputProps = { - type?: ArgumentValueType; - subType1?: ArgumentValueType; - subType2?: ArgumentValueType; - name: FieldPath; - placeholder?: string; - isRequired?: boolean; - validate?: (value: any) => string | undefined; - disabled?: boolean; - width?: CSS.Property.Width; - size?: string; - tabIndex?: number; -}; - -export type KurtosisArgumentTypeInputImplProps = Omit; - -export const KurtosisArgumentTypeInput = ({ - type, - subType1, - subType2, - name, - placeholder, - isRequired, - validate, - disabled, - width, - size, - tabIndex, -}: KurtosisArgumentTypeInputProps) => { - const childProps: KurtosisArgumentTypeInputImplProps = { - name, - placeholder, - isRequired, - validate, - disabled, - width, - size, - tabIndex, - }; - - switch (type) { - case ArgumentValueType.INTEGER: - return ; - case ArgumentValueType.DICT: - assertDefined( - subType1, - `innerType1 was not defined on DICT argument ${name}, check the format used matches https://docs.kurtosis.com/api-reference/starlark-reference/docstring-syntax#types`, - ); - assertDefined( - subType2, - `innerType2 was not defined on DICT argument ${name}, check the format used matches https://docs.kurtosis.com/api-reference/starlark-reference/docstring-syntax#types`, - ); - return ; - case ArgumentValueType.LIST: - assertDefined( - subType1, - `innerType1 was not defined on DICT argument ${name}, check the format used matches https://docs.kurtosis.com/api-reference/starlark-reference/docstring-syntax#types`, - ); - return ; - case ArgumentValueType.BOOL: - return ; - case ArgumentValueType.STRING: - return ; - case ArgumentValueType.JSON: - default: - return ; - } -}; diff --git a/enclave-manager/web/packages/app/src/emui/enclaves/components/configuration/inputs/BooleanArgumentInput.tsx b/enclave-manager/web/packages/app/src/emui/enclaves/components/form/BooleanArgumentInput.tsx similarity index 68% rename from enclave-manager/web/packages/app/src/emui/enclaves/components/configuration/inputs/BooleanArgumentInput.tsx rename to enclave-manager/web/packages/app/src/emui/enclaves/components/form/BooleanArgumentInput.tsx index b28b6ea7ac..aaeddad9b1 100644 --- a/enclave-manager/web/packages/app/src/emui/enclaves/components/configuration/inputs/BooleanArgumentInput.tsx +++ b/enclave-manager/web/packages/app/src/emui/enclaves/components/form/BooleanArgumentInput.tsx @@ -1,13 +1,17 @@ import { Radio, RadioGroup, Stack, Switch } from "@chakra-ui/react"; -import { useEnclaveConfigurationFormContext } from "../EnclaveConfigurationForm"; -import { KurtosisArgumentTypeInputImplProps } from "./KurtosisArgumentTypeInput"; -type BooleanArgumentInputProps = KurtosisArgumentTypeInputImplProps & { +import { useFormContext } from "react-hook-form"; +import { KurtosisFormInputProps } from "./types"; + +type BooleanArgumentInputProps = KurtosisFormInputProps & { inputType?: "radio" | "switch"; }; -export const BooleanArgumentInput = ({ inputType, ...props }: BooleanArgumentInputProps) => { - const { register, getValues } = useEnclaveConfigurationFormContext(); +export const BooleanArgumentInput = ({ + inputType, + ...props +}: BooleanArgumentInputProps) => { + const { register, getValues } = useFormContext(); const currentDefault = getValues(props.name); @@ -17,7 +21,8 @@ export const BooleanArgumentInput = ({ inputType, ...props }: BooleanArgumentInp {...register(props.name, { disabled: props.disabled, required: props.isRequired, - value: true, + // any required to force this initial value to work. + value: true as any, validate: props.validate, })} /> diff --git a/enclave-manager/web/packages/app/src/emui/enclaves/components/configuration/inputs/DictArgumentInput.tsx b/enclave-manager/web/packages/app/src/emui/enclaves/components/form/DictArgumentInput.tsx similarity index 62% rename from enclave-manager/web/packages/app/src/emui/enclaves/components/configuration/inputs/DictArgumentInput.tsx rename to enclave-manager/web/packages/app/src/emui/enclaves/components/form/DictArgumentInput.tsx index 27bb67f96d..18deb429ce 100644 --- a/enclave-manager/web/packages/app/src/emui/enclaves/components/configuration/inputs/DictArgumentInput.tsx +++ b/enclave-manager/web/packages/app/src/emui/enclaves/components/form/DictArgumentInput.tsx @@ -1,30 +1,30 @@ import { Button, ButtonGroup, Flex, useToast } from "@chakra-ui/react"; -import { ArgumentValueType } from "kurtosis-cloud-indexer-sdk"; import { CopyButton, PasteButton, stringifyError } from "kurtosis-ui-components"; +import { FC } from "react"; import { useFieldArray, useFormContext } from "react-hook-form"; import { FiDelete, FiPlus } from "react-icons/fi"; -import { KurtosisArgumentSubtypeFormControl } from "../KurtosisArgumentFormControl"; -import { ConfigureEnclaveForm } from "../types"; -import { KurtosisArgumentTypeInput, KurtosisArgumentTypeInputImplProps } from "./KurtosisArgumentTypeInput"; +import { KurtosisSubtypeFormControl } from "./KurtosisFormControl"; +import { KurtosisFormInputProps } from "./types"; -type DictArgumentInputProps = KurtosisArgumentTypeInputImplProps & { - keyType: ArgumentValueType; - valueType: ArgumentValueType; +type DictArgumentInputProps = KurtosisFormInputProps & { + KeyFieldComponent: FC>; + ValueFieldComponent: FC>; }; -export const DictArgumentInput = ({ keyType, valueType, ...otherProps }: DictArgumentInputProps) => { +export const DictArgumentInput = ({ + KeyFieldComponent, + ValueFieldComponent, + ...otherProps +}: DictArgumentInputProps) => { const toast = useToast(); - const { getValues, setValue } = useFormContext(); + const { getValues, setValue } = useFormContext(); const { fields, append, remove } = useFieldArray({ name: otherProps.name }); const handleValuePaste = (value: string) => { try { const parsed = JSON.parse(value); - setValue( - otherProps.name, - Object.entries(parsed).map(([key, value]) => ({ key, value })), - ); + setValue(otherProps.name, Object.entries(parsed).map(([key, value]) => ({ key, value })) as any); } catch (err: any) { toast({ title: `Could not read pasted input, was it a json object? Got error: ${stringifyError(err)}`, @@ -40,7 +40,7 @@ export const DictArgumentInput = ({ keyType, valueType, ...otherProps }: DictArg contentName={"value"} valueToCopy={() => JSON.stringify( - getValues(otherProps.name).reduce( + (getValues(otherProps.name) as any[]).reduce( (acc: Record, { key, value }: { key: string; value: any }) => ({ ...acc, [key]: value }), {}, ), @@ -51,34 +51,32 @@ export const DictArgumentInput = ({ keyType, valueType, ...otherProps }: DictArg {fields.map((field, i) => ( - - - - + - - + @@ -86,7 +84,7 @@ export const DictArgumentInput = ({ keyType, valueType, ...otherProps }: DictArg ))} @@ -64,7 +55,7 @@ export const ListArgumentInput = ({ valueType, ...otherProps }: ListArgumentInpu ))} + + + + + + ); +}; + +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, + }; +}; + +type VisualiserImperativeAttributes = { + getStarlark: () => string; +}; + +type VisualiserProps = { + initialNodes: Node[]; + initialEdges: Edge[]; + existingEnclave?: RemoveFunctions; +}; + +const Visualiser = forwardRef( + ({ initialNodes, initialEdges, existingEnclave }, ref) => { + const { data, updateData } = useVariableContext(); + const insertOffset = useRef(0); + const { fitView, addNodes, getViewport } = useReactFlow(); + const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes || []); + const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges || []); + + const nodeTypes = useMemo(() => ({ serviceNode: KurtosisServiceNode, artifactNode: KurtosisArtifactNode }), []); + + 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: "", ports: [], env: [], files: [], isValid: false }); + addNodes({ + id, + position: getNewNodePosition(), + width: 600, + type: "serviceNode", + data: {}, + }); + }; + + const handleAddArtifactNode = () => { + const id = uuidv4(); + updateData(id, { type: "artifact", artifactName: "", files: {}, isValid: false }); + addNodes({ + id, + position: getNewNodePosition(), + width: 600, + type: "artifactNode", + data: {}, + }); + }; + + useEffect(() => { + setEdges((prevState) => { + return Object.entries(getNodeDependencies(data)).flatMap(([to, froms]) => + [...froms].map((from) => ({ + id: `${from}-${to}`, + source: from, + target: to, + animated: true, + style: { strokeWidth: "3px" }, + })), + ); + }); + }, [setEdges, data]); + + useImperativeHandle( + ref, + () => ({ + getStarlark: () => { + return generateStarlarkFromGraph(nodes, edges, data, existingEnclave); + }, + }), + [nodes, edges, data, existingEnclave], + ); + + return ( + + + + + + + + (insertOffset.current = 1)} + onNodesChange={onNodesChange} + onEdgesChange={onEdgesChange} + nodeTypes={nodeTypes} + fitView + > + + + + + + ); + }, +); diff --git a/enclave-manager/web/packages/app/src/emui/enclaves/components/modals/enclaveBuilder/KurtosisArtifactNode.tsx b/enclave-manager/web/packages/app/src/emui/enclaves/components/modals/enclaveBuilder/KurtosisArtifactNode.tsx new file mode 100644 index 0000000000..58c62bdab6 --- /dev/null +++ b/enclave-manager/web/packages/app/src/emui/enclaves/components/modals/enclaveBuilder/KurtosisArtifactNode.tsx @@ -0,0 +1,109 @@ +import { Flex, IconButton, Text } from "@chakra-ui/react"; +import { memo } from "react"; +import { FormProvider, useForm } from "react-hook-form"; +import { FiTrash } from "react-icons/fi"; +import { RxCornerBottomRight } from "react-icons/rx"; +import { Handle, NodeProps, NodeResizeControl, Position, useReactFlow } from "reactflow"; +import { KurtosisFormControl } from "../../form/KurtosisFormControl"; +import { StringArgumentInput } from "../../form/StringArgumentInput"; +import { FileTreeArgumentInput } from "./input/FileTreeArgumentInput"; +import { KurtosisArtifactNodeData, useVariableContext } from "./VariableContextProvider"; + +export const KurtosisArtifactNode = memo( + ({ id, selected }: NodeProps) => { + const { data, updateData, removeData } = useVariableContext(); + const formMethods = useForm({ + defaultValues: (data[id] as KurtosisArtifactNodeData) || {}, + mode: "onBlur", + shouldFocusError: false, + }); + + const { deleteElements } = useReactFlow(); + + const handleDeleteNode = (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + deleteElements({ nodes: [{ id }] }); + removeData(id); + }; + + const handleBlur = async () => { + const isValid = await formMethods.trigger(); + updateData(id, { ...formMethods.getValues(), isValid }); + }; + + return ( + + + + + + + + + + + {(data[id] as KurtosisArtifactNodeData)?.artifactName || Unnamed Artifact} + + } + colorScheme={"red"} + variant={"ghost"} + size={"sm"} + onClick={handleDeleteNode} + /> + + + name={"artifactName"} label={"Artifact Name"}> + + + + + + + + + ); + }, + (oldProps, newProps) => { + return oldProps.id === newProps.id && oldProps.selected === newProps.selected; + }, +); diff --git a/enclave-manager/web/packages/app/src/emui/enclaves/components/modals/enclaveBuilder/KurtosisServiceNode.tsx b/enclave-manager/web/packages/app/src/emui/enclaves/components/modals/enclaveBuilder/KurtosisServiceNode.tsx new file mode 100644 index 0000000000..8f164c2325 --- /dev/null +++ b/enclave-manager/web/packages/app/src/emui/enclaves/components/modals/enclaveBuilder/KurtosisServiceNode.tsx @@ -0,0 +1,241 @@ +import { Flex, Grid, GridItem, IconButton, Text } from "@chakra-ui/react"; +import { memo, useMemo } from "react"; +import { FormProvider, useForm } from "react-hook-form"; +import { FiTrash } from "react-icons/fi"; +import { RxCornerBottomRight } from "react-icons/rx"; +import { Handle, NodeProps, NodeResizeControl, Position, useReactFlow } from "reactflow"; +import { DictArgumentInput } from "../../form/DictArgumentInput"; +import { IntegerArgumentInput } from "../../form/IntegerArgumentInput"; +import { KurtosisFormControl } from "../../form/KurtosisFormControl"; +import { ListArgumentInput } from "../../form/ListArgumentInput"; +import { OptionsArgumentInput } from "../../form/OptionArgumentInput"; +import { SelectArgumentInput, SelectOption } from "../../form/SelectArgumentInput"; +import { StringArgumentInput } from "../../form/StringArgumentInput"; +import { MentionStringArgumentInput } from "./input/MentionStringArgumentInput"; +import { + KurtosisFileMount, + KurtosisPort, + KurtosisServiceNodeData, + useVariableContext, +} from "./VariableContextProvider"; + +export const KurtosisServiceNode = memo( + ({ id, selected }: NodeProps) => { + const { data, updateData, removeData, variables } = useVariableContext(); + const artifactVariableOptions = useMemo((): SelectOption[] => { + return variables + .filter((variable) => variable.id.startsWith("artifact")) + .map((variable) => ({ display: variable.displayName, value: `{{${variable.id}}}` })); + }, [variables]); + const formMethods = useForm({ + defaultValues: (data[id] as KurtosisServiceNodeData) || {}, + mode: "onBlur", + shouldFocusError: false, + }); + + const { deleteElements } = useReactFlow(); + + const handleDeleteNode = (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + deleteElements({ nodes: [{ id }] }); + removeData(id); + }; + + const handleBlur = async () => { + const isValid = await formMethods.trigger(); + updateData(id, { ...formMethods.getValues(), isValid }); + }; + + return ( + + + + + + + + + + + {(data[id] as KurtosisServiceNodeData)?.serviceName || Unnamed Service} + + } + colorScheme={"red"} + variant={"ghost"} + size={"sm"} + onClick={handleDeleteNode} + /> + + + name={"serviceName"} label={"Service Name"} isRequired> + { + if (typeof val !== "string") { + return "Value should be a string"; + } + if (!val.match(/^[a-z]([-a-z0-9]{0,61}[a-z0-9])?$/)) { + return ( + "Service names must adhere to the RFC 1035 standard, specifically implementing this regex and" + + " be 1-63 characters long: ^[a-z]([-a-z0-9]{0,61}[a-z0-9])?$. This means the service name must " + + "only contain lowercase alphanumeric characters or '-', and must start with a lowercase alphabet " + + "and end with a lowercase alphanumeric" + ); + } + }} + /> + + name={"image"} label={"Container Image"} isRequired> + { + if (typeof val !== "string") { + return "Value should be a string"; + } + if ( + !val.match( + /^(?[\w.\-_]+((?::\d+|)(?=\/[a-z0-9._-]+\/[a-z0-9._-]+))|)(?:\/|)(?[a-z0-9.\-_]+(?:\/[a-z0-9.\-_]+|))(:(?[\w.\-_]{1,127})|)$/gim, + ) + ) { + return "Value does not look like a docker image"; + } + }} + /> + + name={"env"} label={"Environment Variables"}> + + name={"env"} + KeyFieldComponent={StringArgumentInput} + ValueFieldComponent={MentionStringArgumentInput} + /> + + name={"ports"} label={"Ports"}> + ( + + + + {...props} + size={"sm"} + placeholder={"Port Name (eg postgres)"} + name={`${props.name as `ports.${number}`}.portName`} + /> + + + + {...props} + size={"sm"} + placeholder={"Application Protocol (eg postgresql)"} + name={`${props.name as `ports.${number}`}.applicationProtocol`} + /> + + + + {...props} + options={["TCP", "UDP"]} + name={`${props.name as `ports.${number}`}.transportProtocol`} + /> + + + + {...props} + name={`${props.name as `ports.${number}`}.port`} + size={"sm"} + /> + + + )} + createNewValue={(): KurtosisPort => ({ + portName: "", + applicationProtocol: "", + transportProtocol: "TCP", + port: 0, + })} + /> + + + name={"files"} + label={"Files"} + helperText={"Choose where to mount artifacts on this services filesystem"} + > + ( + + + + {...props} + size={"sm"} + placeholder={"/some/path"} + name={`${props.name as `files.${number}`}.mountPoint`} + /> + + + + options={artifactVariableOptions} + {...props} + size={"sm"} + placeholder={"Select an Artifact"} + name={`${props.name as `files.${number}`}.artifactName`} + /> + + + )} + createNewValue={(): KurtosisFileMount => ({ + mountPoint: "", + artifactName: "", + })} + /> + + + + + ); + }, + (oldProps, newProps) => { + return oldProps.id === newProps.id && oldProps.selected === newProps.selected; + }, +); diff --git a/enclave-manager/web/packages/app/src/emui/enclaves/components/modals/enclaveBuilder/VariableContextProvider.tsx b/enclave-manager/web/packages/app/src/emui/enclaves/components/modals/enclaveBuilder/VariableContextProvider.tsx new file mode 100644 index 0000000000..d5e3d742cc --- /dev/null +++ b/enclave-manager/web/packages/app/src/emui/enclaves/components/modals/enclaveBuilder/VariableContextProvider.tsx @@ -0,0 +1,78 @@ +import { createContext, PropsWithChildren, useCallback, useContext, useMemo, useState } from "react"; +import { Variable } from "./types"; +import { getVariablesFromNodes } from "./utils"; + +export type KurtosisPort = { + portName: string; + port: number; + transportProtocol: "TCP" | "UDP"; + applicationProtocol: string; +}; + +export type KurtosisFileMount = { + mountPoint: string; + artifactName: string; +}; + +export type KurtosisServiceNodeData = { + type: "service"; + serviceName: string; + image: string; + env: { key: string; value: string }[]; + ports: KurtosisPort[]; + files: KurtosisFileMount[]; + isValid: boolean; +}; + +export type KurtosisArtifactNodeData = { + type: "artifact"; + artifactName: string; + files: Record; + isValid: boolean; +}; + +export type KurtosisNodeData = KurtosisArtifactNodeData | KurtosisServiceNodeData; + +type VariableContextState = { + data: Record; + variables: Variable[]; + updateData: (id: string, data: KurtosisNodeData) => void; + removeData: (id: string) => void; +}; + +const VariableContext = createContext({ + data: {}, + variables: [], + updateData: () => null, + removeData: () => null, +}); + +type VariableContextProviderProps = { + initialData: Record; +}; + +export const VariableContextProvider = ({ initialData, children }: PropsWithChildren) => { + const [data, setData] = useState>(initialData); + + const variables = useMemo((): Variable[] => { + return getVariablesFromNodes(data); + }, [data]); + + const updateData = useCallback((id: string, data: KurtosisNodeData) => { + setData((oldData) => ({ ...oldData, [id]: data })); + }, []); + + const removeData = useCallback((id: string) => { + setData((oldData) => { + const r = { ...oldData }; + delete r[id]; + return r; + }); + }, []); + + return ( + {children} + ); +}; + +export const useVariableContext = () => useContext(VariableContext); diff --git a/enclave-manager/web/packages/app/src/emui/enclaves/components/modals/enclaveBuilder/input/FileTreeArgumentInput.tsx b/enclave-manager/web/packages/app/src/emui/enclaves/components/modals/enclaveBuilder/input/FileTreeArgumentInput.tsx new file mode 100644 index 0000000000..dd9c68a5c4 --- /dev/null +++ b/enclave-manager/web/packages/app/src/emui/enclaves/components/modals/enclaveBuilder/input/FileTreeArgumentInput.tsx @@ -0,0 +1,126 @@ +import { Button, ButtonGroup, Flex } from "@chakra-ui/react"; +import { FileTree, FileTreeNode, isDefined } from "kurtosis-ui-components"; +import { useMemo, useState } from "react"; +import { Controller } from "react-hook-form"; +import { FiPlus } from "react-icons/fi"; +import { KurtosisFormInputProps } from "../../../form/types"; +import { EditFileModal } from "./modals/EditFileModal"; +import { NewFileModal } from "./modals/NewFileModal"; + +type FileTreeArgumentInputProps = KurtosisFormInputProps; + +export const FileTreeArgumentInput = ({ + name, + isRequired, + validate, + disabled, +}: FileTreeArgumentInputProps) => { + return ( + { + return ; + }} + /> + ); +}; + +type FileTreeInputProps = { + files: Record; + onUpdateFiles: (newFiles: Record) => void; +}; + +const FileTreeInput = ({ files, onUpdateFiles }: FileTreeInputProps) => { + const [selectedPath, setSelectedPath] = useState(); + const [showNewFileInputDialog, setShowNewFileInputDialog] = useState(false); + const [editingFilePath, setEditingFilePath] = useState(); + + const fileTree = useMemo((): FileTreeNode[] => { + return ( + Object.entries(files).reduce( + (acc, [fileName, fileContent]) => { + let filePath = fileName.split("/"); + if (filePath[0] === "") { + filePath = filePath.slice(1); + } + let destinationNode = acc; + let i = 0; + while (i < filePath.length - 1) { + const filePart = filePath[i]; + let nextNode = destinationNode.childNodes?.find((node) => node.name === filePart); + if (!isDefined(nextNode)) { + nextNode = { name: filePart, childNodes: [] }; + destinationNode.childNodes?.push(nextNode); + } + destinationNode = nextNode; + i++; + } + destinationNode.childNodes?.push({ + name: filePath[filePath.length - 1], + size: BigInt(fileContent.length), + }); + + return acc; + }, + { name: "/", childNodes: [] } as FileTreeNode, + ).childNodes || [] + ); + }, [files]); + + const handleNewFile = (newFileName: string) => { + setShowNewFileInputDialog(false); + onUpdateFiles({ ...files, [newFileName]: "" }); + }; + + const handleSaveEditedFile = (text: string) => { + if (!isDefined(editingFilePath)) { + return; + } + onUpdateFiles({ ...files, ["/" + editingFilePath.join("/")]: text }); + setEditingFilePath(undefined); + }; + + const handleDeleteSelectedFile = () => { + if (!isDefined(selectedPath)) { + return; + } + const newFiles = { ...files }; + delete newFiles["/" + selectedPath.join("/")]; + onUpdateFiles(newFiles); + }; + + return ( + + + + + + + setShowNewFileInputDialog(false)} + onConfirm={handleNewFile} + /> + setEditingFilePath(undefined)} + filePath={editingFilePath || []} + file={files["/" + editingFilePath?.join("/")]} + onSave={handleSaveEditedFile} + /> + + ); +}; diff --git a/enclave-manager/web/packages/app/src/emui/enclaves/components/modals/enclaveBuilder/input/MentionStringArgumentInput.css b/enclave-manager/web/packages/app/src/emui/enclaves/components/modals/enclaveBuilder/input/MentionStringArgumentInput.css new file mode 100644 index 0000000000..65a156a440 --- /dev/null +++ b/enclave-manager/web/packages/app/src/emui/enclaves/components/modals/enclaveBuilder/input/MentionStringArgumentInput.css @@ -0,0 +1,63 @@ +.mentions { + appearance: none; + height: var(--input-height); + font-size: var(--input-font-size); + border: 1px solid; + border-color: inherit; + border-radius: var(--input-border-radius); + outline: 2px solid transparent; + outline-offset: 2px; + padding-inline-start: var(--input-padding); + padding-inline-end: var(--input-padding); + --input-font-size: var(--chakra-fontSizes-sm); + --input-padding: var(--chakra-space-3); + --input-border-radius: var(--chakra-radii-sm); + --input-height: var(--chakra-sizes-8); + padding-top: 4px; + + &[aria-invalid="true"] { + border-color: var(--chakra-colors-red-300); + box-shadow: 0 0 0 1px var(--chakra-colors-red-300); + } + + &:focus-within { + z-index: 1; + border-color: #63b3ed; + box-shadow: 0 0 0 1px #63b3ed; + } +} + +.mentions--singleLine .mentions__highlighter { +} + +.mentions--singleLine .mentions__input { + padding-inline-start: var(--input-padding); + padding-inline-end: var(--input-padding); + --input-padding: var(--chakra-space-3); + + padding-top: 4px; + + &:focus-visible { + outline: none; + } +} + +.mentions__suggestions__list { + background-color: var(--chakra-colors-gray-800); + border: 1px solid rgba(0, 0, 0, 0.15); + font-size: 10pt; +} + +.mentions__suggestions__item { + padding: 5px 15px; + border-bottom: 1px solid rgba(0, 0, 0, 0.15); +} + +.mentions__suggestions__item--focused { + background-color: var(--chakra-colors-gray-600); +} + +.mentions__mention { + background-color: var(--chakra-colors-blue-500); + border-radius: 2px; +} diff --git a/enclave-manager/web/packages/app/src/emui/enclaves/components/modals/enclaveBuilder/input/MentionStringArgumentInput.tsx b/enclave-manager/web/packages/app/src/emui/enclaves/components/modals/enclaveBuilder/input/MentionStringArgumentInput.tsx new file mode 100644 index 0000000000..7684098336 --- /dev/null +++ b/enclave-manager/web/packages/app/src/emui/enclaves/components/modals/enclaveBuilder/input/MentionStringArgumentInput.tsx @@ -0,0 +1,71 @@ +import { isDefined } from "kurtosis-ui-components"; +import { useCallback } from "react"; +import { Controller } from "react-hook-form"; +import { Mention, MentionsInput } from "react-mentions"; +import { KurtosisFormInputProps } from "../../../form/types"; +import { useVariableContext } from "../VariableContextProvider"; +import "./MentionStringArgumentInput.css"; + +type MentionStringArgumentInputProps = KurtosisFormInputProps; + +export const MentionStringArgumentInput = ({ + name, + placeholder, + isRequired, + validate, + disabled, + width, + size, + tabIndex, +}: MentionStringArgumentInputProps) => { + const { variables } = useVariableContext(); + + const handleQuery = useCallback( + (query?: string) => { + if (!isDefined(query)) { + return []; + } + const suggestions = variables.map((v) => ({ display: v.displayName, id: v.id })); + const queryTerms = query.toLowerCase().split(/\s+|\./); + return suggestions.filter((variable) => queryTerms.every((term) => variable.display.includes(term))); + }, + [variables], + ); + + return ( + { + return ( + field.onChange(newValue)} + > + + variables.find((variable) => variable.id === id)?.displayName || "Missing Variable" + } + /> + + ); + }} + /> + ); +}; diff --git a/enclave-manager/web/packages/app/src/emui/enclaves/components/modals/enclaveBuilder/input/modals/EditFileModal.tsx b/enclave-manager/web/packages/app/src/emui/enclaves/components/modals/enclaveBuilder/input/modals/EditFileModal.tsx new file mode 100644 index 0000000000..65361a7928 --- /dev/null +++ b/enclave-manager/web/packages/app/src/emui/enclaves/components/modals/enclaveBuilder/input/modals/EditFileModal.tsx @@ -0,0 +1,55 @@ +import { + Button, + Flex, + Modal, + ModalBody, + ModalCloseButton, + ModalContent, + ModalFooter, + ModalHeader, + ModalOverlay, +} from "@chakra-ui/react"; +import { CodeEditor, CodeEditorImperativeAttributes } from "kurtosis-ui-components"; +import { useMemo, useRef } from "react"; + +type EditFileModalProps = { + isOpen: boolean; + onClose: () => void; + filePath: string[]; + file: string; + onSave: (newContents: string) => void; +}; +export const EditFileModal = ({ isOpen, onClose, filePath, file, onSave }: EditFileModalProps) => { + const codeEditorRef = useRef(null); + + const fileName = useMemo(() => "/" + filePath.join("/"), [filePath]); + + return ( + + + + + Editing {fileName} + + + + + + + + + + + + + + ); +}; diff --git a/enclave-manager/web/packages/app/src/emui/enclaves/components/modals/enclaveBuilder/input/modals/NewFileModal.tsx b/enclave-manager/web/packages/app/src/emui/enclaves/components/modals/enclaveBuilder/input/modals/NewFileModal.tsx new file mode 100644 index 0000000000..36f71b1c3b --- /dev/null +++ b/enclave-manager/web/packages/app/src/emui/enclaves/components/modals/enclaveBuilder/input/modals/NewFileModal.tsx @@ -0,0 +1,61 @@ +import { + Button, + Flex, + Modal, + ModalBody, + ModalCloseButton, + ModalContent, + ModalFooter, + ModalHeader, + ModalOverlay, +} from "@chakra-ui/react"; +import { FormProvider, useForm } from "react-hook-form"; +import { KurtosisFormControl } from "../../../../form/KurtosisFormControl"; +import { StringArgumentInput } from "../../../../form/StringArgumentInput"; + +type NewFileModalProps = { + isOpen: boolean; + onClose: () => void; + onConfirm: (newFileName: string) => void; +}; +export const NewFileModal = ({ isOpen, onClose, onConfirm }: NewFileModalProps) => { + const formMethods = useForm<{ fileName: string }>({ + defaultValues: { fileName: "" }, + }); + + const onValidSubmit = (data: { fileName: string }) => { + onConfirm(data.fileName); + }; + + return ( + + + + + Create a New File + + + + + + + + + + + + + + + + ); +}; diff --git a/enclave-manager/web/packages/app/src/emui/enclaves/components/modals/enclaveBuilder/types.ts b/enclave-manager/web/packages/app/src/emui/enclaves/components/modals/enclaveBuilder/types.ts new file mode 100644 index 0000000000..e17cd84d48 --- /dev/null +++ b/enclave-manager/web/packages/app/src/emui/enclaves/components/modals/enclaveBuilder/types.ts @@ -0,0 +1,5 @@ +export type Variable = { + id: string; + displayName: string; + value: string; +}; diff --git a/enclave-manager/web/packages/app/src/emui/enclaves/components/modals/enclaveBuilder/utils.ts b/enclave-manager/web/packages/app/src/emui/enclaves/components/modals/enclaveBuilder/utils.ts new file mode 100644 index 0000000000..4ee26fec42 --- /dev/null +++ b/enclave-manager/web/packages/app/src/emui/enclaves/components/modals/enclaveBuilder/utils.ts @@ -0,0 +1,245 @@ +import { isDefined, RemoveFunctions, stringifyError } from "kurtosis-ui-components"; +import { Edge, Node } from "reactflow"; +import { Result } from "true-myth"; +import { EnclaveFullInfo } from "../../../types"; +import { Variable } from "./types"; +import { KurtosisNodeData, KurtosisServiceNodeData } from "./VariableContextProvider"; + +export const EMUI_BUILD_STATE_KEY = "EMUI_BUILD_STATE"; + +export function starlarkScriptContainsEMUIBuildState(script: string) { + return script.includes(EMUI_BUILD_STATE_KEY); +} + +export function getInitialGraphStateFromEnclave( + enclave?: RemoveFunctions, +): Result<{ nodes: Node[]; edges: Edge[]; data: Record }, string> { + if (!isDefined(enclave)) { + return Result.ok({ nodes: [], edges: [], data: {} }); + } + if (!isDefined(enclave.starlarkRun)) { + return Result.err("Enclave has no previous starlark run."); + } + if (enclave.starlarkRun.isErr) { + return Result.err(`Fetching previous starlark run resulted in an error: ${enclave.starlarkRun.error}`); + } + const b64State = enclave.starlarkRun.value.serializedScript + .split("\n") + .find((line) => line.includes(EMUI_BUILD_STATE_KEY)); + if (!isDefined(b64State)) { + return Result.err("Enclave wasn't created with the EMUI enclave builder."); + } + try { + return Result.ok(JSON.parse(atob(b64State.split("=")[1]))); + } catch (error: any) { + return Result.err(`Couldn't parse previous state: ${stringifyError(error)}`); + } +} + +function normaliseNameToStarlarkVariable(name: string) { + return name.replace(/\s|-/g, "_").toLowerCase(); +} + +const variablePattern = /\{\{((?:service|artifact).([^.]+)\.?.*)}}/; +export function getVariablesFromNodes(nodes: Record): Variable[] { + return Object.entries(nodes).flatMap(([id, data]) => + data.type === "service" + ? [ + { + id: `service.${id}.name`, + displayName: `service.${data.serviceName}.name`, + value: `${normaliseNameToStarlarkVariable(data.serviceName)}.name`, + }, + { + id: `service.${id}.hostname`, + displayName: `service.${data.serviceName}.hostname`, + value: `${normaliseNameToStarlarkVariable(data.serviceName)}.hostname`, + }, + ...data.ports.flatMap((port, i) => [ + { + id: `service.${id}.port.${i}`, + displayName: `service.${data.serviceName}.port.${port.portName}`, + value: `"{}://{}:{}".format(${normaliseNameToStarlarkVariable(data.serviceName)}.ports["${ + port.portName + }"].application_protocol, ${normaliseNameToStarlarkVariable( + data.serviceName, + )}.hostname, ${normaliseNameToStarlarkVariable(data.serviceName)}.ports["${port.portName}"].number)`, + }, + { + id: `service.${id}.port.${i}.port`, + displayName: `service.${data.serviceName}.port.${port.portName}.port`, + value: `${normaliseNameToStarlarkVariable(data.serviceName)}.ports["${port.portName}"].number`, + }, + { + id: `service.${id}.port.${i}.applicationProtocol`, + displayName: `service.${data.serviceName}.port.${port.portName}.application_protocol`, + value: `${normaliseNameToStarlarkVariable(data.serviceName)}.ports["${ + port.portName + }"].application_protocol`, + }, + ]), + ...data.env.map((env, i) => ({ + id: `service.${id}.env.${i}`, + displayName: `service.${data.serviceName}.env.${env.key}`, + value: `"${env.value}"`, + })), + ] + : [ + { + id: `artifact.${id}`, + displayName: `artifact.${data.artifactName}`, + value: `${normaliseNameToStarlarkVariable(data.artifactName)}`, + }, + ], + ); +} + +export function getNodeDependencies(nodes: Record): Record> { + const dependencies: Record> = {}; + const getDependenciesFor = (key: string): Set => { + if (!isDefined(dependencies[key])) { + dependencies[key] = new Set(); + } + return dependencies[key]; + }; + Object.entries(nodes).forEach(([id, data]) => { + if (data.type === "service") { + const nameMatches = data.serviceName.match(variablePattern); + if (nameMatches) { + getDependenciesFor(id).add(nameMatches[2]); + } + data.env.forEach((env) => { + const envMatches = env.key.match(variablePattern) || env.value.match(variablePattern); + if (envMatches) { + getDependenciesFor(id).add(envMatches[2]); + } + }); + data.ports.forEach((port) => { + const portMatches = port.portName.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); + if (fileMatches) { + getDependenciesFor(id).add(fileMatches[2]); + } + }); + } + }); + return dependencies; +} + +export function generateStarlarkFromGraph( + nodes: Node[], + edges: Edge[], + data: Record, + existingEnclave?: RemoveFunctions, +): string { + // Topological sort + const sortedNodes: Node[] = []; + let remainingEdges = [...edges]; + while (remainingEdges.length > 0 || sortedNodes.length !== nodes.length) { + const nodesRemoved = nodes + .filter((node) => remainingEdges.every((edge) => edge.target !== node.id)) // eslint-disable-line no-loop-func + .filter((node) => !sortedNodes.includes(node)); + if (nodesRemoved.length === 0) { + throw new Error( + "Topological sort couldn't remove nodes. This indicates a cycle has been detected. Starlark cannot be rendered.", + ); + } + sortedNodes.push(...nodesRemoved); + remainingEdges = remainingEdges.filter((edge) => sortedNodes.every((node) => edge.source !== node.id)); + } + const variablesById = getVariablesFromNodes(data).reduce( + (acc, cur) => ({ ...acc, [cur.id]: cur }), + {} as Record, + ); + const interpolateValue = (input: string): string => { + let formatString = input; + let variableMatches = formatString.match(variablePattern); + if (!isDefined(variableMatches)) { + return `"${formatString}"`; + } + + const references: string[] = []; + while (isDefined(variableMatches)) { + formatString = formatString.replace(variableMatches[0], "{}"); + references.push(variablesById[variableMatches[1]].value); + variableMatches = formatString.match(variablePattern); + } + if (formatString === "{}") { + return references[0]; + } + + return `"${formatString}".format(${references.join(", ")})`; + }; + + let starlark = "def run(plan):\n"; + for (const node of sortedNodes) { + const nodeData = data[node.id]; + if (nodeData.type === "service") { + const serviceName = normaliseNameToStarlarkVariable(nodeData.serviceName); + starlark += ` ${serviceName} = plan.add_service(\n`; + starlark += ` name = ${interpolateValue(nodeData.serviceName)},\n`; + starlark += ` config = ServiceConfig (\n`; + starlark += ` image = ${interpolateValue(nodeData.image)},\n`; + starlark += ` ports = {\n`; + for (const { portName, port, applicationProtocol, transportProtocol } of nodeData.ports) { + starlark += ` ${interpolateValue(portName)}: PortSpec(\n`; + starlark += ` number = ${port},\n`; + starlark += ` transport_protocol = "${transportProtocol}",\n`; + starlark += ` application_protocol = ${interpolateValue(applicationProtocol)},\n`; + starlark += ` ),\n`; + } + starlark += ` },\n`; + starlark += ` env_vars = {\n`; + for (const { key, value } of nodeData.env) { + starlark += ` ${interpolateValue(key)}: ${interpolateValue(value)},\n`; + } + starlark += ` },\n`; + starlark += ` files = {\n`; + for (const { mountPoint, artifactName } of nodeData.files) { + starlark += ` ${interpolateValue(mountPoint)}: ${interpolateValue(artifactName)},\n`; + } + starlark += ` },\n`; + starlark += ` ),\n`; + starlark += ` )\n\n`; + } + + if (nodeData.type === "artifact") { + const artifactName = normaliseNameToStarlarkVariable(nodeData.artifactName); + starlark += ` ${artifactName} = plan.render_templates(\n`; + starlark += ` name = "${nodeData.artifactName}",\n`; + starlark += ` config = {\n`; + for (const [fileName, fileText] of Object.entries(nodeData.files)) { + starlark += ` "${fileName}": struct(\n`; + starlark += ` template="""${fileText}""",\n`; + starlark += ` data={},\n`; + starlark += ` ),\n`; + } + starlark += ` },\n`; + 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 nodeData = data[node.id]; + return nodeData.type !== "service" || nodeData.serviceName !== existingService.name; + }); + if (serviceNoLongerExists) { + starlark += ` plan.remove_service(name = "${existingService.name}")\n`; + } + } + } + + starlark += `\n\n# ${EMUI_BUILD_STATE_KEY}=${btoa(JSON.stringify({ nodes, edges, data }))}`; + + console.log(starlark); + + return starlark; +} diff --git a/enclave-manager/web/packages/app/src/emui/enclaves/components/widgets/CreateEnclaveButton.tsx b/enclave-manager/web/packages/app/src/emui/enclaves/components/widgets/CreateEnclaveButton.tsx index 1b6ceaa4ae..fc2bec4e60 100644 --- a/enclave-manager/web/packages/app/src/emui/enclaves/components/widgets/CreateEnclaveButton.tsx +++ b/enclave-manager/web/packages/app/src/emui/enclaves/components/widgets/CreateEnclaveButton.tsx @@ -1,33 +1,36 @@ -import { Button, Menu, MenuButton, Tooltip } from "@chakra-ui/react"; -import { FiPlus } from "react-icons/fi"; +import { Button, ButtonGroup, Tooltip } from "@chakra-ui/react"; +import { FiPlus, FiTool } from "react-icons/fi"; import { useNavigate } from "react-router-dom"; -import { KURTOSIS_CREATE_ENCLAVE_URL_ARG } from "../configuration/drawer/constants"; +import { useSettings } from "../../../settings"; +import { KURTOSIS_BUILD_ENCLAVE_URL_ARG, KURTOSIS_CREATE_ENCLAVE_URL_ARG } from "../configuration/drawer/constants"; export const CreateEnclaveButton = () => { + const { settings } = useSettings(); const navigate = useNavigate(); return ( - <> - - - } + + {settings.ENABLE_EXPERIMENTAL_BUILD_ENCLAVE && ( + + - {/**/} - {/* navigate(`#${KURTOSIS_CREATE_ENCLAVE_URL_ARG}`)} icon={}>*/} - {/* Manual*/} - {/* */} - {/* navigate("/catalog")} icon={}>*/} - {/* Catalog*/} - {/* */} - {/**/} - - + )} + + + + ); }; diff --git a/enclave-manager/web/packages/app/src/emui/settings.tsx b/enclave-manager/web/packages/app/src/emui/settings.tsx new file mode 100644 index 0000000000..03f88a83c9 --- /dev/null +++ b/enclave-manager/web/packages/app/src/emui/settings.tsx @@ -0,0 +1,66 @@ +import { isDefined, maybeParse } from "kurtosis-ui-components"; +import { createContext, PropsWithChildren, useCallback, useContext, useEffect, useState } from "react"; + +type Settings = { + ENABLE_EXPERIMENTAL_BUILD_ENCLAVE: boolean; + SAVED_PACKAGES: string[]; +}; + +export const settingKeys: { [k in keyof Settings]: k } = { + ENABLE_EXPERIMENTAL_BUILD_ENCLAVE: "ENABLE_EXPERIMENTAL_BUILD_ENCLAVE", + SAVED_PACKAGES: "SAVED_PACKAGES", +} as const; + +const defaultSettings: Settings = { ENABLE_EXPERIMENTAL_BUILD_ENCLAVE: false, SAVED_PACKAGES: [] }; + +const SETTINGS_LOCAL_STORAGE_KEY = "kurtosis-settings"; + +export const storeSettings = (settings: Settings) => { + localStorage.setItem(SETTINGS_LOCAL_STORAGE_KEY, JSON.stringify(settings)); +}; + +export const loadSettings = (): Settings => { + // TODO: Remove once confident all users have migrated from the old settings key + const oldSavedPackages = localStorage.getItem("kurtosis-saved-packages"); + const migratedDefaultSettings = { + ...defaultSettings, + SAVED_PACKAGES: isDefined(oldSavedPackages) + ? maybeParse(oldSavedPackages, defaultSettings.SAVED_PACKAGES) + : defaultSettings.SAVED_PACKAGES, + }; + + const savedRawValue = localStorage.getItem(SETTINGS_LOCAL_STORAGE_KEY); + + if (!isDefined(savedRawValue)) { + return migratedDefaultSettings; + } + + return maybeParse(savedRawValue, migratedDefaultSettings); +}; + +type SettingsContextState = { + settings: Settings; + updateSetting: (setting: S, value: Settings[S]) => void; +}; + +const SettingsContext = createContext(null as any); + +export const SettingsContextProvider = ({ children }: PropsWithChildren) => { + const [settings, setSettings] = useState(defaultSettings); + + const updateSetting = useCallback((setting: S, value: Settings[S]) => { + setSettings((settings) => { + const newSettings = { ...settings, [setting]: value }; + storeSettings(newSettings); + return newSettings; + }); + }, []); + + useEffect(() => { + setSettings(loadSettings()); + }, []); + + return {children}; +}; + +export const useSettings = () => useContext(SettingsContext); diff --git a/enclave-manager/web/packages/components/src/CodeEditor.tsx b/enclave-manager/web/packages/components/src/CodeEditor.tsx index 1b234d3575..3b5147b645 100644 --- a/enclave-manager/web/packages/components/src/CodeEditor.tsx +++ b/enclave-manager/web/packages/components/src/CodeEditor.tsx @@ -10,6 +10,7 @@ const MONACO_READ_ONLY_CHANGE_EVENT_ID = 89; type CodeEditorProps = { text: string; fileName?: string; + isEditable?: boolean; onTextChange?: (newText: string) => void; showLineNumbers?: boolean; }; @@ -17,12 +18,13 @@ type CodeEditorProps = { export type CodeEditorImperativeAttributes = { formatCode: () => Promise; setText: (text: string) => void; + getText: () => string; setLanguage: (language: string) => void; }; export const CodeEditor = forwardRef( - ({ text, fileName, onTextChange, showLineNumbers }, ref) => { - const isReadOnly = !isDefined(onTextChange); + ({ text, fileName, isEditable, onTextChange, showLineNumbers }, ref) => { + const isReadOnly = !isEditable && !isDefined(onTextChange); const [monaco, setMonaco] = useState(); const [editor, setEditor] = useState(); @@ -56,8 +58,8 @@ export const CodeEditor = forwardRef { if (isDefined(value) && onTextChange) { onTextChange(value); - resizeEditorBasedOnContent(); } + resizeEditorBasedOnContent(); }; useImperativeHandle( @@ -111,6 +113,9 @@ export const CodeEditor = forwardRef { + return editor?.getValue() || ""; + }, setLanguage: (language: string) => { if (!isDefined(editor) || !isDefined(monaco)) { return; diff --git a/enclave-manager/web/packages/components/src/FileTree.tsx b/enclave-manager/web/packages/components/src/FileTree.tsx index c67ea182ac..a917160320 100644 --- a/enclave-manager/web/packages/components/src/FileTree.tsx +++ b/enclave-manager/web/packages/components/src/FileTree.tsx @@ -21,11 +21,18 @@ type FileTreeProps = { nodes: FileTreeNode[]; selectedFilePath?: string[]; onFileSelected: (selectedFilePath: string[]) => void; + onFileDblClicked?: (selectedFilePath: string[]) => void; // Internal prop used for padding _isChildNode?: boolean; }; -export const FileTree = ({ nodes, selectedFilePath, onFileSelected, _isChildNode }: FileTreeProps) => { +export const FileTree = ({ + nodes, + selectedFilePath, + onFileSelected, + onFileDblClicked, + _isChildNode, +}: FileTreeProps) => { return ( {nodes.map((node, i) => ( @@ -38,6 +45,7 @@ export const FileTree = ({ nodes, selectedFilePath, onFileSelected, _isChildNode : undefined } onFileSelected={onFileSelected} + onFileDblClicked={onFileDblClicked} /> ))} @@ -48,6 +56,7 @@ type FileTreeNodeComponentProps = { node: FileTreeNode; selectedFilePath?: string[]; onFileSelected: (selectedFilePath: string[]) => void; + onFileDblClicked?: (selectedFilePath: string[]) => void; }; const FileTreeNodeComponent = React.memo((props: FileTreeNodeComponentProps) => { @@ -62,6 +71,7 @@ const DirectoryNode = ({ node, selectedFilePath, onFileSelected, + onFileDblClicked, }: FileTreeNodeComponentProps & { node: { childNodes: FileTreeNode[] } }) => { const [collapsed, setCollapsed] = useState(false); @@ -82,6 +92,13 @@ const DirectoryNode = ({ [onFileSelected, node], ); + const handleFileDblClicked = useMemo(() => { + if (!isDefined(onFileDblClicked)) { + return undefined; + } + return (filePath: string[]) => onFileDblClicked([node.name, ...filePath]); + }, [onFileDblClicked]); + return ( <>