Skip to content

Commit

Permalink
feat: [WD-14036] Duplicate instance (lxc copy)
Browse files Browse the repository at this point in the history
Signed-off-by: Nkeiruka <[email protected]>
  • Loading branch information
Kxiru committed Aug 21, 2024
1 parent 0d2119d commit 73040f8
Show file tree
Hide file tree
Showing 5 changed files with 327 additions and 0 deletions.
6 changes: 6 additions & 0 deletions src/pages/instances/InstanceDetailHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -130,6 +131,11 @@ const InstanceDetailHeader: FC<Props> = ({
<MigrateInstanceBtn instance={instance} project={project} />
) : null}
<CreateImageFromInstanceBtn key="publish" instance={instance} />
<DuplicateInstanceBtn
key="duplicate"
instance={instance}
isLoading={isLoading}
/>
<DeleteInstanceBtn key="delete" instance={instance} />
</>
) : null
Expand Down
36 changes: 36 additions & 0 deletions src/pages/instances/actions/DuplicateInstanceBtn.tsx
Original file line number Diff line number Diff line change
@@ -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<Props> = ({ instance, isLoading }) => {
const { openPortal, closePortal, isOpen, Portal } = usePortal();

return (
<>
{isOpen && (
<Portal>
<DuplicateInstanceForm close={closePortal} instance={instance} />
</Portal>
)}
<Button
appearance="default"
aria-label="Duplicate instance"
className="u-no-margin--bottom"
disabled={isLoading}
onClick={openPortal}
title="Duplicate instance"
>
<span>Duplicate</span>
</Button>
</>
);
};

export default DuplicateInstanceBtn;
259 changes: 259 additions & 0 deletions src/pages/instances/forms/DuplicateInstanceForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,259 @@
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 } from "api/instances";
import { isClusteredServer } from "util/settings";
import { useSettings } from "context/useSettings";
import { useQuery } from "@tanstack/react-query";
import { queryKeys } from "util/queryKeys";
import { fetchStoragePools } from "api/storage-pools";
import { fetchClusterMembers } from "api/cluster";
import { Link } from "react-router-dom";
import { instanceNameValidation } from "util/instances";
import { fetchProjects } from "api/projects";
import { LxdDiskDevice } from "types/device";
import InstanceLink from "pages/instances/InstanceLink";
import { useEventQueue } from "context/eventQueue";
import { getDuplicatedInstanceName } from "util/instanceDuplicate";

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<Props> = ({ instance, close }) => {
const toastNotify = useToastNotification();
const { data: settings } = useSettings();
const isClustered = isClusteredServer(settings);
const controllerState = useState<AbortController | null>(null);
const eventQueue = useEventQueue();

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 notifySuccess = (instanceName: string, instanceProject: string) => {
const newInstance = (
<Link to={`/ui/project/${instanceProject}/instance/${instanceName}`}>
{instanceName}
</Link>
);
toastNotify.success(
<>
Created instance <b>{newInstance}</b>.
</>,
);
};

const formik = useFormik<LxdInstanceDuplicate>({
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),
<InstanceLink instance={instance} />,
),
);
})
.catch((e) => {
toastNotify.failure(
"Instance duplication failed.",
e,
<InstanceLink instance={instance} />,
);
close();
});
},
});

return (
<Modal
close={close}
className="duplicate-instances-modal"
title="Duplicate Instance"
buttonRow={
<>
<Button
appearance="base"
className="u-no-margin--bottom"
type="button"
onClick={close}
>
Cancel
</Button>
<ActionButton
appearance="positive"
className="u-no-margin--bottom"
loading={formik.isSubmitting}
disabled={
!formik.isValid ||
storagePoolsLoading ||
clusterMembersLoading ||
projectsLoading
}
onClick={() => void formik.submitForm()}
>
Duplicate
</ActionButton>
</>
}
>
<Form onSubmit={formik.handleSubmit}>
<Input
{...formik.getFieldProps("instanceName")}
type="text"
label="New instance name"
error={
formik.touched.instanceName ? formik.errors.instanceName : null
}
/>
{isClustered && (
<Select
{...formik.getFieldProps("targetClusterMember")}
id="targetClusterMember"
label="Target cluster member"
options={clusterMembers.map((clusterMember) => {
return {
label: clusterMember.server_name,
value: clusterMember.server_name,
};
})}
/>
)}

<Select
{...formik.getFieldProps("targetStoragePool")}
id="storagePool"
label="Storage pool"
options={storagePools.map((storagePool) => {
return {
label: storagePool.name,
value: storagePool.name,
};
})}
/>
<Select
{...formik.getFieldProps("targetProject")}
id="project"
label="Target project"
options={projects.map((project) => {
return {
label: project.name,
value: project.name,
};
})}
/>
<Input
{...formik.getFieldProps("allowInconsistent")}
type="checkbox"
label="Ignore copy errors for volatile files"
error={
formik.touched.allowInconsistent
? formik.errors.allowInconsistent
: null
}
/>
<Input
{...formik.getFieldProps("instanceOnly")}
type="checkbox"
label="Copy without instance snapshots"
error={
formik.touched.instanceOnly ? formik.errors.instanceOnly : null
}
/>
<Input
{...formik.getFieldProps("stateful")}
type="checkbox"
label="Copy stateful"
error={formik.touched.stateful ? formik.errors.stateful : null}
/>
{/* hidden submit to enable enter key in inputs */}
<Input type="submit" hidden value="Hidden input" />
</Form>
</Modal>
);
};

export default DuplicateInstanceForm;
2 changes: 2 additions & 0 deletions src/sass/_instance_detail_page.scss
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@
}
}

.duplicate-instances-modal,
.create-image-from-instance-modal {
.p-modal__dialog {
justify-self: center;
width: 35rem;
}
}
24 changes: 24 additions & 0 deletions src/util/instanceDuplicate.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { useQuery } from "@tanstack/react-query";
import { queryKeys } from "./queryKeys";
import { fetchInstances } from "api/instances";
import { LxdInstance } from "types/instance";

export const getDuplicatedInstanceName = (instance: LxdInstance): string => {
const { data: instances = [] } = useQuery({
queryKey: [queryKeys.instances],
queryFn: () => fetchInstances(instance.project),
});

const instanceNames = instances.map((instance) => instance.name);
const newInstanceName = instance.name + "-duplicate";

if (instanceNames.includes(newInstanceName)) {
let count = 1;
while (instanceNames.includes(`${newInstanceName}-${count}`)) {
count++;
}
return `${newInstanceName}-${count}`;
}

return newInstanceName;
};

0 comments on commit 73040f8

Please sign in to comment.