diff --git a/client/src/features/project/components/ProjectEntityHeader.tsx b/client/src/features/project/components/ProjectEntityHeader.tsx index aa94848d44..04976529a9 100644 --- a/client/src/features/project/components/ProjectEntityHeader.tsx +++ b/client/src/features/project/components/ProjectEntityHeader.tsx @@ -17,6 +17,7 @@ */ import { skipToken } from "@reduxjs/toolkit/query"; +import { ACCESS_LEVELS } from "../../../api-client"; import type { EntityHeaderProps } from "../../../components/entityHeader/EntityHeader"; import EntityHeader from "../../../components/entityHeader/EntityHeader"; @@ -27,15 +28,24 @@ import { useProjectMetadataQuery, } from "../projectKg.api"; import { ProjectStatusIcon } from "./migrations/ProjectStatusIcon"; +import { ProjectEntityMigration } from "./projectMigration/ProjectEntityMigration"; type ProjectEntityHeaderProps = EntityHeaderProps & { defaultBranch: string; projectId: number; + accessLevel: number; }; export function ProjectEntityHeader(props: ProjectEntityHeaderProps) { - const { defaultBranch, devAccess, fullPath, gitUrl, projectId, visibility } = - props; + const { + defaultBranch, + devAccess, + fullPath, + gitUrl, + projectId, + visibility, + accessLevel, + } = props; const projectIndexingStatus = useGetProjectIndexingStatusQuery( fullPath && projectId ? projectId : skipToken @@ -73,13 +83,22 @@ export function ProjectEntityHeader(props: ProjectEntityHeaderProps) { ); return ( - + <> + {accessLevel >= ACCESS_LEVELS.OWNER && visibility === "public" && ( + + )} + + ); } diff --git a/client/src/features/project/components/projectMigration/ProjectEntityMigration.tsx b/client/src/features/project/components/projectMigration/ProjectEntityMigration.tsx new file mode 100644 index 0000000000..ee5837f316 --- /dev/null +++ b/client/src/features/project/components/projectMigration/ProjectEntityMigration.tsx @@ -0,0 +1,375 @@ +/*! + * Copyright 2025 - 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 { DateTime } from "luxon"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { XLg } from "react-bootstrap-icons"; +import { useForm } from "react-hook-form"; +import { generatePath, Link } from "react-router-dom-v5-compat"; +import { + Button, + Form, + Modal, + ModalBody, + ModalFooter, + ModalHeader, +} from "reactstrap"; +import { + ErrorAlert, + InfoAlert, + SuccessAlert, + WarnAlert, +} from "../../../../components/Alert"; +import { RtkErrorAlert } from "../../../../components/errors/RtkErrorAlert"; +import { ExternalLink } from "../../../../components/ExternalLinks"; +import { Loader } from "../../../../components/Loader"; +import { ABSOLUTE_ROUTES } from "../../../../routing/routes.constants"; +import { Links } from "../../../../utils/constants/Docs.js"; +import useLegacySelector from "../../../../utils/customHooks/useLegacySelector.hook"; +import { toHumanDateTime } from "../../../../utils/helpers/DateTimeUtils"; +import { + RepositoriesList, + useGetRenkuV1ProjectsByV1IdMigrationsQuery, + usePostRenkuV1ProjectsByV1IdMigrationsMutation, +} from "../../../projectsV2/api/projectV2.api"; +import { useGetSessionLauncherData } from "../../hook/useGetSessionLauncherData"; +import { + ProjectMetadata, + ProjectMigrationForm, +} from "./ProjectMigration.types"; +import { + DetailsMigration, + DetailsNotIncludedInMigration, +} from "./ProjectMigrationDetails"; +import { ProjectMigrationFormInputs } from "./ProjectMigrationForm"; + +interface ProjectEntityMigrationProps { + projectId: number; + description?: { isLoading?: boolean; unavailable?: string; value: string }; + tagList: string[]; +} +export function ProjectEntityMigration({ + projectId, + description, + tagList, +}: ProjectEntityMigrationProps) { + const [isOpenModal, setIsOpenModal] = useState(false); + + const { + data: projectMigration, + isFetching: isFetchingMigrations, + isLoading: isLoadingMigrations, + refetch: refetchMigrations, + } = useGetRenkuV1ProjectsByV1IdMigrationsQuery({ v1Id: projectId }); + + const linkToProject = useMemo(() => { + return projectMigration + ? generatePath(ABSOLUTE_ROUTES.v2.projects.show.root, { + namespace: projectMigration.namespace, + slug: projectMigration.slug, + }) + : ""; + }, [projectMigration]); + + const projectMetadata = useLegacySelector( + (state) => state.stateModel.project.metadata + ); + + useEffect(() => { + if (!isOpenModal) { + refetchMigrations(); + } + }, [isOpenModal, refetchMigrations]); + + const toggle = useCallback(() => { + setIsOpenModal((open) => !open); + }, []); + + if (isFetchingMigrations || isLoadingMigrations) return ; + + if (projectMigration) + return ( + +

+ This project has been migrated to a newer version of Renku, Renku 2.0 +

+
+ + Go to the 2.0 version of the project + + +
+
+ ); + + return ( + <> + +

This project can be migrated to Renku 2.0

+
+ + +
+
+ + + ); +} + +function MigrationModal({ + isOpen, + toggle, + projectMetadata, + description, + tagList, +}: { + isOpen: boolean; + toggle: () => void; + projectMetadata: ProjectMetadata; + description?: string; + tagList: string[]; +}) { + const { + control, + formState: { dirtyFields, errors }, + handleSubmit, + watch, + setValue, + } = useForm({ + defaultValues: { + name: projectMetadata.title, + namespace: "", + slug: projectMetadata.path, + visibility: + projectMetadata.visibility === "public" ? "public" : "private", + }, + }); + const [migrateProject, result] = + usePostRenkuV1ProjectsByV1IdMigrationsMutation(); + const { + registryTag, + isFetchingData, + projectConfig, + branch, + commits, + templateName, + resourcePools, + isProjectSupported, + } = useGetSessionLauncherData(); + + const isPinnedImage = !!projectConfig?.config?.sessions?.dockerImage; + + const containerImage = useMemo(() => { + return ( + projectConfig?.config?.sessions?.dockerImage ?? registryTag?.location + ); + }, [projectConfig, registryTag]); + + const defaultSessionClass = useMemo( + () => + resourcePools?.flatMap((pool) => pool.classes).find((c) => c.default) ?? + resourcePools?.find(() => true)?.classes[0] ?? + undefined, + [resourcePools] + ); + + const resourceClass = useMemo(() => { + return ( + resourcePools + ?.flatMap((pool) => pool.classes) + .find((c) => c.id == defaultSessionClass?.id && c.matching) ?? + resourcePools + ?.filter((pool) => pool.default) + .flatMap((pool) => pool.classes) + .find((c) => c.matching) ?? + resourcePools + ?.flatMap((pool) => pool.classes) + .find((c) => c.id == defaultSessionClass?.id) ?? + undefined + ); + }, [resourcePools, defaultSessionClass]); + + const linkToProject = useMemo(() => { + return result?.data + ? generatePath(ABSOLUTE_ROUTES.v2.projects.show.root, { + namespace: result.data.namespace, + slug: result.data.slug, + }) + : ""; + }, [result.data]); + + const onSubmit = useCallback( + (data: ProjectMigrationForm) => { + if (!containerImage) return; + const nowFormatted = toHumanDateTime({ datetime: DateTime.now() }); + const dataMigration = { + project: { + name: data.name, + namespace: data.namespace, + slug: data.slug, + visibility: data.visibility, + description: description, + keywords: tagList, + repositories: [projectMetadata.httpUrl ?? ""] as RepositoriesList, + }, + session_launcher: { + container_image: containerImage, + name: `${templateName ?? data.name} ${nowFormatted}`, + default_url: projectConfig?.config?.sessions?.defaultUrl ?? "", + resource_class_id: resourceClass?.id, + }, + }; + migrateProject({ + projectMigrationPost: dataMigration, + v1Id: parseInt(projectMetadata.id), + }); + }, + [ + migrateProject, + projectMetadata.id, + projectMetadata.httpUrl, + tagList, + description, + projectConfig, + containerImage, + templateName, + resourceClass, + ] + ); + + return ( + +
+ Migrate project to Renku 2.0 + + {result.error && } + {!result.data && ( + + )} + {isFetchingData && ( +
+ Loading session data... +
+ )} + {!result.data && !isFetchingData && ( + <> + + + + )} + {result?.data && ( + +

This project has been successfully migrated to Renku 2.0

+ + Go to the 2.0 version of the project + +
+ )} + {!containerImage && !isFetchingData && ( + + Container image not available, it does not exist or is currently + building. + + )} + {!isProjectSupported && !isFetchingData && ( + + Please update this project before migrating it to Renku 2.0. + + )} +
+ + {!result.data && ( + <> + + + + )} + {result.data && ( + + )} + +
+
+ ); +} diff --git a/client/src/features/project/components/projectMigration/ProjectMigration.types.ts b/client/src/features/project/components/projectMigration/ProjectMigration.types.ts new file mode 100644 index 0000000000..45928b4ff9 --- /dev/null +++ b/client/src/features/project/components/projectMigration/ProjectMigration.types.ts @@ -0,0 +1,46 @@ +/*! + * Copyright 2025 - 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 { + LegacySlug, + ProjectName, + Slug, + Visibility, +} from "../../../projectsV2/api/projectV2.api"; + +export interface ProjectMigrationForm { + name: ProjectName; + namespace: Slug; + slug: LegacySlug; + visibility: Visibility; +} + +export interface ProjectMetadata { + accessLevel: number; + defaultBranch: string; + externalUrl: string; + httpUrl: string; + id: string; + namespace: string; + path: string; + pathWithNamespace: string; + visibility: string; + description: string; + title: string; + tagList: string[]; +} diff --git a/client/src/features/project/components/projectMigration/ProjectMigrationDetails.tsx b/client/src/features/project/components/projectMigration/ProjectMigrationDetails.tsx new file mode 100644 index 0000000000..7ec7789641 --- /dev/null +++ b/client/src/features/project/components/projectMigration/ProjectMigrationDetails.tsx @@ -0,0 +1,287 @@ +/*! + * Copyright 2025 - 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, useMemo, useState } from "react"; +import { + BarChartSteps, + Bookmarks, + CardImage, + Check2Circle, + Database, + FileCode, + FileEarmarkRuled, + FileText, + FileX, + People, + PlayCircle, +} from "react-bootstrap-icons"; +import { Collapse } from "reactstrap"; +import { InfoAlert } from "../../../../components/Alert.jsx"; +import { ExternalLink } from "../../../../components/ExternalLinks.tsx"; +import ChevronFlippedIcon from "../../../../components/icons/ChevronFlippedIcon"; +import { Links } from "../../../../utils/constants/Docs.js"; +import { ResourceClass } from "../../../dataServices/dataServices.types"; +import { SessionRowResourceRequests } from "../../../session/components/SessionsList"; +import { GitLabRepositoryCommit } from "../../GitLab.types"; + +interface DetailsMigrationProps { + isPinnedImage?: boolean; + commits?: GitLabRepositoryCommit[]; + containerImage?: string; + branch?: string; + keywords?: string; + description?: string; + codeRepository: string; + resourceClass?: ResourceClass; + isProjectSupported: boolean; +} +export function DetailsMigration({ + isPinnedImage, + commits, + containerImage, + branch, + keywords, + description, + codeRepository, + resourceClass, + isProjectSupported, +}: DetailsMigrationProps) { + const [showDetails, setShowDetails] = useState(false); + const onToggleShowDetails = useCallback(() => { + setShowDetails((isOpen) => !isOpen); + }, []); + + const commitMessage = useMemo(() => { + return commits ? commits[0].message : ""; + }, [commits]); + const shortIdCommit = useMemo(() => { + return commits ? commits[0].short_id : undefined; + }, [commits]); + + const resourceClassData = resourceClass + ? { + gpu: resourceClass.gpu, + cpu: resourceClass.cpu, + storage: resourceClass.max_storage, + memory: resourceClass.memory, + } + : undefined; + + const containerImageInfo = ( +
+ - Container image: {containerImage} +
+ ); + const resourceClassInfo = ( +
+ - Resource class: + {resourceClass ? ( + <> + {resourceClass?.name} | + + + ) : ( + Resource class not found + )} +
+ ); + + const detailsSession = ( +
+ {isPinnedImage ? ( + <> +
+ The pinned image for this project will be used to create a session + launcher. +
+ {containerImageInfo} + {resourceClassInfo} + + ) : ( + <> +
+ The latest image for this project will be used to create a session + launcher. +
+ {containerImageInfo} +
+ - Branch: {branch} +
+
+ - Commit: {shortIdCommit} -{" "} + {commitMessage} +
+ {resourceClassInfo} + + )} +
+ ); + + const containerImageInfoAlert = ( +
+ + This session image will not update as you make additional commits.{" "} + + +
+ ); + + return ( + <> + {!isPinnedImage && + containerImage && + isProjectSupported && + !showDetails && + containerImageInfoAlert} + +
+ +
+
+ + + Code repository: + {" "} + {codeRepository} +
+
+ + Datasets & + Data in Git LFS:{" "} + + Will continue to be available via the git lfs command line +
+
+ + Session launcher{" "} + +
+ {detailsSession} + {!isPinnedImage && + containerImage && + isProjectSupported && + showDetails && + containerImageInfoAlert} +
+ + Workflows:{" "} + + You may continue to use Renku workflows in your session via the + CLI. +
+
+ + Description: + {" "} + {description ? ( + description + ) : ( + + Description not found + + )} +
+
+ + Keywords: + {" "} + {keywords ? ( + keywords + ) : ( + Keywords not found + )} +
+
+
+
+ + ); +} + +export function DetailsNotIncludedInMigration() { + const [showDetails, setShowDetails] = useState(true); + const onToggleShowDetails = useCallback(() => { + setShowDetails((isOpen) => !isOpen); + }, []); + + return ( +
+ +
+ +
+
+ + Members: + {" "} + Members will not be migrated. Please add members directly to the + Renku 2.0 project. +
+
+ + Cloud storage: + {" "} + We're sorry, cloud storage migration isn't available at + the moment. Please reconfigure your cloud storage as a Renku 2.0 + Data Connector. +
+
+ + Project image: + {" "} + We're sorry, project image migration isn't available at + the moment. +
+
+
+
+
+ ); +} diff --git a/client/src/features/project/components/projectMigration/ProjectMigrationForm.tsx b/client/src/features/project/components/projectMigration/ProjectMigrationForm.tsx new file mode 100644 index 0000000000..0b9528203e --- /dev/null +++ b/client/src/features/project/components/projectMigration/ProjectMigrationForm.tsx @@ -0,0 +1,134 @@ +/*! + * Copyright 2025 - 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, useContext, useEffect } from "react"; +import { + Control, + Controller, + FieldErrors, + FieldNamesMarkedBoolean, + UseFormSetValue, + UseFormWatch, +} from "react-hook-form"; +import { useLocation } from "react-router-dom"; +import { Input, Label } from "reactstrap"; +import AppContext from "../../../../utils/context/appContext"; +import { slugFromTitle } from "../../../../utils/helpers/HelperFunctions.js"; +import { isRenkuLegacy } from "../../../../utils/helpers/HelperFunctionsV2"; +import ProjectNamespaceFormField from "../../../projectsV2/fields/ProjectNamespaceFormField"; +import ProjectVisibilityFormField from "../../../projectsV2/fields/ProjectVisibilityFormField"; +import SlugPreviewFormField from "../../../projectsV2/fields/SlugPreviewFormField"; +import { ProjectMigrationForm } from "./ProjectMigration.types"; + +import styles from "../../../projectsV2/fields/RenkuV1FormFields.module.scss"; + +interface ProjectMigrationFormInputsProps { + control: Control; + errors: FieldErrors; + watch: UseFormWatch; + setValue: UseFormSetValue; + dirtyFields: Partial>>; +} +export function ProjectMigrationFormInputs({ + control, + dirtyFields, + errors, + watch, + setValue, +}: ProjectMigrationFormInputsProps) { + const currentName = watch("name"); + const currentNamespace = watch("namespace"); + const currentSlug = watch("slug"); + const { params } = useContext(AppContext); + + useEffect(() => { + setValue("slug", slugFromTitle(currentName, true, true), { + shouldValidate: true, + }); + }, [currentName, setValue]); + const resetUrl = useCallback(() => { + setValue("slug", slugFromTitle(currentName, true, true), { + shouldValidate: true, + }); + }, [setValue, currentName]); + const url = `${params?.BASE_URL ?? ""}/v2/projects/${ + currentNamespace ?? "" + }/`; + const location = useLocation(); + const isRenkuV1 = isRenkuLegacy(location.pathname); + const formId = "project-migration-form"; + return ( + <> +
+ + ( + + )} + rules={{ required: true }} + /> +
Please provide a name
+
+
+ +
+
+ +
+
+ +
+ + ); +} diff --git a/client/src/features/project/hook/useGetSessionLauncherData.ts b/client/src/features/project/hook/useGetSessionLauncherData.ts new file mode 100644 index 0000000000..fde63ade88 --- /dev/null +++ b/client/src/features/project/hook/useGetSessionLauncherData.ts @@ -0,0 +1,193 @@ +/*! + * Copyright 2025 - 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 { useEffect, useMemo } from "react"; +import useAppSelector from "../../../utils/customHooks/useAppSelector.hook"; +import useLegacySelector from "../../../utils/customHooks/useLegacySelector.hook"; +import { useGetResourcePoolsQuery } from "../../dataServices/computeResources.api"; +import useDefaultBranchOption from "../../session/hooks/options/useDefaultBranchOption.hook"; +import useDefaultCommitOption from "../../session/hooks/options/useDefaultCommitOption.hook"; +import { + ProjectMetadataParams, + useGetConfigQuery, + useProjectMetadataMutation, +} from "../projectCoreApi"; +import projectGitLabApi, { + useGetAllRepositoryBranchesQuery, + useGetAllRepositoryCommitsQuery, +} from "../projectGitLab.api"; +import { useCoreSupport } from "../useProjectCoreSupport"; + +export function useGetSessionLauncherData() { + const defaultBranch = useLegacySelector( + (state) => state.stateModel.project.metadata.defaultBranch + ); + const gitLabProjectId = useLegacySelector( + (state) => state.stateModel.project.metadata.id ?? null + ); + const projectRepositoryUrl = useLegacySelector( + (state) => state.stateModel.project.metadata.externalUrl + ); + const { branch: currentBranch, commit } = useAppSelector( + ({ startSessionOptions }) => startSessionOptions + ); + + const { data: branches } = useGetAllRepositoryBranchesQuery( + gitLabProjectId + ? { + projectId: `${gitLabProjectId}`, + } + : skipToken + ); + const { data: commits } = useGetAllRepositoryCommitsQuery( + gitLabProjectId && currentBranch + ? { + branch: currentBranch, + projectId: `${gitLabProjectId}`, + } + : skipToken + ); + const { coreSupport } = useCoreSupport({ + gitUrl: projectRepositoryUrl ?? undefined, + branch: defaultBranch ?? undefined, + }); + const { + apiVersion, + backendAvailable, + computed: coreSupportComputed, + metadataVersion, + } = coreSupport; + + const isSupported = coreSupportComputed && backendAvailable; + + const { data: projectConfig } = useGetConfigQuery( + backendAvailable && coreSupportComputed && currentBranch && commit + ? { + apiVersion, + metadataVersion, + projectRepositoryUrl, + branch: currentBranch, + commit, + } + : skipToken + ); + + const [projectMetadata, projectMetadataStatus] = useProjectMetadataMutation(); + + const { data: resourcePools, isFetching: resourcePoolsIsFetching } = + useGetResourcePoolsQuery( + projectConfig + ? { + cpuRequest: projectConfig.config.sessions?.legacyConfig?.cpuRequest, + gpuRequest: projectConfig.config.sessions?.legacyConfig?.gpuRequest, + memoryRequest: + projectConfig.config.sessions?.legacyConfig?.memoryRequest, + storageRequest: projectConfig.config.sessions?.storage, + } + : skipToken + ); + + useEffect(() => { + if ( + projectRepositoryUrl && + commit && + backendAvailable && + coreSupportComputed + ) { + const params: ProjectMetadataParams = { + projectRepositoryUrl: `${projectRepositoryUrl}`, + commitSha: commit, + isDelayed: false, + metadataVersion, + apiVersion, + }; + projectMetadata(params); + } + }, [ + projectRepositoryUrl, + commit, + backendAvailable, + coreSupportComputed, + apiVersion, + metadataVersion, + projectMetadata, + ]); + + const templateName: string | undefined = useMemo(() => { + if ( + !projectMetadataStatus?.error && + !projectConfig?.config?.sessions?.dockerImage + ) { + const templateInfo = + projectMetadataStatus.data?.result?.template_info ?? ""; + const templateParts = templateInfo.split(": "); + return templateParts.pop() || undefined; + } + return undefined; + }, [projectMetadataStatus, projectConfig]); + + useDefaultBranchOption({ branches, defaultBranch }); + useDefaultCommitOption({ commits }); + + const tag = useMemo(() => commit.slice(0, 7), [commit]); + + const { + currentData: registry, + isFetching: renkuRegistryIsFetching, + error: renkuRegistryError, + } = projectGitLabApi.useGetRenkuRegistryQuery( + gitLabProjectId + ? { + projectId: `${gitLabProjectId}`, + } + : skipToken + ); + + const { + currentData: registryTag, + isFetching: registryTagIsFetching, + error: renkuRegistryTagError, + } = projectGitLabApi.useGetRegistryTagQuery( + gitLabProjectId && registry && tag + ? { + projectId: gitLabProjectId, + registryId: registry.id, + tag, + } + : skipToken + ); + + return { + registry, + registryTag, + isFetchingData: + renkuRegistryIsFetching || + registryTagIsFetching || + backendAvailable === undefined || + coreSupportComputed === undefined || + resourcePoolsIsFetching, + error: renkuRegistryError || renkuRegistryTagError, + projectConfig, + commits, + branch: defaultBranch, + templateName, + resourcePools, + isProjectSupported: isSupported, + }; +} diff --git a/client/src/features/project/projectCoreApi.ts b/client/src/features/project/projectCoreApi.ts index 73bdea9447..2ca13c71ed 100644 --- a/client/src/features/project/projectCoreApi.ts +++ b/client/src/features/project/projectCoreApi.ts @@ -76,6 +76,12 @@ interface UpdateConfigParams extends Omit { }; } +export interface ProjectMetadataParams extends CoreVersionUrl { + commitSha: string; + projectRepositoryUrl: string; + isDelayed: boolean; +} + interface UpdateConfigResponse { branch: string; update: { @@ -83,6 +89,16 @@ interface UpdateConfigResponse { }; } +interface ProjectMetadataResponse { + result?: { + name: string; + template_info: string; + id: string; + agent: string; + created: string; + }; +} + interface UpdateConfigRawResponse { result?: { config?: { [key: string]: string | null }; @@ -99,7 +115,7 @@ function urlWithQueryParams(url: string, queryParams: any) { export const projectCoreApi = createApi({ reducerPath: "projectCore", baseQuery: fetchBaseQuery({ baseUrl: "/ui-server/api/renku" }), - tagTypes: ["project", "project-status", "ProjectConfig"], + tagTypes: ["project", "project-status", "ProjectConfig", "ProjectMetadata"], keepUnusedDataFor: 10, endpoints: (builder) => ({ getDatasetFiles: builder.query({ @@ -310,6 +326,39 @@ export const projectCoreApi = createApi({ { type: "ProjectConfig", id: arg.projectRepositoryUrl }, ], }), + projectMetadata: builder.mutation< + ProjectMetadataResponse, + ProjectMetadataParams + >({ + query: ({ + projectRepositoryUrl, + commitSha, + isDelayed, + metadataVersion, + apiVersion, + }) => { + const body = { + git_url: projectRepositoryUrl, + commit_sha: commitSha, + is_delayed: isDelayed, + }; + return { + url: versionedPathForEndpoint({ + endpoint: "project.show", + metadataVersion, + apiVersion, + }), + method: "POST", + body, + validateStatus: (response, body) => + response.status >= 200 && response.status < 300 && !body.error, + }; + }, + transformErrorResponse: (error) => transformRenkuCoreErrorResponse(error), + invalidatesTags: (_result, _error, arg) => [ + { type: "ProjectMetadata", id: arg.projectRepositoryUrl }, + ], + }), }), }); @@ -419,4 +468,5 @@ export const { useStartMigrationMutation, useGetConfigQuery, useUpdateConfigMutation, + useProjectMetadataMutation, } = projectCoreApi; diff --git a/client/src/features/projectsV2/api/projectV2.api.ts b/client/src/features/projectsV2/api/projectV2.api.ts index 9d79665a8b..5f1ac43905 100644 --- a/client/src/features/projectsV2/api/projectV2.api.ts +++ b/client/src/features/projectsV2/api/projectV2.api.ts @@ -43,6 +43,24 @@ const injectedRtkApi = api.injectEndpoints({ method: "DELETE", }), }), + getRenkuV1ProjectsByV1IdMigrations: build.query< + GetRenkuV1ProjectsByV1IdMigrationsApiResponse, + GetRenkuV1ProjectsByV1IdMigrationsApiArg + >({ + query: (queryArg) => ({ + url: `/renku_v1_projects/${queryArg.v1Id}/migrations`, + }), + }), + postRenkuV1ProjectsByV1IdMigrations: build.mutation< + PostRenkuV1ProjectsByV1IdMigrationsApiResponse, + PostRenkuV1ProjectsByV1IdMigrationsApiArg + >({ + query: (queryArg) => ({ + url: `/renku_v1_projects/${queryArg.v1Id}/migrations`, + method: "POST", + body: queryArg.projectMigrationPost, + }), + }), getNamespacesByNamespaceProjectsAndSlug: build.query< GetNamespacesByNamespaceProjectsAndSlugApiResponse, GetNamespacesByNamespaceProjectsAndSlugApiArg @@ -71,6 +89,14 @@ const injectedRtkApi = api.injectEndpoints({ body: queryArg.projectPost, }), }), + getProjectsByProjectIdMigrationInfo: build.query< + GetProjectsByProjectIdMigrationInfoApiResponse, + GetProjectsByProjectIdMigrationInfoApiArg + >({ + query: (queryArg) => ({ + url: `/projects/${queryArg.projectId}/migration_info`, + }), + }), getProjectsByProjectIdMembers: build.query< GetProjectsByProjectIdMembersApiResponse, GetProjectsByProjectIdMembersApiArg @@ -219,6 +245,19 @@ export type DeleteProjectsByProjectIdApiResponse = export type DeleteProjectsByProjectIdApiArg = { projectId: Ulid; }; +export type GetRenkuV1ProjectsByV1IdMigrationsApiResponse = + /** status 200 Project exists in v2 and has been migrated */ Project; +export type GetRenkuV1ProjectsByV1IdMigrationsApiArg = { + /** The ID of the project in Renku v1 */ + v1Id: number; +}; +export type PostRenkuV1ProjectsByV1IdMigrationsApiResponse = + /** status 201 The project was created */ Project; +export type PostRenkuV1ProjectsByV1IdMigrationsApiArg = { + /** The ID of the project in Renku v1 */ + v1Id: number; + projectMigrationPost: ProjectMigrationPost; +}; export type GetNamespacesByNamespaceProjectsAndSlugApiResponse = /** status 200 The project */ Project; export type GetNamespacesByNamespaceProjectsAndSlugApiArg = { @@ -239,6 +278,11 @@ export type PostProjectsByProjectIdCopiesApiArg = { projectId: Ulid; projectPost: ProjectPost; }; +export type GetProjectsByProjectIdMigrationInfoApiResponse = + /** status 200 Project exists in v2 and is a migrated project from v1 */ ProjectMigrationInfo; +export type GetProjectsByProjectIdMigrationInfoApiArg = { + projectId: Ulid; +}; export type GetProjectsByProjectIdMembersApiResponse = /** status 200 The project's members */ ProjectMemberListResponse; export type GetProjectsByProjectIdMembersApiArg = { @@ -393,6 +437,28 @@ export type ProjectPatch = { is_template?: IsTemplate; secrets_mount_directory?: SecretsMountDirectoryPatch; }; +export type SessionName = string; +export type ContainerImage = string; +export type DefaultUrl = string; +export type ResourceClassId = number | null; +export type DiskStorage = number; +export type MigrationSessionLauncherPost = { + name: SessionName; + container_image: ContainerImage; + default_url?: DefaultUrl & any; + resource_class_id?: ResourceClassId; + disk_storage?: DiskStorage; +}; +export type ProjectMigrationPost = { + project: ProjectPost; + session_launcher?: MigrationSessionLauncherPost; +}; +export type ProjectMigrationInfo = { + project_id: Ulid; + /** The id of the project in v1 */ + v1_id: number; + launcher_id?: Ulid; +}; export type UserFirstLastName = string; export type Role = "viewer" | "editor" | "owner"; export type ProjectMemberResponse = { @@ -468,9 +534,12 @@ export const { useGetProjectsByProjectIdQuery, usePatchProjectsByProjectIdMutation, useDeleteProjectsByProjectIdMutation, + useGetRenkuV1ProjectsByV1IdMigrationsQuery, + usePostRenkuV1ProjectsByV1IdMigrationsMutation, useGetNamespacesByNamespaceProjectsAndSlugQuery, useGetProjectsByProjectIdCopiesQuery, usePostProjectsByProjectIdCopiesMutation, + useGetProjectsByProjectIdMigrationInfoQuery, useGetProjectsByProjectIdMembersQuery, usePatchProjectsByProjectIdMembersMutation, useDeleteProjectsByProjectIdMembersAndMemberIdMutation, diff --git a/client/src/features/projectsV2/api/projectV2.openapi.json b/client/src/features/projectsV2/api/projectV2.openapi.json index e7470bd48f..f09e67a3fe 100644 --- a/client/src/features/projectsV2/api/projectV2.openapi.json +++ b/client/src/features/projectsV2/api/projectV2.openapi.json @@ -234,6 +234,88 @@ "tags": ["projects"] } }, + "/renku_v1_projects/{v1_id}/migrations": { + "get": { + "summary": "Check if a v1 project has been migrated to v2", + "parameters": [ + { + "in": "path", + "name": "v1_id", + "required": true, + "description": "The ID of the project in Renku v1", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Project exists in v2 and has been migrated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Project" + } + } + } + }, + "404": { + "description": "No corresponding project found in v2", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + }, + "tags": ["projects"] + }, + "post": { + "summary": "Create a new project migrated from Renku v1", + "parameters": [ + { + "in": "path", + "name": "v1_id", + "required": true, + "description": "The ID of the project in Renku v1", + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectMigrationPost" + } + } + } + }, + "responses": { + "201": { + "description": "The project was created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Project" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + }, + "tags": ["projects"] + } + }, "/namespaces/{namespace}/projects/{slug}": { "get": { "summary": "Get a project by namespace and project slug", @@ -401,6 +483,47 @@ "tags": ["projects"] } }, + "/projects/{project_id}/migration_info": { + "get": { + "summary": "Check if a v2 project is a project migrated from v1", + "parameters": [ + { + "in": "path", + "name": "project_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/Ulid" + } + } + ], + "responses": { + "200": { + "description": "Project exists in v2 and is a migrated project from v1", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectMigrationInfo" + } + } + } + }, + "404": { + "description": "No corresponding project migrated from v1", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + }, + "tags": ["projects"] + } + }, "/projects/{project_id}/members": { "get": { "summary": "Get all members of a project", @@ -1024,6 +1147,133 @@ } } }, + "ProjectMigrationPost": { + "description": "Project v1 data to be migrated in Renku", + "type": "object", + "additionalProperties": false, + "properties": { + "project": { + "$ref": "#/components/schemas/ProjectPost" + }, + "session_launcher": { + "$ref": "#/components/schemas/MigrationSessionLauncherPost" + } + }, + "required": ["project"] + }, + "MigrationSessionLauncherPost": { + "description": "Data required to create a session launcher for a project migrated", + "type": "object", + "additionalProperties": false, + "properties": { + "name": { + "$ref": "#/components/schemas/SessionName" + }, + "container_image": { + "$ref": "#/components/schemas/ContainerImage" + }, + "default_url": { + "allOf": [ + { + "$ref": "#/components/schemas/DefaultUrl" + }, + { + "default": "/lab" + } + ], + "default": "/lab" + }, + "resource_class_id": { + "$ref": "#/components/schemas/ResourceClassId" + }, + "disk_storage": { + "$ref": "#/components/schemas/DiskStorage" + } + }, + "required": ["name", "container_image"] + }, + "SessionName": { + "description": "Renku session name", + "type": "string", + "minLength": 1, + "maxLength": 99, + "example": "My Renku Session :)" + }, + "ContainerImage": { + "description": "A container image", + "type": "string", + "maxLength": 500, + "pattern": "^[a-z0-9]+((\\.|_|__|-+)[a-z0-9]+)*(\\/[a-z0-9]+((\\.|_|__|-+)[a-z0-9]+)*)*(:[a-zA-Z0-9_][a-zA-Z0-9._-]{0,127}|@sha256:[a-fA-F0-9]{64}){0,1}$", + "example": "renku/renkulab-py:3.10-0.18.1" + }, + "DefaultUrl": { + "description": "The default path to open in a session", + "type": "string", + "maxLength": 200, + "example": "/lab" + }, + "EnvironmentUid": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true, + "maximum": 65535, + "description": "The user ID used to run the session" + }, + "EnvironmentGid": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true, + "maximum": 65535, + "description": "The group ID used to run the session" + }, + "EnvironmentWorkingDirectory": { + "type": "string", + "description": "The location where the session will start, if left unset it will default to the session image working directory.", + "minLength": 1, + "example": "/home/jovyan/work" + }, + "EnvironmentMountDirectory": { + "type": "string", + "description": "The location where the persistent storage for the session will be mounted, usually it should be identical to or a parent of the working directory, if left unset will default to the working directory.", + "minLength": 1, + "example": "/home/jovyan/work" + }, + "EnvironmentCommand": { + "type": "array", + "items": { + "type": "string" + }, + "description": "The command that will be run i.e. will overwrite the image Dockerfile ENTRYPOINT, equivalent to command in Kubernetes", + "minLength": 1 + }, + "EnvironmentArgs": { + "type": "array", + "items": { + "type": "string" + }, + "description": "The arguments that will follow the command, i.e. will overwrite the image Dockerfile CMD, equivalent to args in Kubernetes", + "minLength": 1 + }, + "EnvironmentPort": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true, + "exclusiveMaximum": true, + "maximum": 65400, + "description": "The TCP port (on any container in the session) where user requests will be routed to from the ingress" + }, + "ResourceClassId": { + "description": "The identifier of a resource class", + "type": "integer", + "default": null, + "nullable": true + }, + "DiskStorage": { + "description": "The size of disk storage for the session, in gigabytes", + "type": "integer", + "minimum": 1, + "example": 8 + }, "Ulid": { "description": "ULID identifier", "type": "string", @@ -1283,6 +1533,23 @@ } ] }, + "ProjectMigrationInfo": { + "description": "Information if a project is a migrated project", + "type": "object", + "properties": { + "project_id": { + "$ref": "#/components/schemas/Ulid" + }, + "v1_id": { + "description": "The id of the project in v1", + "type": "integer" + }, + "launcher_id": { + "$ref": "#/components/schemas/Ulid" + } + }, + "required": ["v1_id", "project_id"] + }, "ProjectPermissions": { "description": "The set of permissions on a project", "type": "object", diff --git a/client/src/features/projectsV2/fields/ProjectVisibilityFormField.tsx b/client/src/features/projectsV2/fields/ProjectVisibilityFormField.tsx index 8ea71d5d62..63d5de9803 100644 --- a/client/src/features/projectsV2/fields/ProjectVisibilityFormField.tsx +++ b/client/src/features/projectsV2/fields/ProjectVisibilityFormField.tsx @@ -22,14 +22,19 @@ import type { FieldValues } from "react-hook-form"; import { Controller } from "react-hook-form"; import { Globe, Lock } from "react-bootstrap-icons"; +import { useLocation } from "react-router-dom"; import { ButtonGroup, Input, Label } from "reactstrap"; +import { isRenkuLegacy } from "../../../utils/helpers/HelperFunctionsV2"; import type { GenericProjectFormFieldProps } from "./formField.types"; +import styles from "./RenkuV1FormFields.module.scss"; export default function ProjectVisibilityFormField({ control, formId, name, }: GenericProjectFormFieldProps) { + const location = useLocation(); + const isRenkuV1 = isRenkuLegacy(location.pathname); return (