diff --git a/client/package.json b/client/package.json index 083f7e9d75..df41838bdf 100644 --- a/client/package.json +++ b/client/package.json @@ -22,7 +22,8 @@ "storybook-wait-server": "wait-on http://127.0.0.1:6006", "storybook-test": "test-storybook", "storybook-compile-and-test": "concurrently -k -s first -n 'BUILD,TEST' -c 'magenta,blue' 'npm run storybook-build && npm run storybook-start-server' 'npm run storybook-wait-server && npm run storybook-test'", - "generate-api": "npm run generate-api:dataServicesUser && npm run generate-api:namespaceV2 && npm run generate-api:projectV2 && npm run generate-api:platform && npm run generate-api:searchV2 && npm run generate-api:storages", + "generate-api": "npm run generate-api:data-connectors && npm run generate-api:dataServicesUser && npm run generate-api:namespaceV2 && npm run generate-api:projectV2 && npm run generate-api:platform && npm run generate-api:searchV2 && npm run generate-api:storages", + "generate-api:data-connectors": "rtk-query-codegen-openapi src/features/projectsV2/api/data-connectors.api-config.ts", "generate-api:dataServicesUser": "rtk-query-codegen-openapi src/features/user/dataServicesUser.api/dataServicesUser.api-config.ts", "generate-api:namespaceV2": "rtk-query-codegen-openapi src/features/projectsV2/api/namespace.api-config.ts", "generate-api:projectV2": "rtk-query-codegen-openapi src/features/projectsV2/api/projectV2.api-config.ts", diff --git a/client/src/features/ProjectPageV2/ProjectPageContent/DataSources/DataSourceCredentialsModal.tsx b/client/src/features/ProjectPageV2/ProjectPageContent/DataSources/DataSourceCredentialsModal.tsx index a02181ec42..7bffcc26dc 100644 --- a/client/src/features/ProjectPageV2/ProjectPageContent/DataSources/DataSourceCredentialsModal.tsx +++ b/client/src/features/ProjectPageV2/ProjectPageContent/DataSources/DataSourceCredentialsModal.tsx @@ -28,7 +28,7 @@ import { } from "../../../projectsV2/api/projectV2.enhanced-api"; import type { CloudStorageGetRead } from "../../../projectsV2/api/storagesV2.api"; import type { SessionStartCloudStorageConfiguration } from "../../../sessionsV2/startSessionOptionsV2.types"; -import CloudStorageSecretsModal from "../../../sessionsV2/DataConnectorSecretsModal"; +import DataStorageSecretsModal from "../../../sessionsV2/DataStorageSecretsModal"; import useDataSourceConfiguration from "./useDataSourceConfiguration.hook"; import { Loader } from "../../../../components/Loader"; @@ -188,7 +188,7 @@ export default function DataSourceCredentialsModal({ } return ( - - - )} - void; + toggleModal: () => void; +} +function DataConnectorDeleteModal({ + dataConnector, + onDelete, + toggleModal, + isOpen, +}: DataConnectorDeleteModalProps) { + const [deleteDataConnector, { isLoading, isSuccess }] = + useDeleteDataConnectorsByDataConnectorIdMutation(); + + const [typedName, setTypedName] = useState(""); + const onChange = useCallback( + (e: React.ChangeEvent) => { + setTypedName(e.target.value.trim()); + }, + [setTypedName] + ); + + useEffect(() => { + if (isSuccess) { + onDelete(); + } + }, [isSuccess, onDelete]); + const onDeleteDataCollector = () => { + deleteDataConnector({ + dataConnectorId: dataConnector.id, + }); + }; + + return ( + + + Delete data connector + + + + +

+ Are you sure you want to delete this data connector? It will + affect all projects that use it. Please type{" "} + {dataConnector.slug}, the slug of the data + connector, to confirm. +

+ + +
+
+ +
+ + +
+
+
+ ); +} +export default function DataConnectorActions({ + dataConnector, + toggleView, +}: { + dataConnector: DataConnectorRead; + toggleView: () => void; +}) { + const [isCredentialsOpen, setCredentialsOpen] = useState(false); + const [isDeleteOpen, setIsDeleteOpen] = useState(false); + const [isEditOpen, setIsEditOpen] = useState(false); + const onDelete = useCallback(() => { + setIsDeleteOpen(false); + toggleView(); + }, [toggleView]); + const toggleCredentials = useCallback(() => { + setCredentialsOpen((open) => !open); + }, []); + const toggleDelete = useCallback(() => { + setIsDeleteOpen((open) => !open); + }, []); + const toggleEdit = useCallback(() => { + setIsEditOpen((open) => !open); + }, []); + + const defaultAction = ( + + ); + + return ( + <> + + + + Credentials + + + + Remove + + + + + + + ); +} diff --git a/client/src/features/dataConnectorsV2/components/DataConnectorCredentialsModal.tsx b/client/src/features/dataConnectorsV2/components/DataConnectorCredentialsModal.tsx new file mode 100644 index 0000000000..3c578cc7f9 --- /dev/null +++ b/client/src/features/dataConnectorsV2/components/DataConnectorCredentialsModal.tsx @@ -0,0 +1,200 @@ +/*! + * Copyright 2024 - Swiss Data Science Center (SDSC) + * A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and + * Eidgenössische Technische Hochschule Zürich (ETHZ). + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import cx from "classnames"; +import { useCallback, useEffect } from "react"; +import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from "reactstrap"; +import { XLg } from "react-bootstrap-icons"; + +import { RtkErrorAlert } from "../../../components/errors/RtkErrorAlert"; +import { + useDeleteDataConnectorsByDataConnectorIdSecretsMutation, + usePostDataConnectorsByDataConnectorIdSecretsMutation, +} from "../../projectsV2/api/data-connectors.enhanced-api"; +import type { DataConnectorRead } from "../../projectsV2/api/data-connectors.api"; +import DataConnectorSecretsModal from "../../sessionsV2/DataConnectorSecretsModal"; + +import useDataConnectorConfiguration, { + type DataConnectorConfiguration, +} from "./useDataConnectorConfiguration.hook"; +import { Loader } from "../../../components/Loader"; + +interface DataSourceCredentialsModalProps { + isOpen: boolean; + setOpen: (isOpen: boolean) => void; + dataConnector: DataConnectorRead; +} +export default function DataSourceCredentialsModal({ + isOpen, + dataConnector, + setOpen, +}: DataSourceCredentialsModalProps) { + const { dataConnectorConfigs } = useDataConnectorConfiguration({ + dataConnectors: [dataConnector], + }); + + const [saveCredentials, saveCredentialsResult] = + usePostDataConnectorsByDataConnectorIdSecretsMutation(); + const [deleteCredentials, deleteCredentialsResult] = + useDeleteDataConnectorsByDataConnectorIdSecretsMutation(); + + const onSave = useCallback( + (configs: DataConnectorConfiguration[]) => { + const activeConfigs = configs.filter((c) => c.active); + if (activeConfigs.length === 0) { + if (!deleteCredentialsResult.isUninitialized) return; + deleteCredentials({ dataConnectorId: dataConnector.id }); + return; + } + if (!saveCredentialsResult.isUninitialized) return; + const config = configs[0]; + saveCredentials({ + dataConnectorId: dataConnector.id, + cloudStorageSecretPostList: Object.entries( + config.sensitiveFieldValues + ).map(([key, value]) => ({ + name: key, + value, + })), + }); + }, + [ + deleteCredentials, + deleteCredentialsResult, + dataConnector, + saveCredentials, + saveCredentialsResult, + ] + ); + + useEffect(() => { + if (deleteCredentialsResult.isSuccess || saveCredentialsResult.isSuccess) { + setOpen(false); + } + }, [deleteCredentialsResult, saveCredentialsResult.isSuccess, setOpen]); + if (!isOpen) return null; + + if ( + dataConnector.storage.sensitive_fields == null || + dataConnector.storage.sensitive_fields.length === 0 + ) { + return ( + + + No credentials required + + + This data source does not require any credentials. + + + + + + ); + } + + if ( + (!saveCredentialsResult.isUninitialized && + saveCredentialsResult.error != null) || + (!deleteCredentialsResult.isUninitialized && + deleteCredentialsResult.error != null) + ) { + const error = saveCredentialsResult.error || deleteCredentialsResult.error; + return ( + + + Cloud Storage Credentials Update Error + + + + + + + + + ); + } + + if (saveCredentialsResult.isLoading) { + return ( + + + Saving Cloud Storage Credentials + + + + + + ); + } + + if (deleteCredentialsResult.isLoading) { + return ( + + + Clearing Cloud Storage Credentials + + + + + + ); + } + + return ( + setOpen(false)} + onStart={onSave} + /> + ); +} diff --git a/client/src/features/dataConnectorsV2/components/DataConnectorModal/DataConnectorModal.module.scss b/client/src/features/dataConnectorsV2/components/DataConnectorModal/DataConnectorModal.module.scss new file mode 100644 index 0000000000..a70616b0c0 --- /dev/null +++ b/client/src/features/dataConnectorsV2/components/DataConnectorModal/DataConnectorModal.module.scss @@ -0,0 +1,13 @@ +@import "/src/styles/bootstrap/_custom_bootstrap_variables.scss"; + +.modal :global(.modal-content) { + height: unset !important; +} + +.listGroupItemActive { + border: 1px solid $rk-green !important; + color: $rk-green; + &:hover { + color: $rk-green; + } +} diff --git a/client/src/features/dataConnectorsV2/components/DataConnectorModal/DataConnectorModalBody.tsx b/client/src/features/dataConnectorsV2/components/DataConnectorModal/DataConnectorModalBody.tsx new file mode 100644 index 0000000000..88f2226b5a --- /dev/null +++ b/client/src/features/dataConnectorsV2/components/DataConnectorModal/DataConnectorModalBody.tsx @@ -0,0 +1,554 @@ +/*! + * Copyright 2024 - Swiss Data Science Center (SDSC) + * A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and + * Eidgenössische Technische Hochschule Zürich (ETHZ). + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import cx from "classnames"; +import { useCallback } from "react"; +import { Globe, Lock } from "react-bootstrap-icons"; +import { Controller, useForm } from "react-hook-form"; +import { ButtonGroup, Input, Label } from "reactstrap"; + +import { Loader } from "../../../../components/Loader"; +import { RtkOrNotebooksError } from "../../../../components/errors/RtkErrorAlert"; +import { WarnAlert } from "../../../../components/Alert"; +import { slugFromTitle } from "../../../../utils/helpers/HelperFunctions"; + +import { CLOUD_STORAGE_TOTAL_STEPS } from "../../../project/components/cloudStorage/projectCloudStorage.constants"; +import { useGetCloudStorageSchemaQuery } from "../../../project/components/cloudStorage/projectCloudStorage.api"; +import type { + AddCloudStorageState, + CloudStorageDetails, + CloudStorageSchema, +} from "../../../project/components/cloudStorage/projectCloudStorage.types"; +import { getSchemaOptions } from "../../../project/utils/projectCloudStorage.utils"; +import { + AddStorageAdvanced, + AddStorageAdvancedToggle, + AddStorageOptions, + AddStorageType, + type AddStorageStepProps, +} from "../../../project/components/cloudStorage/AddOrEditCloudStorage"; +import { ProjectNamespaceControl } from "../../../projectsV2/fields/ProjectNamespaceFormField"; +import SlugFormField from "../../../projectsV2/fields/SlugFormField"; +import type { CloudStorageSecretGet } from "../../../projectsV2/api/storagesV2.api"; + +import { type DataConnectorFlat } from "../dataConnector.utils"; +import DataConnectorModalResult, { + type CredentialSaveStatus, +} from "./DataConnectorModalResult"; +import DataConnectorSaveCredentialsInfo from "./DataConnectorSaveCredentialsInfo"; + +interface AddOrEditDataConnectorProps { + flatDataConnector: DataConnectorFlat; + schema: CloudStorageSchema[]; + setFlatDataConnector: (newDetails: Partial) => void; + setState: (newState: Partial) => void; + state: AddCloudStorageState; + storageSecrets: CloudStorageSecretGet[]; + validationSucceeded: boolean; +} + +interface DataConnectorModalBodyProps { + dataConnectorResultName: string | undefined; + flatDataConnector: DataConnectorFlat; + credentialSaveStatus: CredentialSaveStatus; + redraw: boolean; + schemaQueryResult: SchemaQueryResult; + setFlatDataConnectorSafe: ( + newDataConnector: Partial + ) => void; + setStateSafe: (newState: Partial) => void; + state: AddCloudStorageState; + success: boolean; + validationSucceeded: boolean; + storageSecrets: CloudStorageSecretGet[]; +} + +type SchemaQueryResult = ReturnType; + +export default function DataConnectorModalBody({ + dataConnectorResultName, + flatDataConnector, + credentialSaveStatus, + redraw, + schemaQueryResult, + setFlatDataConnectorSafe, + setStateSafe, + state, + storageSecrets, + success, + validationSucceeded, +}: DataConnectorModalBodyProps) { + const { + data: schema, + error: schemaError, + isFetching: schemaIsFetching, + } = schemaQueryResult; + if (redraw) return ; + if (success) { + return ( + + ); + } + if (schemaIsFetching || !schema) return ; + if (schemaError) return ; + return ( + <> + {!flatDataConnector.dataConnectorId && ( +

+ Add published datasets from data repositories for use in your project. + Or, connect to cloud storage to read and write custom data. +

+ )} + + + ); +} + +function AddOrEditDataConnector({ + schema, + setFlatDataConnector, + setState, + state, + flatDataConnector, + storageSecrets, + validationSucceeded, +}: AddOrEditDataConnectorProps) { + const setStorage = useCallback( + (newDetails: Partial) => { + setFlatDataConnector({ ...newDetails }); + }, + [setFlatDataConnector] + ); + const CloudStorageContentByStep = + state.step >= 0 && state.step <= CLOUD_STORAGE_TOTAL_STEPS + ? mapCloudStorageStepToElement[state.step] + : null; + if (CloudStorageContentByStep) + return ( + <> +
+ +
+ + + ); + const DataConnectorContentByStep = + state.step >= 0 && state.step <= CLOUD_STORAGE_TOTAL_STEPS + ? mapDataConnectorStepToElement[state.step] + : null; + if (DataConnectorContentByStep) + return ( + <> +
+ +
+ + + ); + return

Error - not implemented yet

; +} + +export interface DataConnectorMountForm { + name: string; + namespace: string; + slug: string; + visibility: string; + mountPoint: string; + readOnly: boolean; + saveCredentials: boolean; +} +type DataConnectorMountFormFields = + | "name" + | "namespace" + | "slug" + | "visibility" + | "mountPoint" + | "readOnly" + | "saveCredentials"; +export function DataConnectorMount({ + schema, + setFlatDataConnector, + setState, + flatDataConnector, + state, + validationSucceeded, +}: AddOrEditDataConnectorProps) { + const { + control, + formState: { errors, touchedFields }, + setValue, + getValues, + } = useForm({ + mode: "onChange", + defaultValues: { + name: flatDataConnector.name || "", + namespace: flatDataConnector.namespace || "", + visibility: flatDataConnector.visibility || "private", + slug: flatDataConnector.slug || "", + mountPoint: + flatDataConnector.mountPoint || + `${flatDataConnector.schema?.toLowerCase()}`, + readOnly: flatDataConnector.readOnly ?? false, + saveCredentials: state.saveCredentials, + }, + }); + const onFieldValueChange = useCallback( + (field: DataConnectorMountFormFields, value: string | boolean) => { + setValue(field, value); + if (field === "name") { + if (!touchedFields.slug && !flatDataConnector.dataConnectorId) + setValue("slug", slugFromTitle(value as string)); + if ( + !touchedFields.mountPoint && + !touchedFields.slug && + !flatDataConnector.dataConnectorId + ) + setValue("mountPoint", slugFromTitle(value as string)); + } + + if ( + field === "slug" && + !touchedFields.mountPoint && + !flatDataConnector.dataConnectorId + ) + setValue("mountPoint", value as string); + if (field === "saveCredentials") { + setState({ saveCredentials: !!value }); + return; + } + setFlatDataConnector({ ...getValues() }); + }, + [ + getValues, + setState, + setFlatDataConnector, + flatDataConnector.dataConnectorId, + setValue, + touchedFields.mountPoint, + touchedFields.slug, + ] + ); + + const options = getSchemaOptions( + schema, + true, + flatDataConnector.schema, + flatDataConnector.provider + ); + const secretFields = + options == null + ? [] + : Object.values(options).filter((o) => o && o.convertedType === "secret"); + const hasPasswordFieldWithInput = secretFields.some( + (o) => flatDataConnector.options && flatDataConnector.options[o.name] + ); + + return ( +
+
Final details
+

We need a few more details to mount your data properly.

+ +
+ + + ( + { + field.onChange(e); + onFieldValueChange("name", e.target.value); + }} + /> + )} + /> +
+ {errors.name?.message?.toString()} +
+
+ This name serves as a brief description for the connector and will + help you identify it. +
+
+ +
+ + + { + const fields: Partial = { ...field }; + delete fields?.ref; + return ( + { + field.onChange(e); + onFieldValueChange("namespace", e?.slug ?? ""); + }} + /> + ); + }} + rules={{ + required: true, + maxLength: 99, + pattern: + /^(?!.*\.git$|.*\.atom$|.*[-._][-._].*)[a-zA-Z0-9][a-zA-Z0-9\-_.]*$/, + }} + /> +
+ {errors.name?.message?.toString()} +
+ {flatDataConnector.namespace && flatDataConnector.slug ? ( +
+ The url for this data source will be{" "} + {`${flatDataConnector.namespace}/${flatDataConnector.slug}`}. +
+ ) : ( +
+ The owner and slug together form the url for this data connector. +
+ )} +
+ + + +
+ + +
+ ( + <> + + { + field.onChange(e); + onFieldValueChange("visibility", e.target.value); + }} + /> + + { + field.onChange(e); + onFieldValueChange("visibility", e.target.value); + }} + /> + + + {field.value === "public" && ( +
+ This data connector is visible to everyone. +
+ )} + {field.value === "private" && ( +
+ This data connector is visible to you and members of + projects to which it is connected. +
+ )} + + )} + /> +
+
+ +
+ + + ( + { + field.onChange(e); + onFieldValueChange("mountPoint", e.target.value); + }} + /> + )} + rules={{ required: true }} + /> +
Please provide a mount point.
+
+ This is the name of the folder where you will find your external + storage in sessions. You should pick something different from the + folders used in the projects repository, and from folders mounted by + other storage services. +
+
+ +
+ + + ( + { + field.onChange(e); + onFieldValueChange("readOnly", e.target.checked); + }} + value="" + checked={flatDataConnector.readOnly ?? false} + /> + )} + rules={{ required: true }} + /> + {!flatDataConnector.readOnly && ( +
+ +

+ You are mounting this storage in read-write mode. If you have + read-only access, please check the box to prevent errors with + some storage types. +

+
+
+ )} +
+ Check this box to mount the storage in read-only mode. You should + always check this if you do not have credentials to write. You can use + this in any case to prevent accidental data modifications. +
+
+ + {flatDataConnector.dataConnectorId == null && + hasPasswordFieldWithInput && + validationSucceeded && ( + + )} + + ); +} + +const mapCloudStorageStepToElement: { + [key: number]: React.ComponentType; +} = { + 0: AddStorageAdvanced, + 1: AddStorageType, + 2: AddStorageOptions, +}; + +const mapDataConnectorStepToElement: { + [key: number]: React.ComponentType; +} = { + 3: DataConnectorMount, +}; diff --git a/client/src/features/dataConnectorsV2/components/DataConnectorModal/DataConnectorModalResult.tsx b/client/src/features/dataConnectorsV2/components/DataConnectorModal/DataConnectorModalResult.tsx new file mode 100644 index 0000000000..2e89efcbd2 --- /dev/null +++ b/client/src/features/dataConnectorsV2/components/DataConnectorModal/DataConnectorModalResult.tsx @@ -0,0 +1,74 @@ +/*! + * Copyright 2024 - Swiss Data Science Center (SDSC) + * A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and + * Eidgenössische Technische Hochschule Zürich (ETHZ). + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { SuccessAlert } from "../../../../components/Alert"; +export type CredentialSaveStatus = "failure" | "none" | "success" | "trying"; + +interface DataConnectorModalResultProps { + alreadyExisted: boolean; + credentialSaveStatus: CredentialSaveStatus; + dataConnectorResultName: string | undefined; +} + +export default function DataConnectorModalResult({ + dataConnectorResultName, + credentialSaveStatus, + alreadyExisted, +}: DataConnectorModalResultProps) { + if (credentialSaveStatus == "trying") + return ( + +

+ The data connector {dataConnectorResultName} has been + successfully {alreadyExisted ? "updated" : "added"}; saving the + credentials... +

+
+ ); + + if (credentialSaveStatus == "success") + return ( + +

+ The data connector {dataConnectorResultName} has been + successfully {alreadyExisted ? "updated" : "added"}, along with its + credentials. +

+
+ ); + if (credentialSaveStatus == "failure") + return ( + +

+ The data connector {dataConnectorResultName} has been + successfully {alreadyExisted ? "updated" : "added"},{" "} + but the credentials were not saved. You can re-enter them and + save by editing the storage. +

+
+ ); + + return ( + +

+ The data connector {dataConnectorResultName} has been + successfully {alreadyExisted ? "updated" : "added"}. +

+
+ ); +} diff --git a/client/src/features/dataConnectorsV2/components/DataConnectorModal/DataConnectorSaveCredentialsInfo.tsx b/client/src/features/dataConnectorsV2/components/DataConnectorModal/DataConnectorSaveCredentialsInfo.tsx new file mode 100644 index 0000000000..d2f9f99dee --- /dev/null +++ b/client/src/features/dataConnectorsV2/components/DataConnectorModal/DataConnectorSaveCredentialsInfo.tsx @@ -0,0 +1,81 @@ +/*! + * Copyright 2023 - Swiss Data Science Center (SDSC) + * A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and + * Eidgenössische Technische Hochschule Zürich (ETHZ). + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import cx from "classnames"; +import { Control, Controller } from "react-hook-form"; +import { Label } from "reactstrap"; + +import { AddCloudStorageState } from "../../../project/components/cloudStorage/projectCloudStorage.types"; +import { WarnAlert } from "../../../../components/Alert"; + +import { DataConnectorMountForm } from "./DataConnectorModalBody"; + +type DataConnectorSaveCredentialsInfoProps = { + control: Control; + onFieldValueChange: (field: "saveCredentials", value: boolean) => void; + state: AddCloudStorageState; +}; + +export default function DataConnectorSaveCredentialsInfo({ + control, + onFieldValueChange, + state, +}: DataConnectorSaveCredentialsInfoProps) { + return ( +
+ + + ( + { + field.onChange(e); + onFieldValueChange("saveCredentials", e.target.checked); + }} + value="" + checked={state.saveCredentials} + /> + )} + rules={{ required: true }} + /> + {state.saveCredentials && ( +
+ +

+ The credentials will be stored as secrets and only be for your + use. Other users will have to supply their credentials to use this + data source. +

+
+
+ )} +
+ Check this box to save credentials as secrets, so you will not have to + provide them again when starting a session. +
+
+ ); +} diff --git a/client/src/features/dataConnectorsV2/components/DataConnectorModal/dataConnectorModalButtons.tsx b/client/src/features/dataConnectorsV2/components/DataConnectorModal/dataConnectorModalButtons.tsx new file mode 100644 index 0000000000..870b2df10a --- /dev/null +++ b/client/src/features/dataConnectorsV2/components/DataConnectorModal/dataConnectorModalButtons.tsx @@ -0,0 +1,345 @@ +/*! + * Copyright 2024 - Swiss Data Science Center (SDSC) + * A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and + * Eidgenössische Technische Hochschule Zürich (ETHZ). + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import cx from "classnames"; +import { + ArrowRepeat, + ChevronLeft, + ChevronRight, + PencilSquare, + PlusLg, + XLg, +} from "react-bootstrap-icons"; +import { Button, UncontrolledTooltip } from "reactstrap"; + +import { SuccessAlert } from "../../../../components/Alert"; +import { Loader } from "../../../../components/Loader"; +import { RtkOrNotebooksError } from "../../../../components/errors/RtkErrorAlert"; +import { useTestCloudStorageConnectionMutation } from "../../../project/components/cloudStorage/projectCloudStorage.api"; +import { CLOUD_STORAGE_TOTAL_STEPS } from "../../../project/components/cloudStorage/projectCloudStorage.constants"; +import { + AddCloudStorageState, + CloudStorageDetails, +} from "../../../project/components/cloudStorage/projectCloudStorage.types"; + +interface DataConnectorModalForwardBackButtonProps { + setStateSafe: (newState: Partial) => void; + state: AddCloudStorageState; + validationResult: ReturnType[1]; +} + +interface DataConnectorModalBackButtonProps + extends DataConnectorModalForwardBackButtonProps { + success: boolean; + toggle: () => void; +} +export function DataConnectorModalBackButton({ + setStateSafe, + state, + success, + toggle, + validationResult, +}: DataConnectorModalBackButtonProps) { + if (state.step <= 1 || success) + return ( + + ); + return ( + + ); +} + +interface DataConnectorModalContinueButtonProps + extends DataConnectorModalForwardBackButtonProps { + addButtonDisableReason: string; + addOrEditStorage: () => void; + disableAddButton: boolean; + disableContinueButton: boolean; + hasStoredCredentialsInConfig: boolean; + isResultLoading: boolean; + setValidationSucceeded: (succeeded: boolean) => void; + storageDetails: CloudStorageDetails; + storageId: string | null; + validateConnection: () => void; +} +export function DataConnectorModalContinueButton({ + addButtonDisableReason, + addOrEditStorage, + disableAddButton, + disableContinueButton, + hasStoredCredentialsInConfig, + isResultLoading, + setStateSafe, + setValidationSucceeded, + state, + storageDetails, + storageId, + validateConnection, + validationResult, +}: DataConnectorModalContinueButtonProps) { + const addButtonId = "add-data-connector-continue"; + const continueButtonId = "add-data-connector-next"; + if (state.step === 3 && state.completedSteps >= 2) { + return ( +
+ + {disableAddButton && ( + + {addButtonDisableReason} + + )} +
+ ); + } + if ( + state.step === 2 && + state.completedSteps >= 1 && + !hasStoredCredentialsInConfig + ) { + return ( +
+ + {disableContinueButton && ( + + {!storageDetails.schema + ? "Please select a storage type" + : "Please select a provider or change storage type"} + + )} +
+ ); + } + return ( +
+ + {disableContinueButton && ( + + {!storageDetails.schema + ? "Please select a storage type" + : "Please select a provider or change storage type"} + + )} +
+ ); +} + +interface DataConnectorConnectionTestResultProps { + validationResult: ReturnType[1]; +} + +export function DataConnectorConnectionTestResult({ + validationResult, +}: DataConnectorConnectionTestResultProps) { + if (validationResult.isUninitialized || validationResult.isLoading) + return null; + if (validationResult.error) + return ( +
+ +
+ ); + return ( +
+ {" "} + +

The connection to the storage works correctly.

+
+
+ ); +} + +interface TestConnectionAndContinueButtonsProps { + actionState: (newState: Partial) => void; + actionTest: () => void; + continueId: string; + resetTest: () => void; + setValidationSucceeded: DataConnectorModalContinueButtonProps["setValidationSucceeded"]; + step: number; + testId: string; + testIsFailure: boolean; + testIsOngoing: boolean; + testIsSuccess: boolean; +} +function TestConnectionAndContinueButtons({ + actionState, + actionTest, + continueId, + resetTest, + setValidationSucceeded, + step, + testId, + testIsFailure, + testIsOngoing, + testIsSuccess, +}: TestConnectionAndContinueButtonsProps) { + const buttonTestId = `${testId}-button`; + const divTestId = `${testId}-div`; + const testConnectionContent = + testIsSuccess || testIsFailure ? ( + <> + Re-test + + ) : testIsOngoing ? ( + <> + Testing connection + + ) : ( + <> + Test connection + + ); + const testConnectionColor = testIsSuccess + ? "outline-primary" + : testIsFailure + ? "danger" + : "outline-primary"; + const testConnectionSection = ( +
+ +
+ ); + + const buttonContinueId = `${continueId}-button`; + const divContinueId = `${continueId}-div`; + const continueContent = testIsSuccess ? ( + <> + Continue + + ) : testIsFailure ? ( + <> + Skip Test + + ) : null; + const continueColorClass = testIsSuccess + ? "btn-primary" + : testIsFailure + ? "btn-outline-danger" + : "btn-primary"; + const continueSection = + !testIsFailure && !testIsSuccess ? null : ( +
+ + {testIsFailure && ( + + The connection is not working as configured. You can make changes + and try again, or skip and continue. + + )} +
+ ); + + return ( +
+ {testConnectionSection} + {continueSection} +
+ ); +} diff --git a/client/src/features/dataConnectorsV2/components/DataConnectorModal/index.tsx b/client/src/features/dataConnectorsV2/components/DataConnectorModal/index.tsx new file mode 100644 index 0000000000..79db65af68 --- /dev/null +++ b/client/src/features/dataConnectorsV2/components/DataConnectorModal/index.tsx @@ -0,0 +1,521 @@ +/*! + * Copyright 2024 - Swiss Data Science Center (SDSC) + * A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and + * Eidgenössische Technische Hochschule Zürich (ETHZ). + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { skipToken } from "@reduxjs/toolkit/query"; +import cx from "classnames"; +import { isEqual } from "lodash-es"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { ArrowCounterclockwise, Database } from "react-bootstrap-icons"; +import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from "reactstrap"; + +import { RtkOrNotebooksError } from "../../../../components/errors/RtkErrorAlert"; + +import AddStorageBreadcrumbNavbar from "../../../project/components/cloudStorage/AddStorageBreadcrumbNavbar"; +import { + useGetCloudStorageSchemaQuery, + useTestCloudStorageConnectionMutation, +} from "../../../project/components/cloudStorage/projectCloudStorage.api"; +import { + CLOUD_STORAGE_TOTAL_STEPS, + EMPTY_CLOUD_STORAGE_STATE, +} from "../../../project/components/cloudStorage/projectCloudStorage.constants"; +import { + AddCloudStorageState, + CloudStorageDetailsOptions, + TestCloudStorageConnectionParams, +} from "../../../project/components/cloudStorage/projectCloudStorage.types"; + +import { + findSensitive, + getSchemaProviders, + hasProviderShortlist, +} from "../../../project/utils/projectCloudStorage.utils"; + +import { usePostStoragesV2ByStorageIdSecretsMutation } from "../../../projectsV2/api/projectV2.enhanced-api"; +import { + usePatchDataConnectorsByDataConnectorIdMutation, + usePostDataConnectorsMutation, +} from "../../../projectsV2/api/data-connectors.enhanced-api"; +import type { DataConnectorRead } from "../../../projectsV2/api/data-connectors.api"; + +import styles from "./DataConnectorModal.module.scss"; + +import { + DataConnectorModalBackButton, + DataConnectorModalContinueButton, + DataConnectorConnectionTestResult, +} from "./dataConnectorModalButtons"; +import DataConnectorModalBody from "./DataConnectorModalBody"; +import type { CredentialSaveStatus } from "./DataConnectorModalResult"; +import { + dataConnectorPostFromFlattened, + dataConnectorToFlattened, + EMPTY_DATA_CONNECTOR_FLAT, + type DataConnectorFlat, +} from "../dataConnector.utils"; + +interface DataConnectorModalProps { + dataConnector?: DataConnectorRead | null; + isOpen: boolean; + namespace: string; + toggle: () => void; +} +export default function DataConnectorModal({ + dataConnector = null, + isOpen, + namespace, + toggle: originalToggle, +}: DataConnectorModalProps) { + const dataConnectorId = dataConnector?.id ?? null; + // Fetch available schema when users open the modal + const schemaQueryResult = useGetCloudStorageSchemaQuery( + isOpen ? undefined : skipToken + ); + const { data: schema } = schemaQueryResult; + + // Reset state on props change + useEffect(() => { + const flattened = dataConnectorToFlattened(dataConnector); + const cloudStorageState: AddCloudStorageState = + dataConnector != null + ? { + ...EMPTY_CLOUD_STORAGE_STATE, + step: 2, + completedSteps: CLOUD_STORAGE_TOTAL_STEPS, + } + : EMPTY_CLOUD_STORAGE_STATE; + setFlatDataConnector(flattened); + setState(cloudStorageState); + }, [dataConnector]); + + const [success, setSuccess] = useState(false); + const [credentialSaveStatus, setCredentialSaveStatus] = + useState("none"); + const [validationSucceeded, setValidationSucceeded] = useState(false); + const [state, setState] = useState( + EMPTY_CLOUD_STORAGE_STATE + ); + const initialFlatDataConnector = EMPTY_DATA_CONNECTOR_FLAT; + initialFlatDataConnector.namespace = namespace; + const [flatDataConnector, setFlatDataConnector] = useState( + initialFlatDataConnector + ); + + // Enhanced setters + const setStateSafe = useCallback( + (newState: Partial) => { + const fullNewState = { + ...state, + ...newState, + }; + if (isEqual(fullNewState, state)) { + return; + } + + // Handle advanced mode changes + if ( + fullNewState.advancedMode !== state.advancedMode && + fullNewState.step !== 3 + ) { + if (fullNewState.advancedMode) { + fullNewState.step = 0; + } else { + if ( + // schema and provider (where necessary) must also exist in the list + !flatDataConnector.schema || + !schema?.find((s) => s.prefix === flatDataConnector.schema) || + (hasProviderShortlist(flatDataConnector.schema) && + (!flatDataConnector.provider || + !getSchemaProviders( + schema, + false, + flatDataConnector.schema + )?.find((p) => p.name === flatDataConnector.provider))) + ) { + fullNewState.step = 1; + } else { + fullNewState.step = 2; + } + } + } + setState(fullNewState); + }, + [state, flatDataConnector, schema] + ); + + // Reset + const [redraw, setRedraw] = useState(false); + useEffect(() => { + if (redraw) setRedraw(false); + }, [redraw]); + + // Mutations + const [createDataConnector, createResult] = usePostDataConnectorsMutation(); + const [updateDataConnector, updateResult] = + usePatchDataConnectorsByDataConnectorIdMutation(); + const [saveCredentials, saveCredentialsResult] = + usePostStoragesV2ByStorageIdSecretsMutation(); + const [validateCloudStorageConnection, validationResult] = + useTestCloudStorageConnectionMutation(); + + const reset = useCallback(() => { + const resetStatus = dataConnectorToFlattened(dataConnector); + setState((prevState) => + dataConnector != null + ? { + ...EMPTY_CLOUD_STORAGE_STATE, + step: prevState.step, + completedSteps: prevState.completedSteps, + } + : { + ...EMPTY_CLOUD_STORAGE_STATE, + } + ); + createResult.reset(); + validationResult.reset(); + setFlatDataConnector(resetStatus); + setSuccess(false); + setCredentialSaveStatus("none"); + setValidationSucceeded(false); + setRedraw(true); // This forces re-loading the useForm fields + }, [createResult, dataConnector, validationResult]); + + const setFlatDataConnectorSafe = useCallback( + (newDataConnector: Partial) => { + const fullNewDetails = { + ...flatDataConnector, + ...newDataConnector, + }; + if (isEqual(fullNewDetails, flatDataConnector)) { + return; + } + // reset follow-up properties: schema > provider > options + if (fullNewDetails.schema !== flatDataConnector.schema) { + fullNewDetails.provider = undefined; + fullNewDetails.options = undefined; + fullNewDetails.sourcePath = undefined; + } else if (fullNewDetails.provider !== flatDataConnector.provider) { + fullNewDetails.options = undefined; + fullNewDetails.sourcePath = undefined; + } + if (!validationResult.isUninitialized) validationResult.reset(); + setFlatDataConnector(fullNewDetails); + }, + [flatDataConnector, validationResult] + ); + + const validateConnection = useCallback(() => { + const validateParameters: TestCloudStorageConnectionParams = { + configuration: { + type: flatDataConnector.schema, + }, + source_path: flatDataConnector.sourcePath ?? "/", + }; + if (flatDataConnector.provider) { + validateParameters.configuration.provider = flatDataConnector.provider; + } + if ( + flatDataConnector.options && + Object.keys(flatDataConnector.options).length > 0 + ) { + const options = flatDataConnector.options as CloudStorageDetailsOptions; + Object.entries(options).forEach(([key, value]) => { + if (value != undefined && value !== "") { + validateParameters.configuration[key] = value; + } + }); + } + + validateCloudStorageConnection(validateParameters); + }, [flatDataConnector, validateCloudStorageConnection]); + + const addOrEditStorage = useCallback(() => { + const dataConnectorPost = dataConnectorPostFromFlattened( + flatDataConnector, + schema ?? [], + dataConnector + ); + + // We manually set success only when we get an ID back. That's just to show a success message + if (dataConnector && dataConnectorId) { + updateDataConnector({ + dataConnectorId, + dataConnectorPatch: dataConnectorPost, + "If-Match": dataConnector.etag, + }).then((result) => { + if ("data" in result && result.data.id) { + setSuccess(true); + } + }); + } else { + createDataConnector({ + dataConnectorPost, + }).then((result) => { + if ("data" in result && result.data.id) { + setSuccess(true); + } + }); + } + }, [ + createDataConnector, + dataConnector, + dataConnectorId, + updateDataConnector, + schema, + flatDataConnector, + ]); + + const toggle = useCallback(() => { + originalToggle(); + setCredentialSaveStatus("none"); + setValidationSucceeded(false); + if (success) { + setSuccess(false); + reset(); + } else { + createResult.reset(); + validationResult.reset(); + } + }, [createResult, originalToggle, reset, success, validationResult]); + + // Handle unmount + useEffect(() => { + const cleanup = () => { + reset(); + }; + + return cleanup; + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + const schemaRequiresProvider = useMemo( + () => hasProviderShortlist(flatDataConnector.schema), + [flatDataConnector.schema] + ); + + useEffect(() => { + const dataConnectorId = createResult.data?.id; + if (dataConnectorId == null) return; + const shouldSaveCredentials = shouldSaveDataConnectorCredentials( + flatDataConnector.options, + state.saveCredentials, + validationSucceeded + ); + if (!shouldSaveCredentials) { + return; + } + const options = flatDataConnector.options as CloudStorageDetailsOptions; + if (!schema) return; + const sensitiveFieldNames = findSensitive( + schema.find((s) => s.prefix === flatDataConnector.schema) + ); + const cloudStorageSecretPostList = sensitiveFieldNames + .map((name) => ({ + name, + value: options[name], + })) + .filter((secret) => secret.value != undefined && secret.value != "") + .map((secret) => ({ + name: secret.name, + value: "" + secret.value, + })); + saveCredentials({ + storageId: dataConnectorId, + cloudStorageSecretPostList, + }); + }, [ + createResult.data?.id, + saveCredentials, + state.saveCredentials, + schema, + flatDataConnector.options, + flatDataConnector.schema, + validationSucceeded, + ]); + + useEffect(() => { + const status = !validationSucceeded + ? "none" + : createResult.data?.id == null || saveCredentialsResult.isUninitialized + ? "none" + : saveCredentialsResult.isLoading + ? "trying" + : saveCredentialsResult.isSuccess + ? "success" + : saveCredentialsResult.isError + ? "failure" + : "none"; + setCredentialSaveStatus(status); + }, [createResult, saveCredentialsResult, validationSucceeded]); + + // Visual elements + const disableContinueButton = + state.step === 1 && + (!flatDataConnector.schema || + (schemaRequiresProvider && !flatDataConnector.provider)); + + const isAddResultLoading = createResult.isLoading; + const isModifyResultLoading = updateResult.isLoading; + const actionError = createResult.error || updateResult.error; + const dataConnectorResultNamespace = + createResult?.data?.namespace || updateResult?.data?.namespace; + const dataConnectorResultSlug = + createResult?.data?.slug || updateResult?.data?.slug; + const dataConnectorResultName = `${dataConnectorResultNamespace}/${dataConnectorResultSlug}`; + + const disableAddButton = + isAddResultLoading || + isModifyResultLoading || + !flatDataConnector.name || + !flatDataConnector.mountPoint || + !flatDataConnector.schema || + (hasProviderShortlist(flatDataConnector.schema) && + !flatDataConnector.provider); + const addButtonDisableReason = isAddResultLoading + ? "Please wait, the storage is being added" + : updateResult.isLoading + ? "Please wait, the storage is being modified" + : !flatDataConnector.name + ? "Please provide a name" + : !flatDataConnector.mountPoint + ? "Please provide a mount point" + : !flatDataConnector.schema + ? "Please go back and select a storage type" + : "Please go back and select a provider"; + const isResultLoading = isAddResultLoading || isModifyResultLoading; + + const storageSecrets = + dataConnector != null && "secrets" in dataConnector + ? dataConnector.secrets ?? [] + : []; + const hasStoredCredentialsInConfig = storageSecrets.length > 0; + + return ( + + + + + + + + + + + + {actionError && ( +
+ +
+ )} +
+ +
+ {!isResultLoading && !success && ( + + )} + {!isResultLoading && ( + + )} + {!success && ( + + )} +
+
+ ); +} + +interface DataConnectorModalHeaderProps { + dataConnectorId: string | null; +} +export function DataConnectorModalHeader({ + dataConnectorId, +}: DataConnectorModalHeaderProps) { + return ( + <> + {" "} + {dataConnectorId ? "Edit" : "Add"} data connector + + ); +} + +function shouldSaveDataConnectorCredentials( + flatDataConnectorOptions: CloudStorageDetailsOptions | undefined, + stateSaveCredentials: boolean, + validationSucceeded: boolean +) { + return !!( + flatDataConnectorOptions && + stateSaveCredentials && + validationSucceeded + ); +} diff --git a/client/src/features/dataConnectorsV2/components/DataConnectorView.tsx b/client/src/features/dataConnectorsV2/components/DataConnectorView.tsx new file mode 100644 index 0000000000..10158aa808 --- /dev/null +++ b/client/src/features/dataConnectorsV2/components/DataConnectorView.tsx @@ -0,0 +1,172 @@ +/*! + * Copyright 2024 - Swiss Data Science Center (SDSC) + * A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and + * Eidgenössische Technische Hochschule Zürich (ETHZ). + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import cx from "classnames"; +import { useMemo } from "react"; +import { Offcanvas, OffcanvasBody } from "reactstrap"; + +import { CredentialMoreInfo } from "../../project/components/cloudStorage/CloudStorageItem"; +import { CLOUD_STORAGE_SAVED_SECRET_DISPLAY_VALUE } from "../../project/components/cloudStorage/projectCloudStorage.constants"; +import { getCredentialFieldDefinitions } from "../../project/utils/projectCloudStorage.utils"; +import type { DataConnectorRead } from "../../projectsV2/api/data-connectors.api"; +import { storageSecretNameToFieldName } from "../../secrets/secrets.utils"; +import DataConnectorActions from "./DataConnectorActions"; + +interface DataConnectorViewProps { + dataConnector: DataConnectorRead; + showView: boolean; + toggleView: () => void; +} +export default function DataConnectorView({ + dataConnector, + showView, + toggleView, +}: DataConnectorViewProps) { + const storageDefinition = dataConnector.storage; + const sensitiveFields = storageDefinition.sensitive_fields + ? storageDefinition.sensitive_fields?.map((f) => f.name) + : []; + const anySensitiveField = Object.keys(storageDefinition.configuration).some( + (key) => sensitiveFields.includes(key) + ); + const storageSecrets = dataConnector.secrets; + const savedCredentialFields = + storageSecrets?.reduce((acc: Record, s) => { + acc[storageSecretNameToFieldName(s)] = s.name; + return acc; + }, {}) ?? {}; + const credentialFieldDefinitions = useMemo( + () => getCredentialFieldDefinitions(dataConnector), + [dataConnector] + ); + const requiredCredentials = useMemo( + () => + credentialFieldDefinitions?.filter((field) => field.requiredCredential), + [credentialFieldDefinitions] + ); + const nonRequiredCredentialConfigurationKeys = Object.keys( + storageDefinition.configuration + ).filter((k) => !requiredCredentials?.some((f) => f.name === k)); + + return ( + + +
+ +
+ +
+
+

+ {dataConnector.name} +

+
+ +
+
+ +

Data source

+
+
+
+

+ Mount point {"("}this is where the storage will be mounted during + sessions{")"} +

+

{storageDefinition.target_path}

+
+ {nonRequiredCredentialConfigurationKeys.map((key) => { + const value = storageDefinition.configuration[key]?.toString(); + return ( +
+

{key}

+

{value}

+
+ ); + })} +
+
+

Source path

+
+
{storageDefinition.source_path}
+
+
+

Requires credentials

+

{anySensitiveField ? "Yes" : "No"}

+
+ {anySensitiveField && + requiredCredentials && + requiredCredentials.length > 0 && ( +
+

Required credentials

+ + + + + + + + + {requiredCredentials.map(({ name, help }, index) => { + const value = + name == null + ? "unknown" + : savedCredentialFields[name] + ? CLOUD_STORAGE_SAVED_SECRET_DISPLAY_VALUE + : storageDefinition.configuration[name]?.toString(); + return ( + + + + + ); + })} + +
FieldValue
+ {name} + {help && } + {value}
+
+ )} +
+

Access mode

+

+ {storageDefinition.readonly + ? "Force Read-only" + : "Allow Read-Write (requires adequate privileges on the storage)"} +

+
+
+
+
+ ); +} diff --git a/client/src/features/dataConnectorsV2/components/DataConnectorsBox.tsx b/client/src/features/dataConnectorsV2/components/DataConnectorsBox.tsx new file mode 100644 index 0000000000..992df2e71f --- /dev/null +++ b/client/src/features/dataConnectorsV2/components/DataConnectorsBox.tsx @@ -0,0 +1,395 @@ +/*! + * Copyright 2024 - Swiss Data Science Center (SDSC) + * A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and + * Eidgenössische Technische Hochschule Zürich (ETHZ). + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +import cx from "classnames"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Database, Globe2, Lock, PlusLg } from "react-bootstrap-icons"; +import { useSearchParams } from "react-router-dom-v5-compat"; +import { + Badge, + Button, + Card, + CardBody, + CardHeader, + Col, + ListGroup, + ListGroupItem, + Row, +} from "reactstrap"; + +import ClampedParagraph from "../../../components/clamped/ClampedParagraph"; +import { Loader } from "../../../components/Loader"; +import Pagination from "../../../components/Pagination"; +import { TimeCaption } from "../../../components/TimeCaption"; +import { RtkOrNotebooksError } from "../../../components/errors/RtkErrorAlert"; +import MembershipGuard from "../../ProjectPageV2/utils/MembershipGuard"; +import type { NamespaceKind } from "../../projectsV2/api/namespace.api"; +import type { DataConnector } from "../../projectsV2/api/data-connectors.api"; +import { useGetGroupsByGroupSlugMembersQuery } from "../../projectsV2/api/projectV2.enhanced-api"; +import { + useGetDataConnectorsQuery, + type GetDataConnectorsApiResponse, +} from "../../projectsV2/api/data-connectors.enhanced-api"; +import { useGetUserQuery } from "../../user/dataServicesUser.api"; + +import DataConnectorModal from "./DataConnectorModal"; +import DataConnectorView from "./DataConnectorView"; + +const DEFAULT_PER_PAGE = 12; +const DEFAULT_PAGE_PARAM = "page"; + +function AddButtonForGroupNamespace({ + namespace, + toggleOpen, +}: Pick) { + const { data: members } = useGetGroupsByGroupSlugMembersQuery({ + groupSlug: namespace, + }); + return ( + + + + } + members={members} + minimumRole="editor" + /> + ); +} + +function AddButtonForUserNamespace({ + namespace, + toggleOpen, +}: Pick) { + const { data: currentUser } = useGetUserQuery(); + + if (currentUser?.username === namespace) { + return ( + + ); + } + return null; +} + +interface DataConnectorListDisplayProps { + namespace: string; + namespaceKind: NamespaceKind; + pageParam?: string; + perPage?: number; +} + +export default function DataConnectorsBox({ + namespace: ns, + namespaceKind, + pageParam: pageParam_, + perPage: perPage_, +}: DataConnectorListDisplayProps) { + const pageParam = useMemo( + () => (pageParam_ ? pageParam_ : DEFAULT_PAGE_PARAM), + [pageParam_] + ); + const perPage = useMemo( + () => (perPage_ ? perPage_ : DEFAULT_PER_PAGE), + [perPage_] + ); + + const [searchParams, setSearchParams] = useSearchParams(); + const onPageChange = useCallback( + (pageNumber: number) => { + setSearchParams((prevParams) => { + if (pageNumber == 1) { + prevParams.delete(pageParam); + } else { + prevParams.set(pageParam, `${pageNumber}`); + } + return prevParams; + }); + }, + [pageParam, setSearchParams] + ); + + const page = useMemo(() => { + const pageRaw = searchParams.get(pageParam); + if (!pageRaw) { + return 1; + } + try { + const page = parseInt(pageRaw, 10); + return page > 0 ? page : 1; + } catch { + return 1; + } + }, [pageParam, searchParams]); + + const { data, error, isLoading } = useGetDataConnectorsQuery({ + params: { + namespace: ns, + page, + per_page: perPage, + }, + }); + + useEffect(() => { + if (data?.totalPages && page > data.totalPages) { + setSearchParams( + (prevParams) => { + if (data.totalPages == 1) { + prevParams.delete(pageParam); + } else { + prevParams.set(pageParam, `${data.totalPages}`); + } + return prevParams; + }, + { replace: true } + ); + } + }, [data?.totalPages, page, pageParam, setSearchParams]); + + if (isLoading) return ; + + if (error || data == null) { + return ; + } + + return ( + + ); +} + +interface DataConnectorBoxContentProps { + data: GetDataConnectorsApiResponse; + namespace: string; + namespaceKind: NamespaceKind; + onPageChange: (pageNumber: number) => void; + perPage: number; +} +function DataConnectorBoxContent({ + data, + namespace, + namespaceKind, + onPageChange, + perPage, +}: DataConnectorBoxContentProps) { + const [isModalOpen, setModalOpen] = useState(false); + const toggleOpen = useCallback(() => { + setModalOpen((open) => !open); + }, []); + return ( +
+ + + + {data.total === 0 && ( +

+ Add published datasets from data repositories, and connect to + cloud storage to read and write custom data. +

+ )} + + {data.dataConnectors?.map((dc) => ( + + ))} + + +
+
+ +
+ ); +} + +interface DataConnectorBoxHeaderProps { + toggleOpen: () => void; + namespace: string; + namespaceKind: NamespaceKind; + totalConnectors: number; +} + +function DataConnectorBoxHeader({ + toggleOpen, + namespace, + namespaceKind, + totalConnectors, +}: DataConnectorBoxHeaderProps) { + return ( + +
+
+

+ + Data +

+ {totalConnectors} +
+
+ {namespaceKind === "group" ? ( + + ) : ( + + )} +
+
+
+ ); +} + +function DataConnectorLoadingBoxContent() { + return ( + + +
+
+

+ + Data +

+
+
+
+ + +
Retrieving data connectors...
+
+
+ ); +} + +interface DataConnectorDisplayProps { + dataConnector: DataConnector; +} +function DataConnectorDisplay({ dataConnector }: DataConnectorDisplayProps) { + const { + name, + description, + visibility, + creation_date: creationDate, + } = dataConnector; + + const [showDetails, setShowDetails] = useState(false); + const toggleDetails = useCallback(() => { + setShowDetails((open) => !open); + }, []); + + return ( + <> + + + + {name} + {description && {description}} +
+
+ {visibility.toLowerCase() === "private" ? ( + <> + + Private + + ) : ( + <> + + Public + + )} +
+ +
+ +
+
+ + + ); +} diff --git a/client/src/features/dataConnectorsV2/components/dataConnector.utils.ts b/client/src/features/dataConnectorsV2/components/dataConnector.utils.ts new file mode 100644 index 0000000000..7eeadc8e78 --- /dev/null +++ b/client/src/features/dataConnectorsV2/components/dataConnector.utils.ts @@ -0,0 +1,207 @@ +/*! + * Copyright 2024 - Swiss Data Science Center (SDSC) + * A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and + * Eidgenössische Technische Hochschule Zürich (ETHZ). + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { CLOUD_STORAGE_SENSITIVE_FIELD_TOKEN } from "../../project/components/cloudStorage/projectCloudStorage.constants"; +import type { + CloudStorageDetailsOptions, + CloudStorageSchema, + TestCloudStorageConnectionParams, +} from "../../project/components/cloudStorage/projectCloudStorage.types"; + +import { findSensitive } from "../../project/utils/projectCloudStorage.utils"; + +import type { + CloudStorageCorePost, + DataConnectorPost, + DataConnectorRead, +} from "../../projectsV2/api/data-connectors.api"; +import type { DataConnectorConfiguration } from "./useDataConnectorConfiguration.hook"; + +// This contains the information in a DataConnector, but it is flattened +// to be closer to the old CloudStorageDetails structure +export type DataConnectorFlat = { + // DataConnectorRead metadata fields + dataConnectorId?: string; + name?: string; + namespace?: string; + slug?: string; + visibility?: string; + + // DataConnector storage fields + mountPoint?: string; + options?: CloudStorageDetailsOptions; + provider?: string; + readOnly?: boolean; + schema?: string; + sourcePath?: string; +}; + +type DataConnectorOptions = Record< + string, + string | number | boolean | object | null +>; + +export const EMPTY_DATA_CONNECTOR_FLAT: DataConnectorFlat = { + name: undefined, + namespace: undefined, + slug: undefined, + visibility: "private", + + mountPoint: undefined, + options: undefined, + provider: undefined, + readOnly: true, + schema: undefined, + sourcePath: undefined, +}; + +export function dataConnectorPostFromFlattened( + flatDataConnector: DataConnectorFlat, + schemata: CloudStorageSchema[], + dataConnector: DataConnectorRead | null +): DataConnectorPost { + const meta = { + name: flatDataConnector.name as string, + namespace: flatDataConnector.namespace as string, + slug: flatDataConnector.slug as string, + visibility: + flatDataConnector.visibility === "public" + ? ("public" as const) + : ("private" as const), + }; + const storage: CloudStorageCorePost = { + configuration: { type: flatDataConnector.schema ?? null }, + readonly: flatDataConnector.readOnly ?? true, + source_path: flatDataConnector.sourcePath ?? "/", + target_path: flatDataConnector.mountPoint as string, + }; + if (flatDataConnector.provider) { + storage.configuration.provider = flatDataConnector.provider; + } + // Add options if any + if ( + flatDataConnector.options && + Object.keys(flatDataConnector.options).length > 0 + ) { + const allOptions = flatDataConnector.options as DataConnectorOptions; + const sensitiveFields = schemata + ? findSensitive( + schemata.find((s) => s.prefix === flatDataConnector.schema) + ) + : dataConnector?.storage?.sensitive_fields + ? dataConnector.storage.sensitive_fields.map((field) => field.name) + : []; + const validOptions = Object.keys( + flatDataConnector.options + ).reduce((options, key) => { + const value = allOptions[key]; + if (value != undefined && value !== "") { + options[key] = sensitiveFields.includes(key) + ? CLOUD_STORAGE_SENSITIVE_FIELD_TOKEN + : value; + } + return options; + }, {}); + + storage.configuration = { + ...storage.configuration, + ...validOptions, + }; + } + return { ...meta, storage }; +} + +export function dataConnectorToFlattened( + dataConnector: DataConnectorRead | null +): DataConnectorFlat { + if (!dataConnector) { + return EMPTY_DATA_CONNECTOR_FLAT; + } + const configurationOptions = dataConnector.storage.configuration + ? dataConnector.storage.configuration + : {}; + const { type, provider, ...options } = configurationOptions; // eslint-disable-line @typescript-eslint/no-unused-vars + const flattened: DataConnectorFlat = { + dataConnectorId: dataConnector.id, + name: dataConnector.name, + namespace: dataConnector.namespace, + slug: dataConnector.slug, + visibility: dataConnector.visibility, + mountPoint: dataConnector.storage.target_path, + options, + provider: dataConnector.storage.configuration.provider + ? (dataConnector.storage.configuration.provider as string) + : undefined, + readOnly: dataConnector.storage.readonly, + schema: dataConnector.storage.configuration.type as string, + sourcePath: dataConnector.storage.source_path, + }; + + return flattened; +} + +export function validationParametersFromDataConnectorConfiguration( + config: DataConnectorConfiguration +) { + const newStorageDetails = dataConnectorToFlattened( + _dataConnectorFromConfig(config) + ); + return _validationConfigurationFromDataConnectorFlat(newStorageDetails); +} + +function _dataConnectorFromConfig( + config: DataConnectorConfiguration +): DataConnectorRead { + const dataConnector = config.dataConnector; + const storageDefinition = config.dataConnector.storage; + const mergedDataConnector = { ...dataConnector, ...storageDefinition }; + mergedDataConnector.configuration = { ...storageDefinition.configuration }; + const sensitiveFieldValues = config.sensitiveFieldValues; + Object.entries(sensitiveFieldValues).forEach(([name, value]) => { + if (value != null && value !== "") { + mergedDataConnector.configuration[name] = value; + } else { + delete mergedDataConnector.configuration[name]; + } + }); + return mergedDataConnector; +} + +function _validationConfigurationFromDataConnectorFlat( + dataConnector: DataConnectorFlat +) { + const validateParameters: TestCloudStorageConnectionParams = { + configuration: { + type: dataConnector.schema, + }, + source_path: dataConnector.sourcePath ?? "/", + }; + if (dataConnector.provider) { + validateParameters.configuration.provider = dataConnector.provider; + } + if (dataConnector.options && Object.keys(dataConnector.options).length > 0) { + const options = dataConnector.options as CloudStorageDetailsOptions; + Object.entries(options).forEach(([key, value]) => { + if (value != undefined && value !== "") { + validateParameters.configuration[key] = value; + } + }); + } + + return validateParameters; +} diff --git a/client/src/features/dataConnectorsV2/components/useDataConnectorConfiguration.hook.ts b/client/src/features/dataConnectorsV2/components/useDataConnectorConfiguration.hook.ts new file mode 100644 index 0000000000..ad30d866fc --- /dev/null +++ b/client/src/features/dataConnectorsV2/components/useDataConnectorConfiguration.hook.ts @@ -0,0 +1,105 @@ +/*! + * Copyright 2024 - Swiss Data Science Center (SDSC) + * A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and + * Eidgenössische Technische Hochschule Zürich (ETHZ). + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +import { useMemo } from "react"; + +import { CLOUD_OPTIONS_OVERRIDE } from "../../project/components/cloudStorage/projectCloudStorage.constants"; +import { RCloneOption } from "../../projectsV2/api/data-connectors.api"; +import type { + DataConnectorRead, + CloudStorageSecretGet, +} from "../../projectsV2/api/data-connectors.api"; + +import type { SessionStartCloudStorageConfiguration } from "../../sessionsV2/startSessionOptionsV2.types"; + +export interface DataConnectorConfiguration + extends Omit { + dataConnector: DataConnectorRead; +} + +interface UseDataSourceConfigurationArgs { + dataConnectors: DataConnectorRead[] | undefined; +} + +export default function useDataConnectorConfiguration({ + dataConnectors, +}: UseDataSourceConfigurationArgs) { + const dataConnectorConfigs = useMemo( + () => + dataConnectors?.map((dataConnector) => { + const storageDefinition = dataConnector.storage; + const defSensitiveFieldsMap: Record = {}; + if (dataConnector.storage.sensitive_fields != null) { + dataConnector.storage.sensitive_fields.forEach((f) => { + if (f.name != null) defSensitiveFieldsMap[f.name] = f; + }); + } + const configSensitiveFields = Object.keys( + storageDefinition.configuration + ).filter((key) => defSensitiveFieldsMap[key] != null); + + const overrides = + storageDefinition.storage_type != null + ? CLOUD_OPTIONS_OVERRIDE[storageDefinition.storage_type] + : undefined; + + const sensitiveFieldDefinitions = configSensitiveFields + .filter((key) => defSensitiveFieldsMap[key].name != null) + .map((key) => { + const { help, name } = defSensitiveFieldsMap[key]; + return { + help: overrides?.[key]?.help ?? help ?? "", + friendlyName: overrides?.[key]?.friendlyName ?? name ?? key, + name: name ?? key, + value: "", + }; + }); + + const sensitiveFieldValues: SessionStartCloudStorageConfiguration["sensitiveFieldValues"] = + {}; + configSensitiveFields.forEach((key) => { + const { name } = defSensitiveFieldsMap[key]; + if (name == null) return; + sensitiveFieldValues[name] = ""; + }); + const storagesSecrets = dataConnectors?.reduce( + (a: Record, s) => { + a[s.id] = s.secrets ? s.secrets : []; + return a; + }, + {} + ); + const savedCredentialFields = storagesSecrets + ? storagesSecrets[dataConnector.id].map((s) => s.name) + : []; + return { + active: true, + dataConnector, + sensitiveFieldDefinitions, + sensitiveFieldValues, + saveCredentials: false, + savedCredentialFields, + }; + }), + [dataConnectors] + ); + + return { + dataConnectorConfigs, + }; +} diff --git a/client/src/features/groupsV2/show/GroupV2Show.tsx b/client/src/features/groupsV2/show/GroupV2Show.tsx index 38cb300d6a..c5fac24e93 100644 --- a/client/src/features/groupsV2/show/GroupV2Show.tsx +++ b/client/src/features/groupsV2/show/GroupV2Show.tsx @@ -26,12 +26,14 @@ import { useNavigate, useParams, } from "react-router-dom-v5-compat"; -import { Badge } from "reactstrap"; +import { Badge, Col, Row } from "reactstrap"; import { Loader } from "../../../components/Loader"; import ContainerWrap from "../../../components/container/ContainerWrap"; import LazyNotFound from "../../../not-found/LazyNotFound"; import { ABSOLUTE_ROUTES } from "../../../routing/routes.constants"; + +import DataConnectorsBox from "../../dataConnectorsV2/components/DataConnectorsBox"; import MembershipGuard from "../../ProjectPageV2/utils/MembershipGuard"; import type { GroupResponse } from "../../projectsV2/api/namespace.api"; import { @@ -134,6 +136,18 @@ export default function GroupV2Show() { emptyListElement={

No visible projects.

} /> + +
+ + + + + +
); } diff --git a/client/src/features/project/components/cloudStorage/AddOrEditCloudStorage.tsx b/client/src/features/project/components/cloudStorage/AddOrEditCloudStorage.tsx index f70e5071fd..ebd997d15b 100644 --- a/client/src/features/project/components/cloudStorage/AddOrEditCloudStorage.tsx +++ b/client/src/features/project/components/cloudStorage/AddOrEditCloudStorage.tsx @@ -162,7 +162,7 @@ export function AddStorageAdvancedToggle({ } // *** Add storage: helpers *** // -interface AddStorageStepProps { +export interface AddStorageStepProps { schema: CloudStorageSchema[]; setStorage: (newDetails: Partial) => void; setState: (newState: Partial) => void; diff --git a/client/src/features/project/utils/projectCloudStorage.utils.ts b/client/src/features/project/utils/projectCloudStorage.utils.ts index d9fd91de2b..40c003fdbe 100644 --- a/client/src/features/project/utils/projectCloudStorage.utils.ts +++ b/client/src/features/project/utils/projectCloudStorage.utils.ts @@ -49,7 +49,16 @@ export interface CloudStorageOptions extends RCloneOption { requiredCredential: boolean; } -type StorageDefinition = CloudStorage | CloudStorageGetRead; +type SensitiveFields = + | CloudStorage["sensitive_fields"] + | CloudStorageGetRead["sensitive_fields"]; +type StorageConfiguration = + | CloudStorage["storage"]["configuration"] + | CloudStorageGetRead["storage"]["configuration"]; +type StorageAndSensitiveFieldsDefinition = { + storage: { configuration: StorageConfiguration }; + sensitive_fields?: SensitiveFields; +}; export function parseCloudStorageConfiguration( formattedConfiguration: string @@ -92,7 +101,9 @@ export function convertFromAdvancedConfig( return values.length ? values.join("\n") + "\n" : ""; } -export function getCredentialFieldDefinitions( +export function getCredentialFieldDefinitions< + T extends StorageAndSensitiveFieldsDefinition +>( storageDefinition: T ): | (T extends CloudStorageGetRead diff --git a/client/src/features/projectsV2/api/data-connectors.api-config.ts b/client/src/features/projectsV2/api/data-connectors.api-config.ts new file mode 100644 index 0000000000..91bdbe184f --- /dev/null +++ b/client/src/features/projectsV2/api/data-connectors.api-config.ts @@ -0,0 +1,33 @@ +/*! + * Copyright 2024 - Swiss Data Science Center (SDSC) + * A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and + * Eidgenössische Technische Hochschule Zürich (ETHZ). + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Run `npm run generate-api:namespaceV2` to generate the API +import type { ConfigFile } from "@rtk-query/codegen-openapi"; +import path from "path"; + +const config: ConfigFile = { + // Configure to inject endpoints into the dataConnectorsApi + apiFile: "./data-connectors.empty-api.ts", + apiImport: "dataConnectorsEmptyApi", + outputFile: "./data-connectors.api.ts", + exportName: "dataConnectorsApi", + hooks: true, + schemaFile: path.join(__dirname, "data-connectors.openapi.json"), +}; + +export default config; diff --git a/client/src/features/projectsV2/api/data-connectors.api.ts b/client/src/features/projectsV2/api/data-connectors.api.ts new file mode 100644 index 0000000000..b52b7353d6 --- /dev/null +++ b/client/src/features/projectsV2/api/data-connectors.api.ts @@ -0,0 +1,354 @@ +import { dataConnectorsEmptyApi as api } from "./data-connectors.empty-api"; +const injectedRtkApi = api.injectEndpoints({ + endpoints: (build) => ({ + getDataConnectors: build.query< + GetDataConnectorsApiResponse, + GetDataConnectorsApiArg + >({ + query: (queryArg) => ({ + url: `/data_connectors`, + params: { params: queryArg.params }, + }), + }), + postDataConnectors: build.mutation< + PostDataConnectorsApiResponse, + PostDataConnectorsApiArg + >({ + query: (queryArg) => ({ + url: `/data_connectors`, + method: "POST", + body: queryArg.dataConnectorPost, + }), + }), + getDataConnectorsByDataConnectorId: build.query< + GetDataConnectorsByDataConnectorIdApiResponse, + GetDataConnectorsByDataConnectorIdApiArg + >({ + query: (queryArg) => ({ + url: `/data_connectors/${queryArg.dataConnectorId}`, + }), + }), + patchDataConnectorsByDataConnectorId: build.mutation< + PatchDataConnectorsByDataConnectorIdApiResponse, + PatchDataConnectorsByDataConnectorIdApiArg + >({ + query: (queryArg) => ({ + url: `/data_connectors/${queryArg.dataConnectorId}`, + method: "PATCH", + body: queryArg.dataConnectorPatch, + headers: { "If-Match": queryArg["If-Match"] }, + }), + }), + deleteDataConnectorsByDataConnectorId: build.mutation< + DeleteDataConnectorsByDataConnectorIdApiResponse, + DeleteDataConnectorsByDataConnectorIdApiArg + >({ + query: (queryArg) => ({ + url: `/data_connectors/${queryArg.dataConnectorId}`, + method: "DELETE", + }), + }), + getNamespacesByNamespaceDataConnectorsAndSlug: build.query< + GetNamespacesByNamespaceDataConnectorsAndSlugApiResponse, + GetNamespacesByNamespaceDataConnectorsAndSlugApiArg + >({ + query: (queryArg) => ({ + url: `/namespaces/${queryArg["namespace"]}/data_connectors/${queryArg.slug}`, + }), + }), + getDataConnectorsByDataConnectorIdSecrets: build.query< + GetDataConnectorsByDataConnectorIdSecretsApiResponse, + GetDataConnectorsByDataConnectorIdSecretsApiArg + >({ + query: (queryArg) => ({ + url: `/data_connectors/${queryArg.dataConnectorId}/secrets`, + }), + }), + postDataConnectorsByDataConnectorIdSecrets: build.mutation< + PostDataConnectorsByDataConnectorIdSecretsApiResponse, + PostDataConnectorsByDataConnectorIdSecretsApiArg + >({ + query: (queryArg) => ({ + url: `/data_connectors/${queryArg.dataConnectorId}/secrets`, + method: "POST", + body: queryArg.cloudStorageSecretPostList, + }), + }), + deleteDataConnectorsByDataConnectorIdSecrets: build.mutation< + DeleteDataConnectorsByDataConnectorIdSecretsApiResponse, + DeleteDataConnectorsByDataConnectorIdSecretsApiArg + >({ + query: (queryArg) => ({ + url: `/data_connectors/${queryArg.dataConnectorId}/secrets`, + method: "DELETE", + }), + }), + }), + overrideExisting: false, +}); +export { injectedRtkApi as dataConnectorsApi }; +export type GetDataConnectorsApiResponse = + /** status 200 List of data connectors */ DataConnectorsListRead; +export type GetDataConnectorsApiArg = { + /** query parameters */ + params?: DataConnectorsGetQuery; +}; +export type PostDataConnectorsApiResponse = + /** status 201 The data connector was created */ DataConnectorRead; +export type PostDataConnectorsApiArg = { + dataConnectorPost: DataConnectorPost; +}; +export type GetDataConnectorsByDataConnectorIdApiResponse = + /** status 200 The data connector */ DataConnectorRead; +export type GetDataConnectorsByDataConnectorIdApiArg = { + /** the ID of the data connector */ + dataConnectorId: Ulid; +}; +export type PatchDataConnectorsByDataConnectorIdApiResponse = + /** status 200 The patched data connector */ DataConnectorRead; +export type PatchDataConnectorsByDataConnectorIdApiArg = { + /** the ID of the data connector */ + dataConnectorId: Ulid; + /** If-Match header, for avoiding mid-air collisions */ + "If-Match": ETag; + dataConnectorPatch: DataConnectorPatch; +}; +export type DeleteDataConnectorsByDataConnectorIdApiResponse = + /** status 204 The data connector was removed or did not exist in the first place */ void; +export type DeleteDataConnectorsByDataConnectorIdApiArg = { + /** the ID of the data connector */ + dataConnectorId: Ulid; +}; +export type GetNamespacesByNamespaceDataConnectorsAndSlugApiResponse = + /** status 200 The data connectors */ DataConnectorRead; +export type GetNamespacesByNamespaceDataConnectorsAndSlugApiArg = { + namespace: string; + slug: string; +}; +export type GetDataConnectorsByDataConnectorIdSecretsApiResponse = + /** status 200 The saved storage secrets */ CloudStorageSecretGetList; +export type GetDataConnectorsByDataConnectorIdSecretsApiArg = { + /** the ID of the data connector */ + dataConnectorId: Ulid; +}; +export type PostDataConnectorsByDataConnectorIdSecretsApiResponse = + /** status 201 The secrets for cloud storage were saved */ CloudStorageSecretGetList; +export type PostDataConnectorsByDataConnectorIdSecretsApiArg = { + /** the ID of the data connector */ + dataConnectorId: Ulid; + cloudStorageSecretPostList: CloudStorageSecretPostList; +}; +export type DeleteDataConnectorsByDataConnectorIdSecretsApiResponse = + /** status 204 The secrets were removed or did not exist in the first place or the storage doesn't exist */ void; +export type DeleteDataConnectorsByDataConnectorIdSecretsApiArg = { + /** the ID of the data connector */ + dataConnectorId: Ulid; +}; +export type Ulid = string; +export type DataConnectorName = string; +export type Slug = string; +export type StorageType = string; +export type StorageTypeRead = string; +export type RCloneConfig = { + [key: string]: number | (string | null) | boolean | object; +}; +export type SourcePath = string; +export type TargetPath = string; +export type StorageReadOnly = boolean; +export type RCloneOption = { + /** name of the option */ + name?: string; + /** help text for the option */ + help?: string; + /** The cloud provider the option is for (See 'provider' RCloneOption in the schema for potential values) */ + provider?: string; + /** default value for the option */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + default?: number | string | boolean | object | any; + /** string representation of the default value */ + default_str?: string; + /** These list potential values for this option, like an enum. With `exclusive: true`, only a value from the list is allowed. */ + examples?: { + /** a potential value for the option (think enum) */ + value?: string; + /** help text for the value */ + help?: string; + /** The provider this value is applicable for. Empty if valid for all providers. */ + provider?: string; + }[]; + /** whether the option is required or not */ + required?: boolean; + /** whether the field is a password (use **** for display) */ + ispassword?: boolean; + /** whether the value is sensitive (not stored in the service). Do not send this in requests to the service. */ + sensitive?: boolean; + /** whether this is an advanced config option (probably don't show these to users) */ + advanced?: boolean; + /** if true, only values from 'examples' can be used */ + exclusive?: boolean; + /** data type of option value. RClone has more options but they map to the ones listed here. */ + datatype?: "int" | "bool" | "string" | "Time"; +}; +export type CloudStorageCore = { + storage_type: StorageType; + configuration: RCloneConfig; + source_path: SourcePath; + target_path: TargetPath; + readonly: StorageReadOnly; + sensitive_fields: RCloneOption[]; +}; +export type CloudStorageCoreRead = { + storage_type: StorageTypeRead; + configuration: RCloneConfig; + source_path: SourcePath; + target_path: TargetPath; + readonly: StorageReadOnly; + sensitive_fields: RCloneOption[]; +}; +export type CloudStorageSecretGet = { + /** Name of the field to store credential for */ + name: string; + secret_id: Ulid; +}; +export type CreationDate = string; +export type UserId = string; +export type Visibility = "private" | "public"; +export type Description = string; +export type ETag = string; +export type Keyword = string; +export type KeywordsList = Keyword[]; +export type DataConnector = { + id: Ulid; + name: DataConnectorName; + namespace: Slug; + slug: Slug; + storage: CloudStorageCore; + secrets?: CloudStorageSecretGet[]; + creation_date: CreationDate; + created_by: UserId; + visibility: Visibility; + description?: Description; + etag: ETag; + keywords?: KeywordsList; +}; +export type DataConnectorRead = { + id: Ulid; + name: DataConnectorName; + namespace: Slug; + slug: Slug; + storage: CloudStorageCoreRead; + secrets?: CloudStorageSecretGet[]; + creation_date: CreationDate; + created_by: UserId; + visibility: Visibility; + description?: Description; + etag: ETag; + keywords?: KeywordsList; +}; +export type DataConnectorsList = DataConnector[]; +export type DataConnectorsListRead = DataConnectorRead[]; +export type ErrorResponse = { + error: { + code: number; + detail?: string; + message: string; + }; +}; +export type PaginationRequest = { + /** Result's page number starting from 1 */ + page?: number; + /** The number of results per page */ + per_page?: number; +}; +export type DataConnectorsGetQuery = PaginationRequest & { + /** A namespace, used as a filter. */ + namespace?: string; +}; +export type CloudStorageCorePost = { + storage_type?: StorageType; + configuration: RCloneConfig; + source_path: SourcePath; + target_path: TargetPath; + readonly?: StorageReadOnly; +}; +export type CloudStorageCorePostRead = { + storage_type?: StorageTypeRead; + configuration: RCloneConfig; + source_path: SourcePath; + target_path: TargetPath; + readonly?: StorageReadOnly; +}; +export type CloudStorageUrlV2 = { + storage_url: string; + target_path: TargetPath; + readonly?: StorageReadOnly; +}; +export type DataConnectorPost = { + name: DataConnectorName; + namespace: Slug; + slug?: Slug; + storage: CloudStorageCorePost | CloudStorageUrlV2; + visibility?: Visibility; + description?: Description; + keywords?: KeywordsList; +}; +export type DataConnectorPostRead = { + name: DataConnectorName; + namespace: Slug; + slug?: Slug; + storage: CloudStorageCorePostRead | CloudStorageUrlV2; + visibility?: Visibility; + description?: Description; + keywords?: KeywordsList; +}; +export type CloudStorageCorePatch = { + storage_type?: StorageType; + configuration?: RCloneConfig; + source_path?: SourcePath; + target_path?: TargetPath; + readonly?: StorageReadOnly; +}; +export type CloudStorageCorePatchRead = { + storage_type?: StorageTypeRead; + configuration?: RCloneConfig; + source_path?: SourcePath; + target_path?: TargetPath; + readonly?: StorageReadOnly; +}; +export type DataConnectorPatch = { + name?: DataConnectorName; + namespace?: Slug; + slug?: Slug; + storage?: CloudStorageCorePatch; + visibility?: Visibility; + description?: Description; + keywords?: KeywordsList; +}; +export type DataConnectorPatchRead = { + name?: DataConnectorName; + namespace?: Slug; + slug?: Slug; + storage?: CloudStorageCorePatchRead; + visibility?: Visibility; + description?: Description; + keywords?: KeywordsList; +}; +export type CloudStorageSecretGetList = CloudStorageSecretGet[]; +export type SecretValue = string; +export type CloudStorageSecretPost = { + /** Name of the field to store credential for */ + name: string; + value: SecretValue; +}; +export type CloudStorageSecretPostList = CloudStorageSecretPost[]; +export const { + useGetDataConnectorsQuery, + usePostDataConnectorsMutation, + useGetDataConnectorsByDataConnectorIdQuery, + usePatchDataConnectorsByDataConnectorIdMutation, + useDeleteDataConnectorsByDataConnectorIdMutation, + useGetNamespacesByNamespaceDataConnectorsAndSlugQuery, + useGetDataConnectorsByDataConnectorIdSecretsQuery, + usePostDataConnectorsByDataConnectorIdSecretsMutation, + useDeleteDataConnectorsByDataConnectorIdSecretsMutation, +} = injectedRtkApi; diff --git a/client/src/features/projectsV2/api/data-connectors.empty-api.ts b/client/src/features/projectsV2/api/data-connectors.empty-api.ts new file mode 100644 index 0000000000..c8bdefe3f1 --- /dev/null +++ b/client/src/features/projectsV2/api/data-connectors.empty-api.ts @@ -0,0 +1,26 @@ +/*! + * Copyright 2023 - Swiss Data Science Center (SDSC) + * A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and + * Eidgenössische Technische Hochschule Zürich (ETHZ). + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/dist/query/react"; + +// initialize an empty api service that we'll inject endpoints into later as needed +export const dataConnectorsEmptyApi = createApi({ + baseQuery: fetchBaseQuery({ baseUrl: "/ui-server/api/data" }), + endpoints: () => ({}), + reducerPath: "dataConnectorsApi", +}); diff --git a/client/src/features/projectsV2/api/data-connectors.enhanced-api.ts b/client/src/features/projectsV2/api/data-connectors.enhanced-api.ts new file mode 100644 index 0000000000..90ca0ae5a8 --- /dev/null +++ b/client/src/features/projectsV2/api/data-connectors.enhanced-api.ts @@ -0,0 +1,86 @@ +import { AbstractKgPaginatedResponse } from "../../../utils/types/pagination.types"; +import { processPaginationHeaders } from "../../../utils/helpers/kgPagination.utils"; + +import { dataConnectorsApi as api } from "./data-connectors.api"; + +import type { + GetDataConnectorsApiArg, + GetDataConnectorsApiResponse as GetDataConnectorsApiResponseOrig, +} from "./data-connectors.api"; + +export interface GetDataConnectorsApiResponse + extends AbstractKgPaginatedResponse { + dataConnectors: GetDataConnectorsApiResponseOrig; +} + +const injectedApi = api.injectEndpoints({ + endpoints: (builder) => ({ + getDataConnectorsPaged: builder.query< + GetDataConnectorsApiResponse, + GetDataConnectorsApiArg + >({ + query: (queryArg) => ({ + url: "/data_connectors", + params: { + namespace: queryArg.params?.namespace, + page: queryArg.params?.page, + per_page: queryArg.params?.per_page, + }, + }), + transformResponse: (response, meta, queryArg) => { + const dataConnectors = response as GetDataConnectorsApiResponseOrig; + const headers = meta?.response?.headers; + const headerResponse = processPaginationHeaders( + headers, + queryArg.params == null + ? {} + : { page: queryArg.params.page, perPage: queryArg.params.per_page }, + dataConnectors + ); + + return { + dataConnectors, + page: headerResponse.page, + perPage: headerResponse.perPage, + total: headerResponse.total, + totalPages: headerResponse.totalPages, + }; + }, + }), + }), +}); + +const enhancedApi = injectedApi.enhanceEndpoints({ + addTagTypes: ["DataConnectors", "DataConnectorSecrets"], + endpoints: { + deleteDataConnectorsByDataConnectorId: { + invalidatesTags: ["DataConnectors"], + }, + deleteDataConnectorsByDataConnectorIdSecrets: { + invalidatesTags: ["DataConnectorSecrets"], + }, + getDataConnectorsPaged: { + providesTags: ["DataConnectors"], + }, + patchDataConnectorsByDataConnectorId: { + invalidatesTags: ["DataConnectors"], + }, + postDataConnectors: { + invalidatesTags: ["DataConnectors"], + }, + postDataConnectorsByDataConnectorIdSecrets: { + invalidatesTags: ["DataConnectorSecrets"], + }, + }, +}); + +export { enhancedApi as dataConnectorsApi }; +export const { + // data connectors hooks + useDeleteDataConnectorsByDataConnectorIdMutation, + useDeleteDataConnectorsByDataConnectorIdSecretsMutation, + useGetDataConnectorsPagedQuery: useGetDataConnectorsQuery, + usePatchDataConnectorsByDataConnectorIdMutation, + usePostDataConnectorsMutation, + usePostDataConnectorsByDataConnectorIdSecretsMutation, +} = enhancedApi; diff --git a/client/src/features/projectsV2/api/data-connectors.openapi.json b/client/src/features/projectsV2/api/data-connectors.openapi.json new file mode 100644 index 0000000000..2366677f89 --- /dev/null +++ b/client/src/features/projectsV2/api/data-connectors.openapi.json @@ -0,0 +1,940 @@ +{ + "openapi": "3.0.2", + "info": { + "title": "Renku Data Services API", + "description": "This service is the main backend for Renku. It provides information about users, projects,\ncloud storage, access to compute resources and many other things.\n", + "version": "v1" + }, + "servers": [ + { + "url": "/api/data" + }, + { + "url": "/ui-server/api/data" + } + ], + "paths": { + "/data_connectors": { + "get": { + "summary": "Get all data connectors", + "parameters": [ + { + "in": "query", + "description": "query parameters", + "name": "params", + "style": "form", + "explode": true, + "schema": { + "$ref": "#/components/schemas/DataConnectorsGetQuery" + } + } + ], + "responses": { + "200": { + "description": "List of data connectors", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DataConnectorsList" + } + } + }, + "headers": { + "page": { + "description": "The index of the current page (starting at 1).", + "required": true, + "schema": { + "type": "integer" + } + }, + "per-page": { + "description": "The number of items per page.", + "required": true, + "schema": { + "type": "integer" + } + }, + "total": { + "description": "The total number of items.", + "required": true, + "schema": { + "type": "integer" + } + }, + "total-pages": { + "description": "The total number of pages.", + "required": true, + "schema": { + "type": "integer" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + }, + "tags": ["data connectors"] + }, + "post": { + "summary": "Create a new data connector", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DataConnectorPost" + } + } + } + }, + "responses": { + "201": { + "description": "The data connector was created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DataConnector" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + }, + "tags": ["data connectors"] + } + }, + "/data_connectors/{data_connector_id}": { + "parameters": [ + { + "in": "path", + "name": "data_connector_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/Ulid" + }, + "description": "the ID of the data connector" + } + ], + "get": { + "summary": "Get data connector details", + "responses": { + "200": { + "description": "The data connector", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DataConnector" + } + } + } + }, + "404": { + "description": "The data connector does not exist", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + }, + "tags": ["data connectors"] + }, + "patch": { + "summary": "Update specific fields of an existing data connector", + "parameters": [ + { + "$ref": "#/components/parameters/If-Match" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DataConnectorPatch" + } + } + } + }, + "responses": { + "200": { + "description": "The patched data connector", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DataConnector" + } + } + } + }, + "404": { + "description": "The data connector does not exist", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + }, + "tags": ["data connectors"] + }, + "delete": { + "summary": "Remove a data connector", + "responses": { + "204": { + "description": "The data connector was removed or did not exist in the first place" + }, + "default": { + "$ref": "#/components/responses/Error" + } + }, + "tags": ["data connectors"] + } + }, + "/namespaces/{namespace}/data_connectors/{slug}": { + "parameters": [ + { + "in": "path", + "name": "namespace", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "slug", + "required": true, + "schema": { + "type": "string" + } + } + ], + "get": { + "summary": "Get a data connector by namespace and project slug", + "responses": { + "200": { + "description": "The data connectors", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DataConnector" + } + } + } + }, + "404": { + "description": "The data connector does not exist", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + }, + "tags": ["data connectors"] + } + }, + "/data_connectors/{data_connector_id}/secrets": { + "parameters": [ + { + "in": "path", + "name": "data_connector_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/Ulid" + }, + "description": "the ID of the data connector" + } + ], + "get": { + "summary": "Get all saved secrets for a data connector", + "responses": { + "200": { + "description": "The saved storage secrets", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CloudStorageSecretGetList" + } + } + } + }, + "404": { + "description": "Storage was not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + }, + "tags": ["data connectors"] + }, + "post": { + "summary": "Save secrets for a data connector", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CloudStorageSecretPostList" + } + } + } + }, + "responses": { + "201": { + "description": "The secrets for cloud storage were saved", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CloudStorageSecretGetList" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + }, + "tags": ["data connectors"] + }, + "delete": { + "summary": "Remove all saved secrets for a data connector", + "responses": { + "204": { + "description": "The secrets were removed or did not exist in the first place or the storage doesn't exist" + }, + "default": { + "$ref": "#/components/responses/Error" + } + }, + "tags": ["data connectors"] + } + } + }, + "components": { + "schemas": { + "DataConnectorsList": { + "description": "A list of data connectors", + "type": "array", + "items": { + "$ref": "#/components/schemas/DataConnector" + } + }, + "DataConnector": { + "description": "A data connector for Renku 2.0 for mounting remote data storage\n", + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "$ref": "#/components/schemas/Ulid" + }, + "name": { + "$ref": "#/components/schemas/DataConnectorName" + }, + "namespace": { + "$ref": "#/components/schemas/Slug" + }, + "slug": { + "$ref": "#/components/schemas/Slug" + }, + "storage": { + "$ref": "#/components/schemas/CloudStorageCore" + }, + "secrets": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CloudStorageSecretGet" + } + }, + "creation_date": { + "$ref": "#/components/schemas/CreationDate" + }, + "created_by": { + "$ref": "#/components/schemas/UserId" + }, + "visibility": { + "$ref": "#/components/schemas/Visibility" + }, + "description": { + "$ref": "#/components/schemas/Description" + }, + "etag": { + "$ref": "#/components/schemas/ETag" + }, + "keywords": { + "$ref": "#/components/schemas/KeywordsList" + } + }, + "required": [ + "id", + "name", + "namespace", + "slug", + "storage", + "creation_date", + "created_by", + "visibility", + "etag" + ] + }, + "DataConnectorPost": { + "description": "A data connector to be created in Renku 2.0\n", + "type": "object", + "additionalProperties": false, + "properties": { + "name": { + "$ref": "#/components/schemas/DataConnectorName" + }, + "namespace": { + "$ref": "#/components/schemas/Slug" + }, + "slug": { + "$ref": "#/components/schemas/Slug" + }, + "storage": { + "oneOf": [ + { + "$ref": "#/components/schemas/CloudStorageCorePost" + }, + { + "$ref": "#/components/schemas/CloudStorageUrlV2" + } + ] + }, + "visibility": { + "$ref": "#/components/schemas/Visibility", + "default": "private" + }, + "description": { + "$ref": "#/components/schemas/Description" + }, + "keywords": { + "$ref": "#/components/schemas/KeywordsList" + } + }, + "required": ["name", "namespace", "storage"] + }, + "DataConnectorPatch": { + "description": "Patch of a data connector\n", + "type": "object", + "additionalProperties": false, + "properties": { + "name": { + "$ref": "#/components/schemas/DataConnectorName" + }, + "namespace": { + "$ref": "#/components/schemas/Slug" + }, + "slug": { + "$ref": "#/components/schemas/Slug" + }, + "storage": { + "$ref": "#/components/schemas/CloudStorageCorePatch" + }, + "visibility": { + "$ref": "#/components/schemas/Visibility" + }, + "description": { + "$ref": "#/components/schemas/Description" + }, + "keywords": { + "$ref": "#/components/schemas/KeywordsList" + } + } + }, + "CloudStorageCore": { + "description": "Represents the configuration used to mount remote data storage", + "type": "object", + "additionalProperties": false, + "properties": { + "storage_type": { + "$ref": "#/components/schemas/StorageType" + }, + "configuration": { + "$ref": "#/components/schemas/RCloneConfig" + }, + "source_path": { + "$ref": "#/components/schemas/SourcePath" + }, + "target_path": { + "$ref": "#/components/schemas/TargetPath" + }, + "readonly": { + "$ref": "#/components/schemas/StorageReadOnly" + }, + "sensitive_fields": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RCloneOption" + } + } + }, + "required": [ + "storage_type", + "configuration", + "source_path", + "target_path", + "readonly", + "sensitive_fields" + ] + }, + "CloudStorageCorePost": { + "type": "object", + "additionalProperties": false, + "properties": { + "storage_type": { + "$ref": "#/components/schemas/StorageType" + }, + "configuration": { + "$ref": "#/components/schemas/RCloneConfig" + }, + "source_path": { + "$ref": "#/components/schemas/SourcePath" + }, + "target_path": { + "$ref": "#/components/schemas/TargetPath" + }, + "readonly": { + "$ref": "#/components/schemas/StorageReadOnly", + "default": true + } + }, + "required": ["configuration", "source_path", "target_path"] + }, + "CloudStorageCorePatch": { + "type": "object", + "additionalProperties": false, + "properties": { + "storage_type": { + "$ref": "#/components/schemas/StorageType" + }, + "configuration": { + "$ref": "#/components/schemas/RCloneConfig" + }, + "source_path": { + "$ref": "#/components/schemas/SourcePath" + }, + "target_path": { + "$ref": "#/components/schemas/TargetPath" + }, + "readonly": { + "$ref": "#/components/schemas/StorageReadOnly" + } + } + }, + "RCloneConfig": { + "type": "object", + "description": "Dictionary of rclone key:value pairs (based on schema from '/storage_schema')", + "additionalProperties": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "string", + "nullable": true + }, + { + "type": "boolean" + }, + { + "type": "object" + } + ] + } + }, + "CloudStorageUrlV2": { + "type": "object", + "properties": { + "storage_url": { + "type": "string" + }, + "target_path": { + "$ref": "#/components/schemas/TargetPath" + }, + "readonly": { + "$ref": "#/components/schemas/StorageReadOnly", + "default": true + } + }, + "required": ["storage_url", "target_path"], + "example": { + "storage_url": "s3://giab" + } + }, + "CloudStorageSecretPost": { + "type": "object", + "description": "Data for storing secret for a storage field", + "properties": { + "name": { + "type": "string", + "description": "Name of the field to store credential for", + "minLength": 1, + "maxLength": 99 + }, + "value": { + "$ref": "#/components/schemas/SecretValue" + } + }, + "required": ["name", "value"] + }, + "CloudStorageSecretPostList": { + "description": "List of storage secrets that are saved", + "type": "array", + "items": { + "$ref": "#/components/schemas/CloudStorageSecretPost" + } + }, + "CloudStorageSecretGetList": { + "description": "List of storage secrets that are saved", + "type": "array", + "items": { + "$ref": "#/components/schemas/CloudStorageSecretGet" + } + }, + "CloudStorageSecretGet": { + "type": "object", + "description": "Data for saved storage secrets", + "properties": { + "name": { + "type": "string", + "description": "Name of the field to store credential for", + "minLength": 1, + "maxLength": 99 + }, + "secret_id": { + "$ref": "#/components/schemas/Ulid" + } + }, + "required": ["name", "secret_id"] + }, + "SecretValue": { + "description": "Secret value that can be any text", + "type": "string", + "minLength": 1, + "maxLength": 5000 + }, + "RCloneEntry": { + "type": "object", + "description": "Schema for a storage type in rclone, like S3 or Azure Blob Storage. Contains fields for that storage type.", + "properties": { + "name": { + "type": "string", + "description": "Human readable name of the provider" + }, + "description": { + "type": "string", + "description": "description of the provider" + }, + "prefix": { + "type": "string", + "description": "Machine readable name of the provider" + }, + "options": { + "description": "Fields/properties used for this storage.", + "type": "array", + "items": { + "$ref": "#/components/schemas/RCloneOption" + } + } + } + }, + "RCloneOption": { + "type": "object", + "description": "Single field on an RClone storage, like \"remote\" or \"access_key_id\"", + "properties": { + "name": { + "type": "string", + "description": "name of the option" + }, + "help": { + "type": "string", + "description": "help text for the option" + }, + "provider": { + "type": "string", + "description": "The cloud provider the option is for (See 'provider' RCloneOption in the schema for potential values)", + "example": "AWS" + }, + "default": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string" + }, + { + "type": "boolean" + }, + { + "type": "object" + }, + { + "type": "array" + } + ], + "description": "default value for the option" + }, + "default_str": { + "type": "string", + "description": "string representation of the default value" + }, + "examples": { + "description": "These list potential values for this option, like an enum. With `exclusive: true`, only a value from the list is allowed.", + "type": "array", + "items": { + "type": "object", + "properties": { + "value": { + "type": "string", + "description": "a potential value for the option (think enum)" + }, + "help": { + "type": "string", + "description": "help text for the value" + }, + "provider": { + "type": "string", + "description": "The provider this value is applicable for. Empty if valid for all providers." + } + } + } + }, + "required": { + "type": "boolean", + "description": "whether the option is required or not" + }, + "ispassword": { + "type": "boolean", + "description": "whether the field is a password (use **** for display)" + }, + "sensitive": { + "type": "boolean", + "description": "whether the value is sensitive (not stored in the service). Do not send this in requests to the service." + }, + "advanced": { + "type": "boolean", + "description": "whether this is an advanced config option (probably don't show these to users)" + }, + "exclusive": { + "type": "boolean", + "description": "if true, only values from 'examples' can be used" + }, + "datatype": { + "type": "string", + "description": "data type of option value. RClone has more options but they map to the ones listed here.", + "enum": ["int", "bool", "string", "Time"] + } + } + }, + "Ulid": { + "description": "ULID identifier", + "type": "string", + "minLength": 26, + "maxLength": 26, + "pattern": "^[0-7][0-9A-HJKMNP-TV-Z]{25}$" + }, + "Slug": { + "description": "A command-line/url friendly name for a namespace", + "type": "string", + "minLength": 1, + "maxLength": 99, + "pattern": "^(?!.*\\.git$|.*\\.atom$|.*[\\-._][\\-._].*)[a-zA-Z0-9][a-zA-Z0-9\\-_.]*$", + "example": "a-slug-example" + }, + "CreationDate": { + "description": "The date and time the resource was created (in UTC and ISO-8601 format)", + "type": "string", + "format": "date-time", + "example": "2023-11-01T17:32:28Z" + }, + "UserId": { + "type": "string", + "description": "Keycloak user ID", + "example": "f74a228b-1790-4276-af5f-25c2424e9b0c", + "pattern": "^[A-Za-z0-9]{1}[A-Za-z0-9-]+$" + }, + "Visibility": { + "description": "Project's visibility levels", + "type": "string", + "enum": ["private", "public"] + }, + "Description": { + "description": "A description for the resource", + "type": "string", + "maxLength": 500 + }, + "KeywordsList": { + "description": "Project keywords", + "type": "array", + "items": { + "$ref": "#/components/schemas/Keyword" + }, + "minItems": 0, + "example": ["project", "keywords"] + }, + "Keyword": { + "description": "A single keyword", + "type": "string", + "minLength": 1, + "maxLength": 99, + "pattern": "^[A-Za-z0-9\\s\\-_.]*$" + }, + "DataConnectorName": { + "description": "Renku data connector name", + "type": "string", + "minLength": 1, + "maxLength": 99, + "example": "My Remote Data :)" + }, + "SourcePath": { + "description": "the source path to mount, usually starts with bucket/container name", + "type": "string", + "example": "bucket/my/storage/folder/" + }, + "TargetPath": { + "description": "the target path relative to the working directory where the storage should be mounted", + "type": "string", + "example": "my/project/folder" + }, + "StorageType": { + "description": "same as rclone prefix/ rclone config type. Ignored in requests, but returned in responses for convenience.", + "type": "string", + "readOnly": true + }, + "StorageReadOnly": { + "description": "Whether this storage should be mounted readonly or not", + "type": "boolean", + "default": true + }, + "ETag": { + "type": "string", + "description": "Entity Tag", + "example": "9EE498F9D565D0C41E511377425F32F3" + }, + "DataConnectorsGetQuery": { + "description": "Query params for data connectors get request", + "allOf": [ + { + "$ref": "#/components/schemas/PaginationRequest" + }, + { + "properties": { + "namespace": { + "description": "A namespace, used as a filter.", + "type": "string", + "default": "" + } + } + } + ] + }, + "PaginationRequest": { + "type": "object", + "additionalProperties": false, + "properties": { + "page": { + "description": "Result's page number starting from 1", + "type": "integer", + "minimum": 1, + "default": 1 + }, + "per_page": { + "description": "The number of results per page", + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 20 + } + } + }, + "ErrorResponse": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true, + "example": 1404 + }, + "detail": { + "type": "string", + "example": "A more detailed optional message showing what the problem was" + }, + "message": { + "type": "string", + "example": "Something went wrong - please try again later" + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"] + } + }, + "responses": { + "Error": { + "description": "The schema for all 4xx and 5xx responses", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "parameters": { + "If-Match": { + "in": "header", + "name": "If-Match", + "description": "If-Match header, for avoiding mid-air collisions", + "required": true, + "schema": { + "$ref": "#/components/schemas/ETag" + } + } + }, + "securitySchemes": { + "oidc": { + "type": "openIdConnect", + "openIdConnectUrl": "/auth/realms/Renku/.well-known/openid-configuration" + } + } + }, + "security": [ + { + "oidc": ["openid"] + } + ] +} diff --git a/client/src/features/projectsV2/api/projectV2.enhanced-api.ts b/client/src/features/projectsV2/api/projectV2.enhanced-api.ts index b47bdc8b3a..731211307b 100644 --- a/client/src/features/projectsV2/api/projectV2.enhanced-api.ts +++ b/client/src/features/projectsV2/api/projectV2.enhanced-api.ts @@ -2,6 +2,7 @@ import { AbstractKgPaginatedResponse } from "../../../utils/types/pagination.typ import { processPaginationHeaders } from "../../../utils/helpers/kgPagination.utils"; import { projectStoragesApi as api } from "./storagesV2.api"; + import type { GetProjectsApiArg, GetProjectsApiResponse as GetProjectsApiResponseOrig, diff --git a/client/src/features/projectsV2/fields/ProjectNamespaceFormField.tsx b/client/src/features/projectsV2/fields/ProjectNamespaceFormField.tsx index c57b47e37b..93c08fcff9 100644 --- a/client/src/features/projectsV2/fields/ProjectNamespaceFormField.tsx +++ b/client/src/features/projectsV2/fields/ProjectNamespaceFormField.tsx @@ -256,7 +256,7 @@ interface ProjectNamespaceControlProps { value?: string; } -function ProjectNamespaceControl(props: ProjectNamespaceControlProps) { +export function ProjectNamespaceControl(props: ProjectNamespaceControlProps) { const { className, id, onChange, value } = props; const dataCy = props["data-cy"]; const { diff --git a/client/src/features/sessionsV2/DataConnectorSecretsModal.tsx b/client/src/features/sessionsV2/DataConnectorSecretsModal.tsx index 45754a4526..a6c075957d 100644 --- a/client/src/features/sessionsV2/DataConnectorSecretsModal.tsx +++ b/client/src/features/sessionsV2/DataConnectorSecretsModal.tsx @@ -47,14 +47,11 @@ import { Loader } from "../../components/Loader"; import { useTestCloudStorageConnectionMutation } from "../project/components/cloudStorage/projectCloudStorage.api"; import { CLOUD_STORAGE_SAVED_SECRET_DISPLAY_VALUE } from "../project/components/cloudStorage/projectCloudStorage.constants"; -import type { - CloudStorageDetailsOptions, - TestCloudStorageConnectionParams, -} from "../project/components/cloudStorage/projectCloudStorage.types"; -import { storageDefinitionFromConfig } from "../project/utils/projectCloudStorage.utils"; +import type { CloudStorageDetailsOptions } from "../project/components/cloudStorage/projectCloudStorage.types"; import type { RCloneOption } from "../projectsV2/api/storagesV2.api"; -import type { SessionStartCloudStorageConfiguration } from "./startSessionOptionsV2.types"; import { storageSecretNameToFieldName } from "../secrets/secrets.utils"; +import { DataConnectorConfiguration } from "../dataConnectorsV2/components/useDataConnectorConfiguration.hook"; +import { validationParametersFromDataConnectorConfiguration } from "../dataConnectorsV2/components/dataConnector.utils"; const CONTEXT_STRINGS = { session: { @@ -73,8 +70,6 @@ const CONTEXT_STRINGS = { }, }; -export type CloudStorageConfiguration = SessionStartCloudStorageConfiguration; - function ClearCredentialsButton({ onSkip, hasSavedCredentials, @@ -98,21 +93,22 @@ function ClearCredentialsButton({ ); } -interface CloudStorageConfigurationSecretsProps { - cloudStorageConfig: CloudStorageConfiguration; - context: Required; +interface DataConnectorConfigurationSecretsProps { + dataConnectorConfig: DataConnectorConfiguration; + context: Required; control: SensitiveFieldInputProps["control"]; } -function CloudStorageConfigurationSecrets({ - cloudStorageConfig, +function DataConnectorSecrets({ + dataConnectorConfig, context, control, -}: CloudStorageConfigurationSecretsProps) { - const storage = cloudStorageConfig.cloudStorage.storage; +}: DataConnectorConfigurationSecretsProps) { + const dataConnector = dataConnectorConfig.dataConnector; + const storage = dataConnector.storage; const credentialFieldDict = Object.fromEntries( - cloudStorageConfig.savedCredentialFields.map((secret) => [ + dataConnectorConfig.savedCredentialFields.map((secret) => [ storageSecretNameToFieldName({ name: secret }), secret, ]) @@ -122,20 +118,20 @@ function CloudStorageConfigurationSecrets({ const hasIncompleteSavedCredentials = savedCredentialsLength > 0 && savedCredentialsLength != - cloudStorageConfig.sensitiveFieldDefinitions.length; + dataConnectorConfig.sensitiveFieldDefinitions.length; return ( <>
-

{storage.name}

+

{dataConnector.name}

({storage.source_path})
- {cloudStorageConfig.sensitiveFieldDefinitions.map((field) => { + {dataConnectorConfig.sensitiveFieldDefinitions.map((field) => { return ( void; - onStart: (cloudStorageConfigs: CloudStorageConfiguration[]) => void; - cloudStorageConfigs: CloudStorageConfiguration[] | undefined; + onStart: (dataConnectorConfigs: DataConnectorConfiguration[]) => void; + dataConnectorConfigs: DataConnectorConfiguration[] | undefined; } -export default function CloudStorageSecretsModal({ +export default function DataConnectorSecretsModal({ context = "session", isOpen, onCancel, onStart, - cloudStorageConfigs: initialCloudStorageConfigs, -}: CloudStorageSecretsModalProps) { + dataConnectorConfigs: initialCloudStorageConfigs, +}: DataConnectorSecretsModalProps) { const noCredentialsConfigs = useMemo( () => initialCloudStorageConfigs == null @@ -179,7 +175,7 @@ export default function CloudStorageSecretsModal({ ), [initialCloudStorageConfigs] ); - const [cloudStorageConfigs, setCloudStorageConfigs] = useState( + const [dataConnectorConfigs, setDataConnectorConfigs] = useState( initialCloudStorageConfigs == null ? [] : initialCloudStorageConfigs.filter( @@ -193,7 +189,7 @@ export default function CloudStorageSecretsModal({ useTestCloudStorageConnectionMutation(); const onNext = useCallback( - (csConfigs: CloudStorageConfiguration[]) => { + (csConfigs: DataConnectorConfiguration[]) => { if (index < csConfigs.length - 1) { if (!validationResult.isUninitialized) validationResult.reset(); resetForm(); @@ -207,25 +203,26 @@ export default function CloudStorageSecretsModal({ ); const onSkip = useCallback(() => { - if (cloudStorageConfigs.length < 1) { + if (dataConnectorConfigs.length < 1) { onStart([...noCredentialsConfigs]); return; } - const newCloudStorageConfigs = [...cloudStorageConfigs]; + const newCloudStorageConfigs = [...dataConnectorConfigs]; newCloudStorageConfigs[index] = { - ...cloudStorageConfigs[index], + ...dataConnectorConfigs[index], active: false, }; - setCloudStorageConfigs(newCloudStorageConfigs); + setDataConnectorConfigs(newCloudStorageConfigs); onNext(newCloudStorageConfigs); - }, [cloudStorageConfigs, index, noCredentialsConfigs, onNext, onStart]); + }, [dataConnectorConfigs, index, noCredentialsConfigs, onNext, onStart]); const onContinue = useCallback( (options: CloudStorageDetailsOptions) => { - if (cloudStorageConfigs == null || cloudStorageConfigs.length < 1) return; + if (dataConnectorConfigs == null || dataConnectorConfigs.length < 1) + return; - const config = { ...cloudStorageConfigs[index] }; + const config = { ...dataConnectorConfigs[index] }; const sensitiveFieldValues = { ...config.sensitiveFieldValues }; const { saveCredentials } = options; if (saveCredentials === true || saveCredentials === false) { @@ -240,29 +237,25 @@ export default function CloudStorageSecretsModal({ }); config.sensitiveFieldValues = sensitiveFieldValues; } - const newStorageDetails = storageDefinitionFromConfig(config); - - const validateParameters: TestCloudStorageConnectionParams = { - configuration: newStorageDetails.configuration, - source_path: newStorageDetails.source_path, - }; + const validateParameters = + validationParametersFromDataConnectorConfiguration(config); validateCloudStorageConnection(validateParameters); - const newCloudStorageConfigs = [...cloudStorageConfigs]; + const newCloudStorageConfigs = [...dataConnectorConfigs]; newCloudStorageConfigs[index] = config; - setCloudStorageConfigs(newCloudStorageConfigs); + setDataConnectorConfigs(newCloudStorageConfigs); }, - [cloudStorageConfigs, index, validateCloudStorageConnection] + [dataConnectorConfigs, index, validateCloudStorageConnection] ); useEffect(() => { - if (cloudStorageConfigs == null) return; - if (cloudStorageConfigs[index].active && !validationResult.isSuccess) + if (dataConnectorConfigs == null) return; + if (dataConnectorConfigs[index].active && !validationResult.isSuccess) return; - onNext(cloudStorageConfigs); + onNext(dataConnectorConfigs); }, [ - cloudStorageConfigs, + dataConnectorConfigs, index, noCredentialsConfigs, onNext, @@ -270,9 +263,9 @@ export default function CloudStorageSecretsModal({ validationResult, ]); - if (cloudStorageConfigs == null) return null; - if (cloudStorageConfigs.length < 1) return null; - const hasSavedCredentials = cloudStorageConfigs.some( + if (dataConnectorConfigs == null) return null; + if (dataConnectorConfigs.length < 1) return null; + const hasSavedCredentials = dataConnectorConfigs.some( (csc) => csc.savedCredentialFields.length > 0 ); @@ -291,8 +284,8 @@ export default function CloudStorageSecretsModal({ onSubmit={handleSubmit(onContinue)} > - @@ -304,9 +297,9 @@ export default function CloudStorageSecretsModal({
@@ -324,8 +317,8 @@ export default function CloudStorageSecretsModal({ } interface CredentialsButtonsProps - extends Pick { - context: NonNullable; + extends Pick { + context: NonNullable; hasSavedCredentials: boolean; onSkip: () => void; validationResult: ReturnType[1]; @@ -397,25 +390,25 @@ function CredentialsTestError({ } interface ProgressBreadcrumbsProps { - cloudStorageConfigs: CloudStorageConfiguration[]; + dataConnectorConfigs: DataConnectorConfiguration[]; index: number; - setCloudStorageConfigs: (configs: CloudStorageConfiguration[]) => void; + setDataConnectorConfigs: (configs: DataConnectorConfiguration[]) => void; setIndex: (index: number) => void; } function ProgressBreadcrumbs({ - cloudStorageConfigs, + dataConnectorConfigs, index, - setCloudStorageConfigs, + setDataConnectorConfigs, setIndex, }: ProgressBreadcrumbsProps) { - if (cloudStorageConfigs.length < 2) return null; + if (dataConnectorConfigs.length < 2) return null; return (