diff --git a/client/src/features/ProjectPageV2/ProjectPageContent/DataConnectors/ProjectConnectDataConnectorsModal.tsx b/client/src/features/ProjectPageV2/ProjectPageContent/DataConnectors/ProjectConnectDataConnectorsModal.tsx index d727c585cd..f68b2b5d60 100644 --- a/client/src/features/ProjectPageV2/ProjectPageContent/DataConnectors/ProjectConnectDataConnectorsModal.tsx +++ b/client/src/features/ProjectPageV2/ProjectPageContent/DataConnectors/ProjectConnectDataConnectorsModal.tsx @@ -23,6 +23,7 @@ import { Bullseye, // eslint-disable-line spellcheck/spell-checker CheckLg, Database, + DatabaseAdd, Globe, Journals, Link45deg, @@ -49,8 +50,10 @@ import { import RtkOrDataServicesError from "~/components/errors/RtkOrDataServicesError"; import ExternalLink from "~/components/ExternalLink"; import RenkuBadge from "~/components/renkuBadge/RenkuBadge"; +import { useGetDataConnectorsStorageAllowByProjectIdQuery } from "~/features/dataConnectorsV2/api/data-connectors.api"; import { useGetProjectsByProjectIdDataConnectorLinksQuery, + useGetProjectsByProjectIdStorageQuery, usePostDataConnectorsByDataConnectorIdProjectLinksMutation, usePostDataConnectorsGlobalMutation, } from "~/features/dataConnectorsV2/api/data-connectors.enhanced-api"; @@ -70,8 +73,11 @@ import ScrollableModal from "../../../../components/modal/ScrollableModal"; import useAppDispatch from "../../../../utils/customHooks/useAppDispatch.hook"; import useAppSelector from "../../../../utils/customHooks/useAppSelector.hook"; import dataConnectorFormSlice from "../../../dataConnectorsV2/state/dataConnectors.slice"; +import PermissionsGuard from "../../../permissionsV2/PermissionsGuard"; import type { Project } from "../../../projectsV2/api/projectV2.api"; import { doiFromUrl } from "../../utils/dataConnectorUtils"; +import useProjectPermissions from "../../utils/useProjectPermissions.hook"; +import ProjectStorageForm from "../ProjectStorage/ProjectStorageForm"; import { DC_LIKELY_DOI_ID, DC_SEARCH_DOI_PREFIX, @@ -89,10 +95,13 @@ interface ProjectConnectDataConnectorsModalProps extends Omit< "dataConnector" | "projectId" > { project: Project; - switchMode?: () => void; + switchMode?: (mode: ProjectConnectDataConnectorMode) => void; } -type ProjectConnectDataConnectorMode = "create" | "search"; +export type ProjectConnectDataConnectorMode = + | "create" + | "search" + | "add-storage"; export default function ProjectConnectDataConnectorsModal({ isOpen, @@ -106,10 +115,9 @@ export default function ProjectConnectDataConnectorsModal({ dispatch(dataConnectorFormSlice.actions.resetTransientState()); originalToggle(); }, [dispatch, originalToggle]); - const switchMode = useCallback(() => { - if (mode === "create") setMode("search"); - else setMode("create"); - }, [mode]); + const switchMode = useCallback((mode: ProjectConnectDataConnectorMode) => { + setMode(mode); + }, []); return ( - ) : ( + ) : mode === "search" ? ( - )} + ) : mode === "add-storage" ? ( + + ) : null} ); } @@ -196,10 +214,21 @@ function ProjectCreateDataConnectorBodyAndFooter({ export function ProjectConnectDataConnectorModeSwitch({ mode, switchMode, + project, }: { mode: ProjectConnectDataConnectorMode; - switchMode: () => void; + switchMode: (mode: ProjectConnectDataConnectorMode) => void; + project: Project; }) { + const permissions = useProjectPermissions({ projectId: project.id }); + const { data: storageAllowData } = + useGetDataConnectorsStorageAllowByProjectIdQuery({ + projectId: project.id, + }); + const { data: projectStorage } = useGetProjectsByProjectIdStorageQuery({ + projectId: project.id, + }); + return ( switchMode("search")} /> ); } +function ProjectStorageDataConnectorBodyAndFooter({ + isOpen, + project, + switchMode, + toggle, +}: ProjectConnectDataConnectorsModalProps) { + useEffect(() => { + if (!isOpen) { + return; + } + }, [isOpen]); + + return ( + + {switchMode && ( +
+ +
+ )} + +
+ ); +} + function ProjectSearchDataConnectorBodyAndFooter({ isOpen, project, @@ -517,6 +611,7 @@ function ProjectSearchDataConnectorBodyAndFooter({ )} @@ -851,5 +946,5 @@ function DataConnectorSearchSourceBadge({ ); - return

{badgeText}

; + return
{badgeText}
; } diff --git a/client/src/features/ProjectPageV2/ProjectPageContent/DataConnectors/ProjectDataConnectorsBox.tsx b/client/src/features/ProjectPageV2/ProjectPageContent/DataConnectors/ProjectDataConnectorsBox.tsx index a3640911a1..79046df915 100644 --- a/client/src/features/ProjectPageV2/ProjectPageContent/DataConnectors/ProjectDataConnectorsBox.tsx +++ b/client/src/features/ProjectPageV2/ProjectPageContent/DataConnectors/ProjectDataConnectorsBox.tsx @@ -32,11 +32,13 @@ import { import { type DataConnectorToProjectLink, type GetProjectsByProjectIdDataConnectorLinksApiResponse, + type ProjectStorage, } from "~/features/dataConnectorsV2/api/data-connectors.api"; import { useGetDataConnectorsByDataConnectorIdQuery, useGetProjectsByProjectIdDataConnectorLinksQuery, useGetProjectsByProjectIdInaccessibleDataConnectorLinksQuery, + useGetProjectsByProjectIdStorageQuery, } from "~/features/dataConnectorsV2/api/data-connectors.enhanced-api"; import { ErrorAlert } from "../../../../components/Alert"; import RtkOrDataServicesError from "../../../../components/errors/RtkOrDataServicesError"; @@ -47,6 +49,7 @@ import DataConnectorBoxListDisplay, { import PermissionsGuard from "../../../permissionsV2/PermissionsGuard"; import type { Project } from "../../../projectsV2/api/projectV2.api"; import useProjectPermissions from "../../utils/useProjectPermissions.hook"; +import ProjectStorageLinkDisplay from "../ProjectStorage/ProjectStorageLinkDisplay"; import ProjectConnectDataConnectorsModal from "./ProjectConnectDataConnectorsModal"; interface DataConnectorListDisplayProps { @@ -68,13 +71,31 @@ export default function ProjectDataConnectorsBox({ projectId: project.id, }); - if (isLoading || inaccessibleDataConnectorsIsLoading) + const { + data: projectStorageData, + error: projectStorageError, + isLoading: projectStorageIsLoading, + } = useGetProjectsByProjectIdStorageQuery({ + projectId: project.id, + }); + + if ( + isLoading || + inaccessibleDataConnectorsIsLoading || + projectStorageIsLoading + ) return ; if (error) { return ; } + if (projectStorageError) { + return ( + + ); + } + if (data == null) { return ( @@ -91,6 +112,7 @@ export default function ProjectDataConnectorsBox({ inaccessibleDataConnectorsCount={ inaccessibleDataConnectorsData?.count || 0 } + projectStorageData={projectStorageData} /> ); } @@ -98,27 +120,32 @@ export default function ProjectDataConnectorsBox({ interface ProjectDataConnectorBoxContentProps extends DataConnectorListDisplayProps { data: GetProjectsByProjectIdDataConnectorLinksApiResponse; inaccessibleDataConnectorsCount: number; + projectStorageData?: ProjectStorage[]; } function ProjectDataConnectorBoxContent({ data, project, inaccessibleDataConnectorsCount, + projectStorageData, }: ProjectDataConnectorBoxContentProps) { const [isModalOpen, setModalOpen] = useState(false); const toggleOpen = useCallback(() => { setModalOpen((open) => !open); }, []); + const accessibleDataConnectorsCount = + data.length + (projectStorageData?.length ?? 0); + return (
- {data.length === 0 && ( + {accessibleDataConnectorsCount === 0 && (

Add published datasets from data repositories, and connect to cloud storage to read and write custom data. @@ -135,6 +162,13 @@ function ProjectDataConnectorBoxContent({ ))} )} + {projectStorageData && projectStorageData.length > 0 && ( + + {projectStorageData.map((ps, index) => ( + + ))} + + )} {isModalOpen && ( diff --git a/client/src/features/ProjectPageV2/ProjectPageContent/ProjectStorage/ProjectStorageForm.tsx b/client/src/features/ProjectPageV2/ProjectPageContent/ProjectStorage/ProjectStorageForm.tsx new file mode 100644 index 0000000000..87e95fc2be --- /dev/null +++ b/client/src/features/ProjectPageV2/ProjectPageContent/ProjectStorage/ProjectStorageForm.tsx @@ -0,0 +1,258 @@ +/*! + * Copyright 2026 - 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 { PlusLg, XLg } from "react-bootstrap-icons"; +import { Controller, useForm } from "react-hook-form"; +import { + Button, + Form, + FormText, + Input, + InputGroup, + InputGroupText, + Label, + UncontrolledTooltip, +} from "reactstrap"; + +import { InfoAlert } from "~/components/Alert"; +import RtkOrDataServicesError from "~/components/errors/RtkOrDataServicesError"; +import { + useGetDataConnectorsStorageAllowByProjectIdQuery, + type ProjectStorage, +} from "~/features/dataConnectorsV2/api/data-connectors.api"; +import { + usePatchDataConnectorsStorageByStorageIdMutation, + usePostDataConnectorsStorageMutation, +} from "~/features/dataConnectorsV2/api/data-connectors.enhanced-api"; +import { + PROJECT_STORAGE_DEFAULT_GB, + PROJECT_STORAGE_DEFAULT_MOUNT_PATH, + PROJECT_STORAGE_MAX_GB, + PROJECT_STORAGE_MIN_GB, + PROJECT_STORAGE_STEP_GB, +} from "./projectStorage.constants"; + +interface ProjectStorageFormValues { + size: number; + mountPath: string; +} + +interface ProjectStorageFormProps { + projectId: string; + namespace?: string; + projectStorage?: ProjectStorage; + toggle: () => void; +} + +export default function ProjectStorageForm({ + projectId, + namespace, + projectStorage, + toggle, +}: ProjectStorageFormProps) { + const { + control, + formState: { errors }, + handleSubmit, + } = useForm({ + mode: "onChange", + defaultValues: { + size: projectStorage?.size ?? PROJECT_STORAGE_DEFAULT_GB, + mountPath: + projectStorage?.mount_path ?? PROJECT_STORAGE_DEFAULT_MOUNT_PATH, + }, + }); + + const [postDataConnectorsStorageMutation, postDataConnectorsStorageStatus] = + usePostDataConnectorsStorageMutation(); + const [ + patchDataConnectorsStorageByStorageIdMutation, + patchDataConnectorsStorageByStorageIdStatus, + ] = usePatchDataConnectorsStorageByStorageIdMutation(); + const { data: storageAllowData } = + useGetDataConnectorsStorageAllowByProjectIdQuery({ + projectId: projectId, + }); + const projectStorageMaxSize = + storageAllowData?.max_size ?? PROJECT_STORAGE_MAX_GB; + + const onSubmit = async (values: ProjectStorageFormValues) => { + if (!projectStorage) { + // Create new project storage + const result = await postDataConnectorsStorageMutation({ + projectStoragePost: { + namespace: namespace ?? "", + size: values.size, + mount_path: values.mountPath, + }, + }); + if (!result.error) { + toggle(); + } + } else { + // Update existing project storage + const result = await patchDataConnectorsStorageByStorageIdMutation({ + storageId: projectStorage.id, + "If-Match": projectStorage.etag ?? "", + projectStoragePatch: { + size: values.size, + mount_path: values.mountPath, + }, + }); + if (!result.error) { + toggle(); + } + } + }; + + return ( +

+ <> + + {!projectStorage && ( + + You can add a project storage to this project. This will create a + new storage volume that will be mounted in your sessions to avoid + data loss on session shutdown. + + )} +
+ + ( + <> + + { + if (isNaN(event.target.valueAsNumber)) { + field.onChange(event.target.value); + } else { + field.onChange(event.target.valueAsNumber); + } + }} + /> + + GB + + + Gigabytes + + +
+ {error?.message || + "Please provide a valid value for project storage."} +
+ + Default: {PROJECT_STORAGE_DEFAULT_GB} GB, max:{" "} + {projectStorageMaxSize} GB + + + )} + rules={{ + required: true, + min: { + value: PROJECT_STORAGE_MIN_GB, + message: `Please select a value greater than or equal to ${PROJECT_STORAGE_MIN_GB}.`, + }, + max: { + value: projectStorageMaxSize, + message: `Selected project storage exceeds maximum allowed value (${projectStorageMaxSize} GB).`, + }, + validate: { + integer: (value: unknown) => + Number.isInteger(Number(value)) || + "Please provide an integer value.", + }, + }} + /> +
+
+
+ +
+ ( + + )} + rules={{ required: true }} + /> +
Please provide a mount point.
+
+ This is the name of the folder in the working directory where you + will find your project storage in sessions. You can either specify + an absolute path (starting with `/`) or a relative path (relative to + your session's working directory). +
+
+ +
+ + +
+ + + ); +} diff --git a/client/src/features/ProjectPageV2/ProjectPageContent/ProjectStorage/ProjectStorageLinkDisplay.tsx b/client/src/features/ProjectPageV2/ProjectPageContent/ProjectStorage/ProjectStorageLinkDisplay.tsx new file mode 100644 index 0000000000..5e1f2fd91d --- /dev/null +++ b/client/src/features/ProjectPageV2/ProjectPageContent/ProjectStorage/ProjectStorageLinkDisplay.tsx @@ -0,0 +1,339 @@ +/*! + * Copyright 2026 - 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 { Pencil, Trash, TrashFill, XLg } from "react-bootstrap-icons"; +import { Link, To, useLocation } from "react-router"; +import { + Button, + Col, + DropdownItem, + ListGroupItem, + Modal, + ModalBody, + ModalFooter, + ModalHeader, + Row, +} from "reactstrap"; + +import { WarnAlert } from "~/components/Alert"; +import { ButtonWithMenuV2 } from "~/components/buttons/Button"; +import RtkOrDataServicesError from "~/components/errors/RtkOrDataServicesError"; +import { type ProjectStorage } from "~/features/dataConnectorsV2/api/data-connectors.api"; +import { useDeleteDataConnectorsStorageByStorageIdMutation } from "~/features/dataConnectorsV2/api/data-connectors.enhanced-api"; +import useLocationHash from "~/utils/customHooks/useLocationHash.hook"; +import useProjectPermissions from "../../utils/useProjectPermissions.hook"; +import ProjectStorageForm from "./ProjectStorageForm"; +import ProjectStorageView from "./ProjectStorageView"; + +export default function ProjectStorageLinkDisplay({ + projectStorage, +}: { + projectStorage: ProjectStorage; +}) { + // Handle hash + const [hash, setHash] = useLocationHash(); + const dcHash = useMemo( + () => `project-storage-${projectStorage.id}`, + [projectStorage.id], + ); + const showOffCanvas = useMemo(() => hash === dcHash, [dcHash, hash]); + const toggleOffCanvas = useCallback(() => { + setHash((prev) => { + const isOpen = prev === dcHash; + return isOpen ? "" : dcHash; + }); + }, [dcHash, setHash]); + + // Handle url with Hash + const location = useLocation(); + const targetOffcanvasLocation: To = { + pathname: location.pathname, + search: location.search, + hash: `#${dcHash}`, + }; + + const [isEditOpen, setIsEditOpen] = useState(false); + const toggleEdit = useCallback(() => { + setIsEditOpen((open) => !open); + }, []); + + return ( + <> + + + + +
+ + Project storage + +
+ + {/* This column is a placeholder to reserve the space for the action button */} + + + +
+ + {/* The action button is visually positioned over the previous placeholder column */} +
+ +
+
+ + + + ); +} + +interface ProjectStorageActionsProps { + projectStorage: ProjectStorage; + toggleView?: () => void; + toggleEdit: () => void; +} + +export function ProjectStorageActions({ + projectStorage, + toggleView, + toggleEdit, +}: ProjectStorageActionsProps) { + const [isDeleteOpen, setIsDeleteOpen] = useState(false); + const toggleDelete = useCallback(() => { + setIsDeleteOpen((open) => !open); + }, []); + const onDeleteSuccess = useCallback(() => { + if (toggleView) toggleView(); + setIsDeleteOpen(false); + }, [toggleView]); + + const permissions = useProjectPermissions({ + projectId: projectStorage.project_id, + }); + + // Display actions only if user is project owner + if (!permissions.delete) { + return null; + } + + const actions = [ + { + key: "project-storage-edit", + onClick: toggleEdit, + content: ( + <> + + Edit + + ), + }, + { + key: "project-storage-delete", + onClick: toggleDelete, + content: ( + <> + + Delete + + ), + }, + ]; + + const actionsContent = + actions.length === 0 ? null : actions.length === 1 ? ( + + ) : ( + + {actions[0].content} + + } + size="sm" + > + {actions.slice(1).map(({ key, onClick, content }) => ( + + {content} + + ))} + + ); + + return ( + <> + {actionsContent} + + + ); +} + +interface DeleteProjectStorageModalProps { + isOpen: boolean; + storageId: string; + toggle: () => void; + executeOnSuccess: () => void; +} + +function DeleteProjectStorageModal({ + isOpen, + storageId, + toggle, + executeOnSuccess, +}: DeleteProjectStorageModalProps) { + const [deleteStorage, result] = + useDeleteDataConnectorsStorageByStorageIdMutation(); + const onDelete = useCallback(() => { + deleteStorage({ storageId }); + }, [deleteStorage, storageId]); + + useEffect(() => { + if (result.isSuccess) { + executeOnSuccess(); + } + }, [result.isSuccess, executeOnSuccess]); + + return ( + + + Are you sure? + + + {result.error && } + +

+ Please confirm that you want to remove this project storage. +

+ +

+ This action cannot be undone. All data stored in this project + storage will be permanently deleted. +

+
+
+ + + + +
+ ); +} + +interface EditProjectStorageModalProps { + isOpen: boolean; + toggle: () => void; + projectStorage: ProjectStorage; +} + +export function EditProjectStorageModal({ + isOpen, + toggle, + projectStorage, +}: EditProjectStorageModalProps) { + return ( + + + Edit Project Storage + + + + + + ); +} diff --git a/client/src/features/ProjectPageV2/ProjectPageContent/ProjectStorage/ProjectStorageView.tsx b/client/src/features/ProjectPageV2/ProjectPageContent/ProjectStorage/ProjectStorageView.tsx new file mode 100644 index 0000000000..cf9b96f9ba --- /dev/null +++ b/client/src/features/ProjectPageV2/ProjectPageContent/ProjectStorage/ProjectStorageView.tsx @@ -0,0 +1,151 @@ +/*! + * Copyright 2026 - 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, useRef, useState } from "react"; +import { InfoCircle } from "react-bootstrap-icons"; +import { + Card, + CardBody, + CardHeader, + Offcanvas, + OffcanvasBody, + UncontrolledTooltip, +} from "reactstrap"; + +import OffcanvasHeaderWithType from "~/components/offcanvas/OffcanvasHeaderWithType"; +import OffcanvasTopButtons from "~/components/offcanvas/OffcanvasTopButtons"; +import type { ProjectStorage } from "~/features/dataConnectorsV2/api/data-connectors.api"; +import { InfoEntry } from "~/features/dataConnectorsV2/components/DataConnectorInfoBox"; +import { + EditProjectStorageModal, + ProjectStorageActions, +} from "./ProjectStorageLinkDisplay"; + +interface ProjectStorageViewProps { + projectStorage: ProjectStorage; + showView: boolean; + toggleView: () => void; +} + +export default function ProjectStorageView({ + projectStorage, + showView, + toggleView, +}: ProjectStorageViewProps) { + const [isEditOpen, setIsEditOpen] = useState(false); + + const toggleEdit = useCallback(() => { + setIsEditOpen((open) => !open); + }, []); + + return ( + + + + +
+ + + +
+
+ +
+ ); +} + +function ProjectStorageViewHeader({ + projectStorage, + toggleView, + toggleEdit, +}: { + projectStorage: ProjectStorage; + toggleView: () => void; + toggleEdit: () => void; +}) { + return ( + } + entityName=" " + title="Project Storage" + > + + + ); +} + +function ProjectStorageInfoBox({ + projectStorage, + headerTag = "h2", +}: { + projectStorage: ProjectStorage; + headerTag?: "h2" | "h3" | "h4"; +}) { + return ( + + + + + Info + + + + {projectStorage.size} GB + } dataCy="mount-point"> + {projectStorage.mount_path} + + + + ); +} + +function MountPointHead() { + const ref = useRef(null); + return ( + <> + Mount Point + + + + + This is where the project storage will be mounted during sessions. + + + ); +} diff --git a/client/src/features/ProjectPageV2/ProjectPageContent/ProjectStorage/projectStorage.constants.ts b/client/src/features/ProjectPageV2/ProjectPageContent/ProjectStorage/projectStorage.constants.ts new file mode 100644 index 0000000000..e4bc472c2d --- /dev/null +++ b/client/src/features/ProjectPageV2/ProjectPageContent/ProjectStorage/projectStorage.constants.ts @@ -0,0 +1,5 @@ +export const PROJECT_STORAGE_MIN_GB = 1; +export const PROJECT_STORAGE_MAX_GB = 10; +export const PROJECT_STORAGE_STEP_GB = 1; +export const PROJECT_STORAGE_DEFAULT_GB = 1; +export const PROJECT_STORAGE_DEFAULT_MOUNT_PATH = "store"; diff --git a/client/src/features/dataConnectorsV2/api/data-connectors.api.ts b/client/src/features/dataConnectorsV2/api/data-connectors.api.ts index 9322ece3f3..c498d7aa41 100644 --- a/client/src/features/dataConnectorsV2/api/data-connectors.api.ts +++ b/client/src/features/dataConnectorsV2/api/data-connectors.api.ts @@ -23,6 +23,103 @@ const injectedRtkApi = api.injectEndpoints({ body: queryArg.dataConnectorPost, }), }), + postDataConnectorsStorage: build.mutation< + PostDataConnectorsStorageApiResponse, + PostDataConnectorsStorageApiArg + >({ + query: (queryArg) => ({ + url: `/data_connectors/storage`, + method: "POST", + body: queryArg.projectStoragePost, + }), + }), + getDataConnectorsStorageConfig: build.query< + GetDataConnectorsStorageConfigApiResponse, + GetDataConnectorsStorageConfigApiArg + >({ + query: () => ({ url: `/data_connectors/storage/config` }), + }), + getDataConnectorsStorageAllow: build.query< + GetDataConnectorsStorageAllowApiResponse, + GetDataConnectorsStorageAllowApiArg + >({ + query: (queryArg) => ({ + url: `/data_connectors/storage/allow`, + params: { + params: queryArg.params, + }, + }), + }), + postDataConnectorsStorageAllow: build.mutation< + PostDataConnectorsStorageAllowApiResponse, + PostDataConnectorsStorageAllowApiArg + >({ + query: (queryArg) => ({ + url: `/data_connectors/storage/allow`, + method: "POST", + body: queryArg.projectStorageAllowPost, + }), + }), + getDataConnectorsStorageAllowByProjectId: build.query< + GetDataConnectorsStorageAllowByProjectIdApiResponse, + GetDataConnectorsStorageAllowByProjectIdApiArg + >({ + query: (queryArg) => ({ + url: `/data_connectors/storage/allow/${queryArg.projectId}`, + }), + }), + patchDataConnectorsStorageAllowByProjectId: build.mutation< + PatchDataConnectorsStorageAllowByProjectIdApiResponse, + PatchDataConnectorsStorageAllowByProjectIdApiArg + >({ + query: (queryArg) => ({ + url: `/data_connectors/storage/allow/${queryArg.projectId}`, + method: "PATCH", + body: queryArg.projectStorageAllowPatch, + headers: { + "If-Match": queryArg["If-Match"], + }, + }), + }), + deleteDataConnectorsStorageAllowByProjectId: build.mutation< + DeleteDataConnectorsStorageAllowByProjectIdApiResponse, + DeleteDataConnectorsStorageAllowByProjectIdApiArg + >({ + query: (queryArg) => ({ + url: `/data_connectors/storage/allow/${queryArg.projectId}`, + method: "DELETE", + }), + }), + getDataConnectorsStorageByStorageId: build.query< + GetDataConnectorsStorageByStorageIdApiResponse, + GetDataConnectorsStorageByStorageIdApiArg + >({ + query: (queryArg) => ({ + url: `/data_connectors/storage/${queryArg.storageId}`, + }), + }), + patchDataConnectorsStorageByStorageId: build.mutation< + PatchDataConnectorsStorageByStorageIdApiResponse, + PatchDataConnectorsStorageByStorageIdApiArg + >({ + query: (queryArg) => ({ + url: `/data_connectors/storage/${queryArg.storageId}`, + method: "PATCH", + body: queryArg.projectStoragePatch, + headers: { + "If-Match": queryArg["If-Match"], + }, + }), + }), + deleteDataConnectorsStorageByStorageId: build.mutation< + DeleteDataConnectorsStorageByStorageIdApiResponse, + DeleteDataConnectorsStorageByStorageIdApiArg + >({ + query: (queryArg) => ({ + url: `/data_connectors/storage/${queryArg.storageId}`, + method: "DELETE", + }), + }), postDataConnectorsGlobal: build.mutation< PostDataConnectorsGlobalApiResponse, PostDataConnectorsGlobalApiArg @@ -171,6 +268,12 @@ const injectedRtkApi = api.injectEndpoints({ }, }), }), + getProjectsByProjectIdStorage: build.query< + GetProjectsByProjectIdStorageApiResponse, + GetProjectsByProjectIdStorageApiArg + >({ + query: (queryArg) => ({ url: `/projects/${queryArg.projectId}/storage` }), + }), getProjectsByProjectIdDataConnectorLinks: build.query< GetProjectsByProjectIdDataConnectorLinksApiResponse, GetProjectsByProjectIdDataConnectorLinksApiArg @@ -260,6 +363,59 @@ export type PostDataConnectorsApiResponse = export type PostDataConnectorsApiArg = { dataConnectorPost: DataConnectorPost; }; +export type PostDataConnectorsStorageApiResponse = + /** status 201 The data connector was created */ ProjectStorage; +export type PostDataConnectorsStorageApiArg = { + projectStoragePost: ProjectStoragePost; +}; +export type GetDataConnectorsStorageConfigApiResponse = + /** status 200 The configuration data */ ProjectStorageConfig; +export type GetDataConnectorsStorageConfigApiArg = void; +export type GetDataConnectorsStorageAllowApiResponse = + /** status 200 List of storage allow entries */ ProjectStorageAllowList; +export type GetDataConnectorsStorageAllowApiArg = { + /** query parameters */ + params?: ProjectStorageAllowListQuery; +}; +export type PostDataConnectorsStorageAllowApiResponse = + /** status 201 The project was added to the allow list */ ProjectStorageAllowPost; +export type PostDataConnectorsStorageAllowApiArg = { + projectStorageAllowPost: ProjectStorageAllowPost; +}; +export type GetDataConnectorsStorageAllowByProjectIdApiResponse = + /** status 200 The project storage allow entry */ ProjectStorageAllow; +export type GetDataConnectorsStorageAllowByProjectIdApiArg = { + projectId: Ulid; +}; +export type PatchDataConnectorsStorageAllowByProjectIdApiResponse = + /** status 200 The patched project storage allow entry */ ProjectStorageAllow; +export type PatchDataConnectorsStorageAllowByProjectIdApiArg = { + projectId: Ulid; + /** If-Match header, for avoiding mid-air collisions */ + "If-Match": ETag; + projectStorageAllowPatch: ProjectStorageAllowPatch; +}; +export type DeleteDataConnectorsStorageAllowByProjectIdApiResponse = unknown; +export type DeleteDataConnectorsStorageAllowByProjectIdApiArg = { + projectId: Ulid; +}; +export type GetDataConnectorsStorageByStorageIdApiResponse = + /** status 200 The project storage information */ ProjectStorage; +export type GetDataConnectorsStorageByStorageIdApiArg = { + storageId: Ulid; +}; +export type PatchDataConnectorsStorageByStorageIdApiResponse = + /** status 200 The patched project storage entry */ ProjectStorage; +export type PatchDataConnectorsStorageByStorageIdApiArg = { + storageId: Ulid; + /** If-Match header, for avoiding mid-air collisions */ + "If-Match": ETag; + projectStoragePatch: ProjectStoragePatch; +}; +export type DeleteDataConnectorsStorageByStorageIdApiResponse = unknown; +export type DeleteDataConnectorsStorageByStorageIdApiArg = { + storageId: Ulid; +}; export type PostDataConnectorsGlobalApiResponse = /** status 200 The data connector already exists */ | DataConnectorRead @@ -365,6 +521,11 @@ export type GetDataConnectorsByDataConnectorIdDepositsApiArg = { /** Query parameters */ params?: PaginationRequest; }; +export type GetProjectsByProjectIdStorageApiResponse = + /** status 200 The list of project storages (currently either one or empty). */ ProjectStorageList; +export type GetProjectsByProjectIdStorageApiArg = { + projectId: Ulid; +}; export type GetProjectsByProjectIdDataConnectorLinksApiResponse = /** status 200 List of data connector to project links */ DataConnectorToProjectLinksList; export type GetProjectsByProjectIdDataConnectorLinksApiArg = { @@ -596,6 +757,55 @@ export type DataConnectorPostRead = { description?: Description; keywords?: KeywordsList; }; +export type ProjectStorage = { + id: Ulid; + project_id: Ulid; + size: number; + mount_path: string; + creation_date: CreationDate; + created_by: UserId; + updated_at: CreationDate; + etag: ETag; +}; +export type ProjectSlug = string; +export type ProjectStoragePost = { + namespace: ProjectSlug; + size: number; + mount_path: string; +}; +export type ProjectStorageConfig = { + enabled: boolean; + /** The maximum size in GB */ + max_size: number; +}; +export type ProjectStorageAllow = { + project_id: Ulid; + name: string; + namespace: string; + /** Maximum size in GB */ + max_size: number; + etag: ETag; +}; +export type ProjectStorageAllowList = ProjectStorageAllow[]; +export type ProjectStorageAllowListQuery = PaginationRequest & { + /** Filter by project name (partial match). */ + project_name?: string; +}; +export type ProjectStorageAllowPost = { + project_id: Ulid; + /** Maximum size in GB */ + max_size: number; +}; +export type ProjectStorageAllowPatch = { + /** The maximum size in GB */ + max_size?: number; +}; +export type ProjectStoragePatch = { + /** The maximum size in GB */ + size?: number; + /** The mount path for the storage */ + mount_path?: string; +}; export type GlobalDataConnectorPost = { storage: CloudStorageCorePost | CloudStorageUrlV2; }; @@ -691,6 +901,7 @@ export type Deposit = DepositPost & { etag: ETag; }; export type DepositList = Deposit[]; +export type ProjectStorageList = ProjectStorage[]; export type InaccessibleDataConnectorLinks = { /** The number of data links the user does not have access to */ count?: number; @@ -706,6 +917,16 @@ export type DepositLogs = { export const { useGetDataConnectorsQuery, usePostDataConnectorsMutation, + usePostDataConnectorsStorageMutation, + useGetDataConnectorsStorageConfigQuery, + useGetDataConnectorsStorageAllowQuery, + usePostDataConnectorsStorageAllowMutation, + useGetDataConnectorsStorageAllowByProjectIdQuery, + usePatchDataConnectorsStorageAllowByProjectIdMutation, + useDeleteDataConnectorsStorageAllowByProjectIdMutation, + useGetDataConnectorsStorageByStorageIdQuery, + usePatchDataConnectorsStorageByStorageIdMutation, + useDeleteDataConnectorsStorageByStorageIdMutation, usePostDataConnectorsGlobalMutation, useGetDataConnectorLinksQuery, useGetDataConnectorsByDataConnectorIdQuery, @@ -722,6 +943,7 @@ export const { usePatchDataConnectorsByDataConnectorIdSecretsMutation, useDeleteDataConnectorsByDataConnectorIdSecretsMutation, useGetDataConnectorsByDataConnectorIdDepositsQuery, + useGetProjectsByProjectIdStorageQuery, useGetProjectsByProjectIdDataConnectorLinksQuery, useGetProjectsByProjectIdInaccessibleDataConnectorLinksQuery, usePostDepositsMutation, diff --git a/client/src/features/dataConnectorsV2/api/data-connectors.enhanced-api.ts b/client/src/features/dataConnectorsV2/api/data-connectors.enhanced-api.ts index ee56c7fa39..3e37e5d1bb 100644 --- a/client/src/features/dataConnectorsV2/api/data-connectors.enhanced-api.ts +++ b/client/src/features/dataConnectorsV2/api/data-connectors.enhanced-api.ts @@ -158,6 +158,7 @@ const enhancedApi = injectedApi.enhanceEndpoints({ "DataConnectors", "DataConnectorsProjectLinks", "DataConnectorSecrets", + "ProjectStorage", ], endpoints: { deleteDataConnectorsByDataConnectorId: { @@ -268,6 +269,18 @@ const enhancedApi = injectedApi.enhanceEndpoints({ ? [{ id: depositId, type: "Deposits" }, "Deposits"] : ["Deposits"], }, + getProjectsByProjectIdStorage: { + providesTags: ["ProjectStorage"], + }, + postDataConnectorsStorage: { + invalidatesTags: ["ProjectStorage"], + }, + deleteDataConnectorsStorageByStorageId: { + invalidatesTags: ["ProjectStorage"], + }, + patchDataConnectorsStorageByStorageId: { + invalidatesTags: ["ProjectStorage"], + }, }, }); @@ -300,4 +313,8 @@ export const { useGetDataConnectorsByDataConnectorIdPermissionsQuery, useGetProjectsByProjectIdDataConnectorLinksQuery, useGetProjectsByProjectIdInaccessibleDataConnectorLinksQuery, + useGetProjectsByProjectIdStorageQuery, + usePostDataConnectorsStorageMutation, + useDeleteDataConnectorsStorageByStorageIdMutation, + usePatchDataConnectorsStorageByStorageIdMutation, } = enhancedApi; diff --git a/client/src/features/dataConnectorsV2/api/data-connectors.openapi.json b/client/src/features/dataConnectorsV2/api/data-connectors.openapi.json index ee3e39d5be..8115a18705 100644 --- a/client/src/features/dataConnectorsV2/api/data-connectors.openapi.json +++ b/client/src/features/dataConnectorsV2/api/data-connectors.openapi.json @@ -103,6 +103,344 @@ "tags": ["data_connectors"] } }, + "/data_connectors/storage": { + "post": { + "summary": "Create a new project storage", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectStoragePost" + } + } + } + }, + "responses": { + "201": { + "description": "The data connector was created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectStorage" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + }, + "tags": ["data_connectors"] + } + }, + "/data_connectors/storage/config": { + "get": { + "summary": "Get the current configuration for project storage", + "responses": { + "200": { + "description": "The configuration data", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectStorageConfig" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + }, + "tags": ["data_connectors"] + } + }, + "/data_connectors/storage/allow": { + "get": { + "summary": "List all projects in the storage allow list", + "parameters": [ + { + "in": "query", + "description": "query parameters", + "name": "params", + "style": "form", + "explode": true, + "schema": { + "$ref": "#/components/schemas/ProjectStorageAllowListQuery" + } + } + ], + "responses": { + "200": { + "description": "List of storage allow entries", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectStorageAllowList" + } + } + }, + "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": "Add a project to the storage allow list", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectStorageAllowPost" + } + } + } + }, + "responses": { + "201": { + "description": "The project was added to the allow list", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectStorageAllowPost" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + }, + "tags": ["data_connectors"] + } + }, + "/data_connectors/storage/allow/{project_id}": { + "parameters": [ + { + "in": "path", + "name": "project_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/Ulid" + } + } + ], + "get": { + "summary": "Get the storage allow entry for a project", + "responses": { + "200": { + "description": "The project storage allow entry", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectStorageAllow" + } + } + } + }, + "404": { + "description": "The project is not in the storage allow list", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + }, + "tags": ["data_connectors"] + }, + "patch": { + "summary": "Change the maximum size for a project storage", + "parameters": [ + { + "$ref": "#/components/parameters/If-Match" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectStorageAllowPatch" + } + } + } + }, + "responses": { + "200": { + "description": "The patched project storage allow entry", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectStorageAllow" + } + } + } + }, + "404": { + "description": "The project storage allow entry doesn't exist", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + }, + "tags": ["data_connectors"] + }, + "delete": { + "summary": "Remove a project from the storage allow list", + "responses": { + "204": { + "description": "The project was removed from the allow list" + }, + "default": { + "$ref": "#/components/responses/Error" + } + }, + "tags": ["data_connectors"] + } + }, + "/data_connectors/storage/{storage_id}": { + "parameters": [ + { + "in": "path", + "name": "storage_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/Ulid" + } + } + ], + "get": { + "summary": "Get a project storage for a project id.", + "responses": { + "200": { + "description": "The project storage information", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectStorage" + } + } + } + }, + "404": { + "description": "The project storage does not exist", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + }, + "tags": ["data_connectors"] + }, + "patch": { + "summary": "Change the size or mount path of a project storage", + "parameters": [ + { + "$ref": "#/components/parameters/If-Match" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectStoragePatch" + } + } + } + }, + "responses": { + "200": { + "description": "The patched project storage entry", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectStorage" + } + } + } + }, + "404": { + "description": "The project storage does not exist", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + }, + "tags": ["data_connectors"] + }, + "delete": { + "summary": "Delete a specific project storage", + "responses": { + "204": { + "description": "The project storage was deleted or did not exist in the first place" + }, + "default": { + "$ref": "#/components/responses/Error" + } + }, + "tags": ["data_connectors"] + } + }, "/data_connectors/global": { "post": { "summary": "Create a new data connector", @@ -737,6 +1075,37 @@ "tags": ["data_connectors"] } }, + "/projects/{project_id}/storage": { + "parameters": [ + { + "in": "path", + "name": "project_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/Ulid" + } + } + ], + "get": { + "summary": "Get the project storage for a given project", + "responses": { + "200": { + "description": "The list of project storages (currently either one or empty).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectStorageList" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + }, + "tags": ["projects"] + } + }, "/projects/{project_id}/data_connector_links": { "parameters": [ { @@ -1001,6 +1370,13 @@ }, "components": { "schemas": { + "ProjectSlug": { + "type": "string", + "description": "The slug used to identify a project", + "minLength": 3, + "example": "user/my-project", + "pattern": "^[a-zA-Z0-9]+([_.\\-/][a-zA-Z0-9]+)*[_.\\-/]?[a-zA-Z0-9]$" + }, "DataConnectorsList": { "description": "A list of data connectors", "type": "array", @@ -1124,6 +1500,171 @@ }, "required": ["storage"] }, + "ProjectStoragePost": { + "description": "A special data connector for associating a shared read+write\nvolume. There can be exactly one such storage per project.\n", + "additionalProperties": false, + "properties": { + "namespace": { + "$ref": "#/components/schemas/ProjectSlug" + }, + "size": { + "type": "integer", + "format": "int32" + }, + "mount_path": { + "type": "string" + } + }, + "required": ["namespace", "size", "mount_path"] + }, + "ProjectStorage": { + "description": "A stored shared storage configuration for a project.\n", + "additionalProperties": false, + "properties": { + "id": { + "$ref": "#/components/schemas/Ulid" + }, + "project_id": { + "$ref": "#/components/schemas/Ulid" + }, + "size": { + "type": "integer", + "format": "int32" + }, + "mount_path": { + "type": "string" + }, + "creation_date": { + "$ref": "#/components/schemas/CreationDate" + }, + "created_by": { + "$ref": "#/components/schemas/UserId" + }, + "updated_at": { + "$ref": "#/components/schemas/CreationDate" + }, + "etag": { + "$ref": "#/components/schemas/ETag" + } + }, + "required": [ + "id", + "project_id", + "size", + "mount_path", + "creation_date", + "created_by", + "updated_at", + "etag" + ] + }, + "ProjectStoragePatch": { + "description": "Data that can be updated on a project storage entry\n", + "type": "object", + "additionalProperties": false, + "properties": { + "size": { + "type": "integer", + "format": "int64", + "description": "The maximum size in GB" + }, + "mount_path": { + "type": "string", + "description": "The mount path for the storage" + } + } + }, + "ProjectStorageAllowPatch": { + "description": "Data that can be updated on a project storage allow entry\n", + "type": "object", + "additionalProperties": false, + "properties": { + "max_size": { + "type": "integer", + "format": "int64", + "description": "The maximum size in GB" + } + } + }, + "ProjectStorageAllowPost": { + "description": "A project to be added to the storage allow list.\n", + "additionalProperties": false, + "properties": { + "project_id": { + "$ref": "#/components/schemas/Ulid" + }, + "max_size": { + "type": "integer", + "format": "int64", + "description": "Maximum size in GB" + } + }, + "required": ["project_id", "max_size"] + }, + "ProjectStorageAllow": { + "description": "A project in the storage allow list.\n", + "additionalProperties": false, + "properties": { + "project_id": { + "$ref": "#/components/schemas/Ulid" + }, + "name": { + "type": "string" + }, + "namespace": { + "type": "string" + }, + "max_size": { + "type": "integer", + "format": "int64", + "description": "Maximum size in GB" + }, + "etag": { + "$ref": "#/components/schemas/ETag" + } + }, + "required": ["project_id", "max_size", "name", "namespace", "etag"] + }, + "ProjectStorageAllowList": { + "description": "A list of project storage allow entries.\n", + "type": "array", + "items": { + "$ref": "#/components/schemas/ProjectStorageAllow" + } + }, + "ProjectStorageAllowListQuery": { + "description": "Query params for listing storage allow entries", + "allOf": [ + { + "$ref": "#/components/schemas/PaginationRequest" + }, + { + "properties": { + "project_name": { + "description": "Filter by project name (partial match).", + "type": "string", + "default": "" + } + } + } + ] + }, + "ProjectStorageConfig": { + "description": "The current configuration for project storage as defined by admins.\n", + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "max_size": { + "type": "integer", + "format": "int64", + "description": "The maximum size in GB" + } + }, + "required": ["enabled", "max_size"] + }, "DataConnectorPatch": { "description": "Patch of a data connector\n", "type": "object", @@ -1279,6 +1820,13 @@ "$ref": "#/components/schemas/DataConnectorToProjectLink" } }, + "ProjectStorageList": { + "description": "A list of project storages.", + "type": "array", + "items": { + "$ref": "#/components/schemas/ProjectStorage" + } + }, "DataConnectorToProjectLink": { "description": "A link from a data connector to a project in Renku 2.0", "type": "object", diff --git a/client/src/features/dataConnectorsV2/components/DataConnectorModal/DataConnectorModalBody.tsx b/client/src/features/dataConnectorsV2/components/DataConnectorModal/DataConnectorModalBody.tsx index 49d0f0cc0c..47c3eabb00 100644 --- a/client/src/features/dataConnectorsV2/components/DataConnectorModal/DataConnectorModalBody.tsx +++ b/client/src/features/dataConnectorsV2/components/DataConnectorModal/DataConnectorModalBody.tsx @@ -36,7 +36,10 @@ import { getSchema, getSchemaOptions, } from "~/features/cloudStorage/projectCloudStorage.utils"; -import { ProjectConnectDataConnectorModeSwitch } from "~/features/ProjectPageV2/ProjectPageContent/DataConnectors/ProjectConnectDataConnectorsModal"; +import { + ProjectConnectDataConnectorModeSwitch, + type ProjectConnectDataConnectorMode, +} from "~/features/ProjectPageV2/ProjectPageContent/DataConnectors/ProjectConnectDataConnectorsModal"; import { ErrorAlert, InfoAlert, WarnAlert } from "../../../../components/Alert"; import ChevronFlippedIcon from "../../../../components/icons/ChevronFlippedIcon"; import { Loader } from "../../../../components/Loader"; @@ -70,7 +73,7 @@ interface AddOrEditDataConnectorProps { dataConnector?: DataConnectorRead | null; project?: Project; storageSecrets: DataConnectorSecret[]; - switchMode?: () => void; + switchMode?: (mode: ProjectConnectDataConnectorMode) => void; } type DataConnectorModalBodyProps = AddOrEditDataConnectorProps; @@ -149,6 +152,7 @@ function AddOrEditDataConnector({
)} @@ -750,7 +754,7 @@ function DataConnectorMount({ dataConnector }: AddOrEditDataConnectorProps) {
- Keywords help orginizing your work and are available to search. You + Keywords help organizing your work and are available to search. You can use them to group elements that belong together or to create specific topics. You can add multiple keywords.
diff --git a/client/src/features/dataConnectorsV2/components/DataConnectorModal/index.tsx b/client/src/features/dataConnectorsV2/components/DataConnectorModal/index.tsx index ea7118724d..022265f274 100644 --- a/client/src/features/dataConnectorsV2/components/DataConnectorModal/index.tsx +++ b/client/src/features/dataConnectorsV2/components/DataConnectorModal/index.tsx @@ -22,6 +22,7 @@ import { useCallback, useEffect } from "react"; import { Database, XLg } from "react-bootstrap-icons"; import { Button, ModalBody, ModalFooter, ModalHeader } from "reactstrap"; +import type { ProjectConnectDataConnectorMode } from "~/features/ProjectPageV2/ProjectPageContent/DataConnectors/ProjectConnectDataConnectorsModal"; import { ErrorAlert } from "../../../../components/Alert"; import RtkOrDataServicesError from "../../../../components/errors/RtkOrDataServicesError"; import { Loader } from "../../../../components/Loader"; @@ -163,7 +164,7 @@ interface DataConnectorModalProps { isOpen: boolean; namespace?: string; project?: Project; - switchMode?: () => void; + switchMode?: (mode: ProjectConnectDataConnectorMode) => void; toggle: (initialStep?: number) => void; } export default function DataConnectorModal({ diff --git a/client/src/features/sessionsV2/components/SessionModals/ShoutdownSessionContent.tsx b/client/src/features/sessionsV2/components/SessionModals/ShoutdownSessionContent.tsx index 222ed3b57d..6a76277487 100644 --- a/client/src/features/sessionsV2/components/SessionModals/ShoutdownSessionContent.tsx +++ b/client/src/features/sessionsV2/components/SessionModals/ShoutdownSessionContent.tsx @@ -24,7 +24,10 @@ import { Collapse } from "reactstrap"; import CollapseBody from "~/components/container/CollapseBody"; import ChevronFlippedIcon from "~/components/icons/ChevronFlippedIcon"; import { useGetProjectsByProjectIdDataConnectorLinksQuery } from "~/features/dataConnectorsV2/api/data-connectors.api"; -import { useGetDataConnectorsListByDataConnectorIdsQuery } from "~/features/dataConnectorsV2/api/data-connectors.enhanced-api"; +import { + useGetDataConnectorsListByDataConnectorIdsQuery, + useGetProjectsByProjectIdStorageQuery, +} from "~/features/dataConnectorsV2/api/data-connectors.enhanced-api"; import { getRepositoryName } from "~/features/ProjectPageV2/ProjectPageContent/CodeRepositories/repositories.utils"; import { useGetProjectsByProjectIdQuery } from "~/features/projectsV2/api/projectV2.enhanced-api"; import { useGetSessionLaunchersByLauncherIdQuery } from "../../api/sessionLaunchersV2.api"; @@ -90,6 +93,26 @@ export default function ShutdownSessionContent({ .map((dc) => dc.storage.target_path); }, [dataConnectorsObjects]); + const { data: projectStorageData = [] } = + useGetProjectsByProjectIdStorageQuery( + sessionProjectId + ? { + projectId: sessionProjectId, + } + : skipToken, + ); + const projectStorage = useMemo(() => { + return projectStorageData.map((storage) => storage.mount_path); + }, [projectStorageData]); + + const hasConnectedStorageOrRepository = useMemo(() => { + return ( + dataConnectors.length > 0 || + codeRepositories.length > 0 || + projectStorage.length > 0 + ); + }, [dataConnectors, codeRepositories, projectStorage]); + // Control collapsible element status const [showDetails, setShowDetails] = useState(false); const toggleShowDetails = useCallback( @@ -119,44 +142,50 @@ export default function ShutdownSessionContent({ alt="announcement for v2" />

Are you sure you want to permanently shut down this session?

-

- - All files will be permanently deleted unless you save them to an - external system first. - {" "} - To preserve your work - {dataConnectors.length <= 0 && codeRepositories.length <= 0 - ? ", consider adding writable data connectors or code repositories to your project." - : ":"} +

+ All files will be permanently deleted unless you save them to an + external system first.

- {dataConnectors.length > 0 || codeRepositories.length > 0 ? ( -
    - {codeRepositories.length > 0 && ( -
  • - Save code changes to your connected repositories:{" "} - {codeRepositories.join(", ")} -
  • - )} - {dataConnectors.length > 0 && ( -
  • - Save files to your connected writeable data connectors:{" "} - {dataConnectors.join(", ")} -
  • - )} -
  • - Download files to your local machine, if available in your session - interface. -
  • -
+ {!hasConnectedStorageOrRepository ? ( + <> +

+ To preserve your work, consider adding writable data connectors or + code repositories to your project. +

+

+ You can still download files to your local machine, if available in + your session interface. +

+ ) : ( -

- You can still download files to your local machine, if available in - your session interface. -

- )} - - {(dataConnectors.length > 0 || codeRepositories.length > 0) && ( <> +

To preserve your work:

+
    + {codeRepositories.length > 0 && ( +
  • + Save code changes to your connected repositories:{" "} + + {codeRepositories.join(", ")} + +
  • + )} + {projectStorage.length > 0 && ( +
  • + Save files to your project storage directory:{" "} + {projectStorage.join(", ")} +
  • + )} + {dataConnectors.length > 0 && ( +
  • + Save files to your connected writeable data connectors:{" "} + {dataConnectors.join(", ")} +
  • + )} +
  • + Download files to your local machine, if available in your session + interface. +
  • +