From 567376cb093876672837fac3a0d2beb8a3a2672c Mon Sep 17 00:00:00 2001 From: Nkeiruka Date: Thu, 15 Aug 2024 19:21:21 +0100 Subject: [PATCH] feat: [WD-14036] Duplicate instance (lxc copy) Signed-off-by: Nkeiruka --- src/pages/instances/InstanceDetailHeader.tsx | 6 + .../actions/DuplicateInstanceBtn.tsx | 36 +++ .../instances/forms/DuplicateInstanceForm.tsx | 290 ++++++++++++++++++ src/sass/_instance_detail_page.scss | 5 +- 4 files changed, 336 insertions(+), 1 deletion(-) create mode 100644 src/pages/instances/actions/DuplicateInstanceBtn.tsx create mode 100644 src/pages/instances/forms/DuplicateInstanceForm.tsx diff --git a/src/pages/instances/InstanceDetailHeader.tsx b/src/pages/instances/InstanceDetailHeader.tsx index 798b3b2275..abef994c09 100644 --- a/src/pages/instances/InstanceDetailHeader.tsx +++ b/src/pages/instances/InstanceDetailHeader.tsx @@ -20,6 +20,7 @@ import MigrateInstanceBtn from "./actions/MigrateInstanceBtn"; import { useSettings } from "context/useSettings"; import { isClusteredServer } from "util/settings"; import CreateImageFromInstanceBtn from "./actions/CreateImageFromInstanceBtn"; +import DuplicateInstanceBtn from "./actions/DuplicateInstanceBtn"; interface Props { name: string; @@ -130,6 +131,11 @@ const InstanceDetailHeader: FC = ({ ) : null} + ) : null diff --git a/src/pages/instances/actions/DuplicateInstanceBtn.tsx b/src/pages/instances/actions/DuplicateInstanceBtn.tsx new file mode 100644 index 0000000000..0faadaa211 --- /dev/null +++ b/src/pages/instances/actions/DuplicateInstanceBtn.tsx @@ -0,0 +1,36 @@ +import { FC } from "react"; +import { LxdInstance } from "types/instance"; +import { Button } from "@canonical/react-components"; +import usePortal from "react-useportal"; +import DuplicateInstanceForm from "../forms/DuplicateInstanceForm"; + +interface Props { + instance: LxdInstance; + isLoading: boolean; +} + +const DuplicateInstanceBtn: FC = ({ instance, isLoading }) => { + const { openPortal, closePortal, isOpen, Portal } = usePortal(); + + return ( + <> + {isOpen && ( + + + + )} + + + ); +}; + +export default DuplicateInstanceBtn; diff --git a/src/pages/instances/forms/DuplicateInstanceForm.tsx b/src/pages/instances/forms/DuplicateInstanceForm.tsx new file mode 100644 index 0000000000..3226838de1 --- /dev/null +++ b/src/pages/instances/forms/DuplicateInstanceForm.tsx @@ -0,0 +1,290 @@ +import { FC, useState } from "react"; +import { LxdInstance } from "types/instance"; +import { useFormik } from "formik"; +import { useToastNotification } from "context/toastNotificationProvider"; +import { + ActionButton, + Button, + Form, + Input, + Modal, + Select, +} from "@canonical/react-components"; +import * as Yup from "yup"; +import { createInstance, fetchInstances } from "api/instances"; +import { isClusteredServer } from "util/settings"; +import { useSettings } from "context/useSettings"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { queryKeys } from "util/queryKeys"; +import { fetchStoragePools } from "api/storage-pools"; +import { fetchClusterMembers } from "api/cluster"; +import { Link, useNavigate } from "react-router-dom"; +import { instanceNameValidation, truncateInstanceName } from "util/instances"; +import { fetchProjects } from "api/projects"; +import { LxdDiskDevice } from "types/device"; +import InstanceLink from "pages/instances/InstanceLink"; +import { useEventQueue } from "context/eventQueue"; + +interface Props { + instance: LxdInstance; + close: () => void; +} + +export interface LxdInstanceDuplicate { + instanceName: string; + targetProject: string; + targetClusterMember?: string; + targetStoragePool: string; + allowInconsistent: boolean; + instanceOnly: boolean; + stateful: boolean; +} + +const DuplicateInstanceForm: FC = ({ instance, close }) => { + const toastNotify = useToastNotification(); + const { data: settings } = useSettings(); + const isClustered = isClusteredServer(settings); + const controllerState = useState(null); + const navigate = useNavigate(); + const eventQueue = useEventQueue(); + const queryClient = useQueryClient(); + + const { data: projects = [], isLoading: projectsLoading } = useQuery({ + queryKey: [queryKeys.projects], + queryFn: fetchProjects, + }); + + const { data: storagePools = [], isLoading: storagePoolsLoading } = useQuery({ + queryKey: [queryKeys.storage], + queryFn: () => fetchStoragePools(instance.project), + }); + + const { data: clusterMembers = [], isLoading: clusterMembersLoading } = + useQuery({ + queryKey: [queryKeys.cluster], + queryFn: fetchClusterMembers, + enabled: isClustered, + }); + + const { data: instances = [] } = useQuery({ + queryKey: [queryKeys.instances], + queryFn: () => fetchInstances(instance.project), + }); + + const notifySuccess = (instanceName: string, instanceProject: string) => { + const message = ( + <> + Created Instance {instanceName}. + + ); + + const actions = [ + { + label: `${instanceName}`, + onClick: () => + navigate(`/ui/project/${instanceProject}/instance/${instanceName}`), + }, + ]; + + toastNotify.success(message, actions); + }; + + const getDuplicatedInstanceName = (instance: LxdInstance): string => { + const instanceNames = instances.map((instance) => instance.name); + const newInstanceName = truncateInstanceName(instance.name, "-duplicate"); + + if (instanceNames.includes(newInstanceName)) { + let count = 1; + while (instanceNames.includes(`${newInstanceName}-${count}`)) { + count++; + } + return `${newInstanceName}-${count}`; + } + void queryClient.invalidateQueries({ + predicate: (query) => { + return query.queryKey[0] === queryKeys.instances; + }, + }); + + return newInstanceName; + }; + + const formik = useFormik({ + initialValues: { + instanceName: getDuplicatedInstanceName(instance), + targetProject: instance.project, + allowInconsistent: false, + instanceOnly: false, + stateful: false, + targetClusterMember: isClustered ? instance.location : "", + targetStoragePool: + (instance.devices["root"] as LxdDiskDevice)?.pool ?? + storagePools[0]?.name, + }, + enableReinitialize: true, + validationSchema: Yup.object().shape({ + instanceName: instanceNameValidation( + instance.project, + controllerState, + ).required(), + }), + onSubmit: (values) => { + createInstance( + JSON.stringify({ + description: instance.description, + name: values.instanceName, + architecture: instance.architecture, + source: { + allow_inconsistent: values.allowInconsistent, + instance_only: values.instanceOnly, + source: instance.name, + type: "copy", + project: instance.project, + }, + stateful: values.stateful, + devices: { + ...instance.devices, + root: { + path: "/", + type: "disk", + pool: values.targetStoragePool, + }, + }, + }), + values.targetProject, + values.targetClusterMember, + ) + .then((operation) => { + toastNotify.info(`Duplication of instance ${instance.name} started.`); + close(); + eventQueue.set( + operation.metadata.id, + () => notifySuccess(values.instanceName, values.targetProject), + (msg) => + toastNotify.failure( + "Instance duplication failed.", + new Error(msg), + , + ), + ); + }) + .catch((e) => { + toastNotify.failure( + "Instance duplication failed.", + e, + , + ); + close(); + }); + }, + }); + + return ( + + + void formik.submitForm()} + > + Duplicate + + + } + > +
+ + {isClustered && ( + { + return { + label: storagePool.name, + value: storagePool.name, + }; + })} + /> + + + + {/* hidden submit to enable enter key in inputs */} + +
+
+ ); +}; + +export default DuplicateInstanceForm; diff --git a/src/sass/_instance_detail_page.scss b/src/sass/_instance_detail_page.scss index 51e199ad7f..ec9b5c7054 100644 --- a/src/sass/_instance_detail_page.scss +++ b/src/sass/_instance_detail_page.scss @@ -8,8 +8,11 @@ } } +.duplicate-instances-modal, .create-image-from-instance-modal { .p-modal__dialog { - width: 35rem; + @include large { + width: 35rem; + } } }