diff --git a/src/context/useSupportedFeatures.tsx b/src/context/useSupportedFeatures.tsx index 124db023d8..11710e54da 100644 --- a/src/context/useSupportedFeatures.tsx +++ b/src/context/useSupportedFeatures.tsx @@ -26,5 +26,8 @@ export const useSupportedFeatures = () => { hasAccessManagement: apiExtensions.has("access_management"), hasExplicitTrustToken: apiExtensions.has("explicit_trust_token"), hasInstanceCreateStart: apiExtensions.has("instance_create_start"), + hasInstanceImportConversion: apiExtensions.has( + "instance_import_conversion", + ), }; }; diff --git a/src/pages/instances/actions/UploadInstanceBtn.tsx b/src/pages/instances/actions/UploadInstanceBtn.tsx deleted file mode 100644 index d0df0a28b5..0000000000 --- a/src/pages/instances/actions/UploadInstanceBtn.tsx +++ /dev/null @@ -1,252 +0,0 @@ -import { ChangeEvent, FC, useCallback, useState } from "react"; -import { - ActionButton, - Button, - Input, - Modal, -} from "@canonical/react-components"; -import { Form, useNavigate } from "react-router-dom"; -import { useFormik } from "formik"; -import * as Yup from "yup"; -import { instanceNameValidation, truncateInstanceName } from "util/instances"; -import { useProject } from "context/project"; -import { useQueryClient } from "@tanstack/react-query"; -import { queryKeys } from "util/queryKeys"; -import { uploadInstance } from "api/instances"; -import { UploadState } from "types/storage"; -import { useEventQueue } from "context/eventQueue"; -import { useToastNotification } from "context/toastNotificationProvider"; -import ProgressBar from "components/ProgressBar"; -import { humanFileSize } from "util/helpers"; -import usePortal from "react-useportal"; -import StoragePoolSelector from "pages/storage/StoragePoolSelector"; -import { AxiosError } from "axios"; -import { LxdSyncResponse } from "types/apiResponse"; - -export interface UploadInstanceFormValues { - instanceFile: File | null; - name: string; - pool: string; -} - -const UploadInstanceBtn: FC = () => { - const { openPortal, closePortal, isOpen, Portal } = usePortal(); - const { project, isLoading: isProjectLoading } = useProject(); - const [isUploading, setIsUploading] = useState(false); - const [uploadState, setUploadState] = useState(null); - const eventQueue = useEventQueue(); - const toastNotify = useToastNotification(); - const queryClient = useQueryClient(); - const navigate = useNavigate(); - const [uploadAbort, setUploadAbort] = useState(null); - const instanceNameAbort = useState(null); - - const changeFile = async (e: ChangeEvent) => { - const { onChange } = formik.getFieldProps("instanceFile"); - onChange(e); - - if (e.currentTarget.files) { - const file = e.currentTarget.files[0]; - const suffix = "-imported"; - const instanceName = truncateInstanceName( - // remove file extension - file.name.split(".")[0], - suffix, - ); - await formik.setFieldValue("instanceFile", file); - await formik.setFieldValue("name", instanceName); - - // validate instance name - await formik.validateField("name"); - void formik.setFieldTouched("name", true, true); - if (!formik.errors.name) { - formik.setFieldError("name", undefined); - } - } - }; - - const handleSuccess = (instanceName: string) => { - const message = ( - <> - Created instance {instanceName}. - - ); - - 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 handleUpload = () => { - if (!formik.values.instanceFile) { - return; - } - - setIsUploading(true); - const uploadController = new AbortController(); - setUploadAbort(uploadController); - - const instanceName = formik.values.name; - void uploadInstance( - formik.values.instanceFile, - instanceName, - project?.name, - formik.values.pool, - setUploadState, - uploadController, - ) - .then((operation) => { - toastNotify.info( - <> - Upload completed. Now creating instance{" "} - {instanceName}. - , - ); - - eventQueue.set( - operation.metadata.id, - () => handleSuccess(instanceName), - handleFailure, - handleFinish, - ); - }) - .catch((e: AxiosError>) => { - const error = new Error(e.response?.data.error); - toastNotify.failure("Instance upload failed", error); - }) - .finally(() => { - handleCloseModal(); - navigate(`/ui/project/${project?.name}/instances`); - }); - }; - - const formik = useFormik({ - initialValues: { - name: "", - pool: "", - instanceFile: null, - }, - validateOnMount: true, - validationSchema: Yup.object().shape({ - name: instanceNameValidation( - project?.name || "", - instanceNameAbort, - ).optional(), - }), - onSubmit: handleUpload, - }); - - const handleCloseModal = useCallback(() => { - uploadAbort?.abort(); - setIsUploading(false); - setUploadState(null); - setUploadAbort(null); - formik.resetForm(); - closePortal(); - }, [uploadAbort, formik.resetForm, closePortal]); - - return ( - <> - - {isOpen && ( - - - - void formik.submitForm()} - > - Upload and create - - - } - > -
- void changeFile(e)} - /> - - void formik.setFieldValue("pool", value)} - selectProps={{ - id: "pool", - label: "Root storage pool", - disabled: isProjectLoading || !formik.values.instanceFile, - }} - /> - - {uploadState && ( - <> - -

- {humanFileSize(uploadState.loaded)} loaded of{" "} - {humanFileSize(uploadState.total ?? 0)} -

- - )} -
-
- )} - - ); -}; - -export default UploadInstanceBtn; diff --git a/src/pages/instances/actions/UploadInstanceFileBtn.tsx b/src/pages/instances/actions/UploadInstanceFileBtn.tsx new file mode 100644 index 0000000000..1119cf5a9b --- /dev/null +++ b/src/pages/instances/actions/UploadInstanceFileBtn.tsx @@ -0,0 +1,27 @@ +import { FC } from "react"; +import { Button } from "@canonical/react-components"; +import usePortal from "react-useportal"; +import UploadInstanceFileModal from "../forms/UploadInstanceFileModal"; + +interface Props { + name?: string; +} + +const UploadInstanceFileBtn: FC = ({ name }) => { + const { openPortal, closePortal, isOpen, Portal } = usePortal(); + + return ( + <> + + {isOpen && ( + + + + )} + + ); +}; + +export default UploadInstanceFileBtn; diff --git a/src/pages/instances/forms/InstanceCreateDetailsForm.tsx b/src/pages/instances/forms/InstanceCreateDetailsForm.tsx index bf4ebd607e..25adf0611d 100644 --- a/src/pages/instances/forms/InstanceCreateDetailsForm.tsx +++ b/src/pages/instances/forms/InstanceCreateDetailsForm.tsx @@ -24,7 +24,7 @@ import UseCustomIsoBtn from "pages/images/actions/UseCustomIsoBtn"; import AutoExpandingTextArea from "components/AutoExpandingTextArea"; import ScrollableForm from "components/ScrollableForm"; import { useSupportedFeatures } from "context/useSupportedFeatures"; -import UploadInstanceBtn from "pages/instances/actions/UploadInstanceBtn"; +import UploadInstanceFileBtn from "../actions/UploadInstanceFileBtn"; export interface InstanceDetailsFormValues { name?: string; @@ -143,7 +143,7 @@ const InstanceCreateDetailsForm: FC = ({ {hasCustomVolumeIso && ( )} - + )} diff --git a/src/pages/instances/forms/InstanceFileTypeSelector.tsx b/src/pages/instances/forms/InstanceFileTypeSelector.tsx new file mode 100644 index 0000000000..3143d07835 --- /dev/null +++ b/src/pages/instances/forms/InstanceFileTypeSelector.tsx @@ -0,0 +1,40 @@ +import { RadioInput } from "@canonical/react-components"; +import { FC } from "react"; + +export type InstanceFileType = "instance-backup" | "external-format"; + +interface Props { + value: InstanceFileType; + onChange: (value: InstanceFileType) => void; +} + +const InstanceFileTypeSelector: FC = ({ value, onChange }) => { + return ( + <> + +
+
+ onChange("instance-backup")} + /> +
+
+ + External format (.qcow2, .vmdk,{" "} + etc...) + + } + checked={value === "external-format"} + onChange={() => onChange("external-format")} + /> +
+
+ + ); +}; + +export default InstanceFileTypeSelector; diff --git a/src/pages/instances/forms/UploadExternalFormatFileForm.tsx b/src/pages/instances/forms/UploadExternalFormatFileForm.tsx new file mode 100644 index 0000000000..a11b180fbe --- /dev/null +++ b/src/pages/instances/forms/UploadExternalFormatFileForm.tsx @@ -0,0 +1,351 @@ +import { + ActionButton, + Button, + Form, + Icon, + Input, + Select, + useNotify, +} from "@canonical/react-components"; +import { useProject } from "context/project"; +import StoragePoolSelector from "pages/storage/StoragePoolSelector"; +import { ChangeEvent, FC, useCallback, useState } from "react"; +import { fileToInstanceName, instanceNameValidation } from "util/instances"; +import { UploadState } from "types/storage"; +import { useEventQueue } from "context/eventQueue"; +import { useToastNotification } from "context/toastNotificationProvider"; +import { useQueryClient } from "@tanstack/react-query"; +import { useNavigate } from "react-router-dom"; +import { queryKeys } from "util/queryKeys"; +import { createInstance } from "api/instances"; +import { useFormik } from "formik"; +import * as Yup from "yup"; +import { useSettings } from "context/useSettings"; +import InstanceLink from "../InstanceLink"; +import { + UploadExternalFormatFileFormValues, + uploadExternalFormatFilePayload, + isImageTypeRaw, + sendFileByWebSocket, + supportedVMArchOptions, + CANCEL_UPLOAD_REASON, +} from "util/uploadExternalFormatFile"; +import { getInstanceName } from "util/operations"; +import classnames from "classnames"; +import InstanceFileTypeSelector, { + InstanceFileType, +} from "./InstanceFileTypeSelector"; + +interface Props { + close: () => void; + uploadState: UploadState | null; + setUploadState: (state: UploadState | null) => void; + fileType: InstanceFileType; + setFileType: (value: InstanceFileType) => void; + defaultInstanceName?: string; +} + +const UploadExternalFormatFileForm: FC = ({ + close, + uploadState, + setUploadState, + fileType, + setFileType, + defaultInstanceName, +}) => { + const { project, isLoading } = useProject(); + const instanceNameAbort = useState(null); + const [socket, setSocket] = useState(); + const toastNotify = useToastNotification(); + const notify = useNotify(); + const navigate = useNavigate(); + const eventQueue = useEventQueue(); + const queryClient = useQueryClient(); + const { data: settings } = useSettings(); + + const handleUploadProgress = ( + instanceName: string, + current: number, + total: number, + ) => { + setUploadState({ + percentage: Math.floor((current / total) * 100), + loaded: current, + total: total, + }); + + if (current === total) { + close(); + toastNotify.info( + <> + Upload completed. Now creating instance{" "} + {instanceName}. + , + ); + navigate(`/ui/project/${project?.name}/instances`); + } + }; + + const handleUploadError = (error: Error) => { + notify.failure("Image upload failed.", error); + setUploadState(null); + }; + + const handleCancelUpload = () => { + if (socket && socket.readyState === WebSocket.OPEN) { + socket.close(1000, CANCEL_UPLOAD_REASON); + toastNotify.info( + <> + Upload cancelled for instance {formik.values.name}. + , + ); + } + }; + + const handleSuccess = (instanceName: string) => { + const message = ( + <> + Created instance{" "} + + . + + ); + + 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 invalidateCache = () => { + void queryClient.invalidateQueries({ + predicate: (query) => { + return query.queryKey[0] === queryKeys.instances; + }, + }); + }; + + const handleSubmit = (values: UploadExternalFormatFileFormValues) => { + if (!values.imageFile) { + return; + } + + // start create instance operation + createInstance(uploadExternalFormatFilePayload(values), project?.name || "") + .then((operation) => { + const operationId = operation.metadata.id; + const operationSecret = operation.metadata?.metadata?.fs; + const instanceName = getInstanceName(operation.metadata); + + // 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 = sendFileByWebSocket( + wsUrl, + formik.values.imageFile, + handleUploadProgress.bind(null, instanceName), + handleUploadError, + ); + + setSocket(ws); + + // set up event queue for the operation + eventQueue.set( + operationId, + () => handleSuccess(getInstanceName(operation.metadata)), + handleFailure, + invalidateCache, + ); + }) + .catch((e) => { + notify.failure("Instance creation failed.", e); + formik.setSubmitting(false); + setUploadState(null); + }); + }; + + const archOptions = supportedVMArchOptions( + settings?.environment?.architectures || [], + ); + + const formik = useFormik({ + initialValues: { + name: defaultInstanceName || "", + pool: "", + imageFile: null, + formatConversion: true, + virtioConversion: false, + architecture: archOptions[0]?.value, + }, + validateOnMount: true, + validationSchema: Yup.object().shape({ + name: instanceNameValidation( + project?.name || "", + instanceNameAbort, + ).optional(), + }), + onSubmit: handleSubmit, + }); + + const handleFileChange = async (e: ChangeEvent) => { + const { onChange } = formik.getFieldProps("imageFile"); + onChange(e); + + if (e.currentTarget.files) { + const file = e.currentTarget.files[0]; + await formik.setFieldValue("imageFile", file); + + if (!defaultInstanceName) { + const instanceName = fileToInstanceName(file.name, "-import"); + await formik.setFieldValue("name", instanceName); + // validate instance name + await formik.validateField("name"); + void formik.setFieldTouched("name", true, true); + if (!formik.errors.name) { + formik.setFieldError("name", undefined); + } + } + + // If the image is already in raw format, remove the format conversion option + // this will optimise the conversion process since raw vm images do not need to be converted + const isRawImage = await isImageTypeRaw(file); + await formik.setFieldValue("formatConversion", !isRawImage); + } + }; + + const handleCloseModal = useCallback(() => { + formik.resetForm(); + close(); + notify.clear(); + handleCancelUpload(); + }, [formik.resetForm, close, notify, socket]); + + const noFileSelectedMessage = !formik.values.imageFile + ? "Please select a file before adding custom configuration." + : ""; + + return ( + <> +
+ + void handleFileChange(e)} + /> + + void formik.setFieldValue("pool", value)} + selectProps={{ + id: "pool", + label: "Root storage pool", + disabled: !project || !!noFileSelectedMessage, + title: noFileSelectedMessage, + }} + /> + + Convert to raw format{" "} + + + } + disabled={!!noFileSelectedMessage} + checked={formik.values.formatConversion} + /> + + Add Virtio drivers{" "} + + + } + disabled={!!noFileSelectedMessage} + checked={formik.values.virtioConversion} + /> + +
+ + void formik.submitForm()} + > + Upload and create + +
+ + ); +}; + +export default UploadExternalFormatFileForm; diff --git a/src/pages/instances/forms/UploadInstanceBackupFileForm.tsx b/src/pages/instances/forms/UploadInstanceBackupFileForm.tsx new file mode 100644 index 0000000000..e77318d0b2 --- /dev/null +++ b/src/pages/instances/forms/UploadInstanceBackupFileForm.tsx @@ -0,0 +1,250 @@ +import { + ActionButton, + Button, + Form, + Input, + useNotify, +} from "@canonical/react-components"; +import { useProject } from "context/project"; +import StoragePoolSelector from "pages/storage/StoragePoolSelector"; +import { ChangeEvent, FC, useCallback, useState } from "react"; +import { fileToInstanceName, instanceNameValidation } from "util/instances"; +import { UploadState } from "types/storage"; +import { useEventQueue } from "context/eventQueue"; +import { useToastNotification } from "context/toastNotificationProvider"; +import { useQueryClient } from "@tanstack/react-query"; +import { useNavigate } from "react-router-dom"; +import { queryKeys } from "util/queryKeys"; +import { uploadInstance } from "api/instances"; +import { useFormik } from "formik"; +import { AxiosError } from "axios"; +import { LxdSyncResponse } from "types/apiResponse"; +import * as Yup from "yup"; +import classnames from "classnames"; +import { useSupportedFeatures } from "context/useSupportedFeatures"; +import InstanceFileTypeSelector, { + InstanceFileType, +} from "./InstanceFileTypeSelector"; + +export interface UploadInstanceBackupFileFormValues { + instanceFile: File | null; + name: string; + pool: string; +} + +interface Props { + close: () => void; + uploadState: UploadState | null; + setUploadState: (state: UploadState | null) => void; + fileType: InstanceFileType; + setFileType: (value: InstanceFileType) => void; + defaultInstanceName?: string; +} + +const UploadInstanceBackupFileForm: FC = ({ + close, + uploadState, + setUploadState, + fileType, + setFileType, + defaultInstanceName, +}) => { + const { project, isLoading } = useProject(); + const eventQueue = useEventQueue(); + const toastNotify = useToastNotification(); + const notify = useNotify(); + const queryClient = useQueryClient(); + const navigate = useNavigate(); + const [uploadAbort, setUploadAbort] = useState(null); + const instanceNameAbort = useState(null); + const { hasInstanceImportConversion } = useSupportedFeatures(); + + const handleSuccess = (instanceName: string) => { + const message = ( + <> + Created instance {instanceName}. + + ); + + 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 handleUpload = () => { + if (!formik.values.instanceFile) { + return; + } + + const uploadController = new AbortController(); + setUploadAbort(uploadController); + + const instanceName = formik.values.name; + void uploadInstance( + formik.values.instanceFile, + instanceName, + project?.name, + formik.values.pool, + setUploadState, + uploadController, + ) + .then((operation) => { + toastNotify.info( + <> + Upload completed. Now creating instance{" "} + {instanceName}. + , + ); + + eventQueue.set( + operation.metadata.id, + () => handleSuccess(instanceName), + handleFailure, + handleFinish, + ); + + handleCloseModal(); + navigate(`/ui/project/${project?.name}/instances`); + }) + .catch((e: AxiosError>) => { + const error = new Error(e.response?.data.error); + notify.failure("Instance upload failed", error); + formik.setSubmitting(false); + setUploadState(null); + }); + }; + + const formik = useFormik({ + initialValues: { + name: defaultInstanceName || "", + pool: "", + instanceFile: null, + }, + validateOnMount: true, + validationSchema: Yup.object().shape({ + name: instanceNameValidation( + project?.name || "", + instanceNameAbort, + ).optional(), + }), + onSubmit: handleUpload, + }); + + const handleCloseModal = useCallback(() => { + uploadAbort?.abort(); + setUploadState(null); + setUploadAbort(null); + formik.resetForm(); + close(); + notify.clear(); + }, [uploadAbort, formik.resetForm, close, notify]); + + const changeFile = async (e: ChangeEvent) => { + const { onChange } = formik.getFieldProps("instanceFile"); + onChange(e); + + if (e.currentTarget.files) { + const file = e.currentTarget.files[0]; + await formik.setFieldValue("instanceFile", file); + + if (!defaultInstanceName) { + const instanceName = fileToInstanceName(file.name, "-import"); + await formik.setFieldValue("name", instanceName); + // validate instance name + await formik.validateField("name"); + void formik.setFieldTouched("name", true, true); + if (!formik.errors.name) { + formik.setFieldError("name", undefined); + } + } + } + }; + + const noFileSelectedMessage = !formik.values.instanceFile + ? "Please select a file before adding custom configuration." + : ""; + + return ( + <> +
+ {hasInstanceImportConversion && ( + + )} + void changeFile(e)} + /> + + void formik.setFieldValue("pool", value)} + selectProps={{ + id: "pool", + label: "Root storage pool", + disabled: isLoading || !!noFileSelectedMessage, + title: noFileSelectedMessage, + }} + /> + +
+ + void formik.submitForm()} + > + Upload and create + +
+ + ); +}; + +export default UploadInstanceBackupFileForm; diff --git a/src/pages/instances/forms/UploadInstanceFileModal.tsx b/src/pages/instances/forms/UploadInstanceFileModal.tsx new file mode 100644 index 0000000000..ce6c9dc6cf --- /dev/null +++ b/src/pages/instances/forms/UploadInstanceFileModal.tsx @@ -0,0 +1,61 @@ +import { FC, useState } from "react"; +import { Modal } from "@canonical/react-components"; +import { UploadState } from "types/storage"; +import ProgressBar from "components/ProgressBar"; +import { humanFileSize } from "util/helpers"; +import UploadInstanceBackupFileForm from "./UploadInstanceBackupFileForm"; +import UploadExternalFormatFileForm from "./UploadExternalFormatFileForm"; +import NotificationRow from "components/NotificationRow"; +import { InstanceFileType } from "./InstanceFileTypeSelector"; +interface Props { + close: () => void; + name?: string; +} + +const UploadInstanceFileModal: FC = ({ close, name }) => { + const [fileType, setFileType] = useState("instance-backup"); + const [uploadState, setUploadState] = useState(null); + + return ( + + + {uploadState && ( + <> + +

+ {humanFileSize(uploadState.loaded)} loaded of{" "} + {humanFileSize(uploadState.total ?? 0)} +

+ + )} + + {fileType === "instance-backup" && ( + + )} + + {fileType === "external-format" && ( + + )} +
+ ); +}; + +export default UploadInstanceFileModal; diff --git a/src/sass/_upload_instance_modal.scss b/src/sass/_upload_instance_modal.scss index b4a4632180..7344ac8c52 100644 --- a/src/sass/_upload_instance_modal.scss +++ b/src/sass/_upload_instance_modal.scss @@ -1,6 +1,14 @@ .upload-instance-modal { - .p-modal__dialog { - margin: auto; - width: 35rem; + .content-details { + margin-left: -$sph--x-small; + padding-left: $sph--x-small; + } + + @include large { + .p-modal__dialog { + margin: auto; + position: absolute; + width: 35rem; + } } } diff --git a/src/util/helpers.tsx b/src/util/helpers.tsx index 9dd1078231..e45da740a2 100644 --- a/src/util/helpers.tsx +++ b/src/util/helpers.tsx @@ -290,3 +290,11 @@ export const getClientOS = (userAgent: string) => { return null; }; + +export const getFileExtension = (filename: string): string => { + if (!filename.includes(".")) { + return ""; + } + + return `.${filename.split(".").pop()}` || ""; +}; diff --git a/src/util/instances.tsx b/src/util/instances.tsx index 63ea373349..59600fdea2 100644 --- a/src/util/instances.tsx +++ b/src/util/instances.tsx @@ -2,7 +2,11 @@ import { LxdOperationResponse } from "types/operation"; import { getInstanceName } from "./operations"; import InstanceLink from "pages/instances/InstanceLink"; import { ReactNode } from "react"; -import { AbortControllerState, checkDuplicateName } from "./helpers"; +import { + AbortControllerState, + checkDuplicateName, + getFileExtension, +} from "./helpers"; import * as Yup from "yup"; export const instanceLinkFromName = (args: { @@ -63,3 +67,18 @@ export const truncateInstanceName = ( return name + suffix; }; + +export const sanitizeInstanceName = (name: string): string => { + return name.replace(/[^A-Za-z0-9-]/g, "-"); +}; + +export const fileToInstanceName = ( + fileName: string, + suffix?: string, +): string => { + const fileExtension = getFileExtension(fileName); + fileName = fileExtension ? fileName.replace(fileExtension, "") : fileName; + const sanitisedFileName = sanitizeInstanceName(fileName); + const instanceName = truncateInstanceName(sanitisedFileName, suffix); + return instanceName; +}; diff --git a/src/util/uploadExternalFormatFile.tsx b/src/util/uploadExternalFormatFile.tsx new file mode 100644 index 0000000000..2ba5218410 --- /dev/null +++ b/src/util/uploadExternalFormatFile.tsx @@ -0,0 +1,165 @@ +export interface UploadExternalFormatFileFormValues { + imageFile: File | null; + name: string; + pool: string; + formatConversion: boolean; + virtioConversion: boolean; + architecture: string; +} + +export const supportedVMArchOptions = (hostArchitectures: string[]) => { + const vmArchitectureOptions = [ + { value: "x86_64", label: "x86_64" }, + { value: "aarch64", label: "aarch64" }, + { value: "ppc64le", label: "ppc64le" }, + { value: "s390x", label: "s390x" }, + ]; + + return vmArchitectureOptions.filter((arch) => + hostArchitectures.includes(arch.value), + ); +}; + +export const CANCEL_UPLOAD_REASON = "cancel upload"; + +export const uploadExternalFormatFilePayload = ( + values: UploadExternalFormatFileFormValues, +) => { + 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); +}; + +// This method facilitates the uploading of a file to a server via a websocket connection. +// 1. When the websocket connection opens, a FileReader instance is created to read the file in chunks. +// 2. The readNextFileChunk function slices the file into chunks of the specified chunk size and converts them into binary buffers. +// 3. If the chunk size is zero, indicating the end of the file, the websocket connection is closed. +// 4. The FileReader.onload event handler processes indiviual file chunks as they get read, which sends them +// through the websocket connection. +export const sendFileByWebSocket = ( + socketURL: string, + file: File | null, + onProgress?: (current: number, total: number) => void, + onError?: (error: Error) => void, +) => { + const chunkSize = 1024 * 1024; // 1MB + const ws = new WebSocket(socketURL); + + ws.onopen = () => { + const reader = new FileReader(); + let sentFileSize = 0; + + const readNextFileChunk = () => { + if (!file) { + return; + } + const chunk = file.slice(sentFileSize, sentFileSize + chunkSize); + if (!chunk.size) { + // NOTE: if we close the connection without a code, the default 1005 is used which will cause backend to throw an error + ws.close(1000, "File upload complete"); + return; + } + + reader.readAsArrayBuffer(chunk); + }; + + reader.onload = (event) => { + if ( + !file || + // in case if the upload is cancelled and the websocket is closed, stop streaming data and don't track progress + ws.readyState === WebSocket.CLOSING || + ws.readyState === WebSocket.CLOSED + ) { + return; + } + + const result = event.target?.result; + let data: ArrayBuffer | ArrayBufferLike; + if (result && typeof result === "string") { + // Convert string to ArrayBuffer + data = new TextEncoder().encode(result).buffer; + } else { + data = result as ArrayBuffer; + } + + ws.send(data); + + sentFileSize += data.byteLength; + if (onProgress) { + onProgress(sentFileSize, file.size); + } + + readNextFileChunk(); + }; + + reader.onerror = (e) => { + if (onError) { + onError( + e.target?.error + ? e.target.error + : new Error("Failed to read read file"), + ); + } + }; + + readNextFileChunk(); + }; + + return ws; +}; + +export const isImageTypeRaw = (file: File): Promise => { + return new Promise((resolve) => { + const reader = new FileReader(); + + reader.onload = (event) => { + const arrayBuffer = event.target?.result; + const bytes = new Uint8Array(arrayBuffer as ArrayBuffer); + + // Checking for the DOS/MBR boot sector signature (0x55AA at the end of the first sector) + // NOTE: this is not a foolproof method to determine if a file is a raw image + // not all raw VM images will contain a boot sector with this signature + if (bytes[510] === 0x55 && bytes[511] === 0xaa) { + resolve(true); + } else { + // Further checks can be added here for other raw formats + resolve(false); + } + }; + + reader.onerror = () => { + resolve(false); + }; + + // Read the first 512 bytes (typically the size of a boot sector for a raw vm image) + reader.readAsArrayBuffer(file.slice(0, 512)); + }); +}; diff --git a/tests/instances.spec.ts b/tests/instances.spec.ts index feedd6bc75..c4543fd13c 100644 --- a/tests/instances.spec.ts +++ b/tests/instances.spec.ts @@ -324,7 +324,7 @@ test("Bulk start, pause, unpause and stop instances", async ({ page }) => { await page.waitForSelector(`text=instance stopped.`); }); -test("Export and Upload an instance", async ({ page }) => { +test("Export and Upload an instance backup", async ({ page }) => { //Export an instance await visitInstance(page, instance); const downloadPromise = page.waitForEvent("download"); @@ -338,11 +338,13 @@ test("Export and Upload an instance", async ({ page }) => { //Upload an instance await page.goto("/ui/"); await page.getByRole("button", { name: "Create instance" }).click(); - await page.getByRole("button", { name: "Upload instance" }).click(); - await page.getByLabel("Instance backup file").setInputFiles(INSTANCE_FILE); + await page.getByRole("button", { name: "Upload instance file" }).click(); + await page + .getByRole("textbox", { name: "LXD backup archive (.tar.gz)" }) + .setInputFiles(INSTANCE_FILE); await page.getByRole("textbox", { name: "Enter name" }).fill(`${instance}-1`); await page - .getByLabel("Upload instance") + .getByLabel("Upload instance file") .getByRole("button", { name: "Upload and create" }) .click(); await page.waitForSelector(`text=Created instance ${instance}-1`);