-
Notifications
You must be signed in to change notification settings - Fork 37
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: support vmdk import for vm instance creation
Signed-off-by: Mason Hu <[email protected]>
- Loading branch information
Showing
4 changed files
with
427 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} |
Oops, something went wrong.