Skip to content

Commit

Permalink
feat: support vmdk import for vm instance creation
Browse files Browse the repository at this point in the history
Signed-off-by: Mason Hu <[email protected]>
  • Loading branch information
mas-who committed Aug 28, 2024
1 parent ac058d1 commit 77dff5e
Show file tree
Hide file tree
Showing 4 changed files with 427 additions and 1 deletion.
328 changes: 328 additions & 0 deletions src/pages/instances/actions/ConvertToInstanceBtn.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,328 @@
import { ChangeEvent, FC, useCallback, useEffect, useState } from "react";
import {
ActionButton,
Button,
Input,
Modal,
Select,
} from "@canonical/react-components";
import { Form, useNavigate } from "react-router-dom";
import { useFormik } from "formik";
import * as Yup from "yup";
import { instanceNameValidation } from "util/instances";
import { useProject } from "context/project";
import { createInstance } from "api/instances";
import usePortal from "react-useportal";
import { UploadState } from "types/storage";
import ProgressBar from "components/ProgressBar";
import {
getSupportedArchitectures,
humanFileSize,
sendFileByWebSocket,
} from "util/helpers";
import StoragePoolSelector from "pages/storage/StoragePoolSelector";
import { useToastNotification } from "context/toastNotificationProvider";
import { useEventQueue } from "context/eventQueue";
import { useQueryClient } from "@tanstack/react-query";
import { queryKeys } from "util/queryKeys";
import { getInstanceName } from "util/operations";

export interface ConvertToInstanceFormValues {
imageFile: File | null;
name: string;
pool: string;
formatConversion: boolean;
virtioConversion: boolean;
architecture: string;
}

const CHUNK_SIZE = 1024 * 1024;

const convertToInstancePayload = (values: ConvertToInstanceFormValues) => {
const fileSize = values.imageFile?.size || 0;
const conversionOptions = [];
if (values.formatConversion) {
conversionOptions.push("format");
}
if (values.virtioConversion) {
conversionOptions.push("virtio");
}

const instanceArgs = {
source: {
type: "conversion",
mode: "push",
conversion_options: conversionOptions,
},
devices: {
root: {
path: "/",
type: "disk",
pool: values.pool,
},
},
type: "virtual-machine",
name: values.name,
sourceDiskSize: fileSize,
architecture: values.architecture,
};

return JSON.stringify(instanceArgs);
};

const ConvertToInstanceBtn: FC = () => {
const { openPortal, closePortal, isOpen, Portal } = usePortal();
const { project, isLoading: isProjectLoading } = useProject();
const instanceNameAbort = useState<AbortController | null>(null);
const [uploadState, setUploadState] = useState<UploadState | null>(null);
const [ws, setWs] = useState<WebSocket | null>(null);
const toastNotify = useToastNotification();
const navigate = useNavigate();
const eventQueue = useEventQueue();
const queryClient = useQueryClient();

const handleUploadProgress = (current: number, total: number) => {
setUploadState({
percentage: Math.floor((current / total) * 100),
loaded: current,
total: total,
});

if (current === total) {
handleCloseModal();
navigate(`/ui/project/${project?.name}/instances`);
}
};

const handleUploadError = (error: Error) => {
toastNotify.failure("Image upload failed.", error);
};

useEffect(() => {
sendFileByWebSocket(
ws,
formik.values.imageFile,
CHUNK_SIZE,
handleUploadProgress,
handleUploadError,
);
}, [ws]);

const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
const { onChange } = formik.getFieldProps("imageFile");
onChange(e);

if (e.currentTarget.files) {
const file = e.currentTarget.files[0];
void formik.setFieldValue("imageFile", file);
}
};

const handleSuccess = (instanceName: string) => {
const message = (
<>
Created instance <strong>{instanceName}</strong>.
</>
);

const actions = [
{
label: "Configure",
onClick: () =>
navigate(
`/ui/project/${project?.name}/instance/${instanceName}/configuration`,
),
},
];

toastNotify.success(message, actions);
};

const handleFailure = (msg: string) => {
toastNotify.failure("Instance creation failed.", new Error(msg));
};

const handleFinish = () => {
void queryClient.invalidateQueries({
predicate: (query) => {
return query.queryKey[0] === queryKeys.instances;
},
});
};

const handleConfirmUpload = (values: ConvertToInstanceFormValues) => {
if (!values.imageFile) {
return;
}

// start create instance operation
createInstance(convertToInstancePayload(values), project?.name || "")
.then((operation) => {
const operationId = operation.metadata.id;
const operationSecret = operation.metadata?.metadata?.fs;

// establish websocket connection based on the instance creation operation
let wsUrl = `wss://${location.host}/1.0/operations/${operationId}/websocket`;
if (operationSecret) {
wsUrl += `?secret=${operationSecret}`;
}
const ws = new WebSocket(wsUrl);
ws.onopen = () => {
setWs(ws);
};
ws.onclose = () => {
setWs(null);
};

// set up event queue for the operation
eventQueue.set(
operationId,
() => handleSuccess(getInstanceName(operation.metadata)),
handleFailure,
handleFinish,
);
})
.catch((e) => {
handleCloseModal();
toastNotify.failure("Instance creation failed.", e);
});
};

const architectureOptions = getSupportedArchitectures("virtual-machine").map(
(arch) => ({ value: arch, label: arch }),
);

const formik = useFormik<ConvertToInstanceFormValues>({
initialValues: {
name: "",
pool: "",
imageFile: null,
formatConversion: true,
virtioConversion: false,
architecture: architectureOptions[0].value,
},
validateOnMount: true,
validationSchema: Yup.object().shape({
name: instanceNameValidation(
project?.name || "",
instanceNameAbort,
).optional(),
}),
onSubmit: handleConfirmUpload,
});

const handleCloseModal = useCallback(() => {
formik.resetForm();
closePortal();
setWs(null);
}, [formik.resetForm, closePortal, ws]);

return (
<>
<Button onClick={openPortal} type="button">
<span>Convert from image</span>
</Button>
{isOpen && (
<Portal>
<Modal
close={handleCloseModal}
className="convert-image-modal"
title="Convert from image file"
buttonRow={
<>
<Button
appearance="base"
className="u-no-margin--bottom"
type="button"
onClick={handleCloseModal}
>
Cancel
</Button>
<ActionButton
appearance="positive"
className="u-no-margin--bottom"
loading={formik.isSubmitting || !!uploadState}
disabled={
!formik.isValid ||
isProjectLoading ||
!formik.values.imageFile
}
onClick={() => void formik.submitForm()}
>
Convert and create
</ActionButton>
</>
}
>
<Form
onSubmit={formik.handleSubmit}
className={uploadState ? "u-hide" : ""}
>
<Input
id="image-file"
name="imageFile"
type="file"
label="Image file"
onChange={handleFileChange}
/>
<Input
{...formik.getFieldProps("name")}
id="name"
type="text"
label="New instance name"
placeholder="Enter name"
error={formik.touched.name ? formik.errors.name : null}
disabled={!formik.values.imageFile}
/>
<StoragePoolSelector
project={project?.name || ""}
value={formik.values.pool}
setValue={(value) => void formik.setFieldValue("pool", value)}
selectProps={{
id: "pool",
label: "Root storage pool",
disabled: isProjectLoading || !formik.values.imageFile,
}}
/>
<Select
{...formik.getFieldProps("architecture")}
id="architecture"
label="Image architecture"
options={architectureOptions}
disabled={!formik.values.imageFile}
/>
<label htmlFor="">Conversion options</label>
<div className="conversion-options">
<Input
{...formik.getFieldProps("formatConversion")}
type="checkbox"
label="Format"
disabled={!formik.values.imageFile}
checked={formik.values.formatConversion}
/>
<Input
{...formik.getFieldProps("virtioConversion")}
type="checkbox"
label="Virtio"
disabled={!formik.values.imageFile}
checked={formik.values.virtioConversion}
/>
</div>
</Form>
{uploadState && (
<>
<ProgressBar percentage={Math.floor(uploadState.percentage)} />
<p>
{humanFileSize(uploadState.loaded)} loaded of{" "}
{humanFileSize(uploadState.total ?? 0)}
</p>
</>
)}
</Modal>
</Portal>
)}
</>
);
};

export default ConvertToInstanceBtn;
2 changes: 2 additions & 0 deletions src/pages/instances/forms/InstanceCreateDetailsForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import AutoExpandingTextArea from "components/AutoExpandingTextArea";
import ScrollableForm from "components/ScrollableForm";
import { useSupportedFeatures } from "context/useSupportedFeatures";
import UploadInstanceBtn from "pages/instances/actions/UploadInstanceBtn";
import ConvertToInstanceBtn from "../actions/ConvertToInstanceBtn";

export interface InstanceDetailsFormValues {
name?: string;
Expand Down Expand Up @@ -145,6 +146,7 @@ const InstanceCreateDetailsForm: FC<Props> = ({
<UseCustomIsoBtn onSelect={onSelectImage} />
)}
<UploadInstanceBtn />
<ConvertToInstanceBtn />
</>
)}
</div>
Expand Down
9 changes: 8 additions & 1 deletion src/sass/_upload_instance_modal.scss
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
.upload-instance-modal {
.upload-instance-modal,
.convert-image-modal {
.p-modal__dialog {
margin: auto;
width: 35rem;
}

.conversion-options {
display: flex;
gap: 1rem;
margin-bottom: $spv--medium;
}
}
Loading

0 comments on commit 77dff5e

Please sign in to comment.