Skip to content

Commit

Permalink
feat: [WD-13704] Create image from backup file (#848)
Browse files Browse the repository at this point in the history
## Done

- Added import tarball button as well as associated modal.
- Minor scss changes
- Additional API calls for importing the file and amending image
properties.

## QA

1. Run the LXD-UI:
- On the demo server via the link posted by @webteam-app below. This is
only available for PRs created by collaborators of the repo. Ask
@mas-who or @edlerd for access.
- With a local copy of this branch, [build and run as described in the
docs](../CONTRIBUTING.md#setting-up-for-development).
2. Perform the following QA steps:
- Upload a unified tarball image or a split tarball image to LXD-UI by
selecting the "Import from Tarball" button on the Image list page.

## Screenshots


![image](https://github.com/user-attachments/assets/20c256bd-5cfc-400a-99e6-e0be541b9f84)

![image](https://github.com/user-attachments/assets/094f86e7-b37c-4ce3-adcd-063cdd8acb82)

![image](https://github.com/user-attachments/assets/0041482d-ae79-4bb4-b0b1-333359a8690e)
  • Loading branch information
Kxiru authored Aug 22, 2024
2 parents dd59a2a + d2236b7 commit fe13643
Show file tree
Hide file tree
Showing 7 changed files with 266 additions and 9 deletions.
27 changes: 27 additions & 0 deletions src/api/images.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { LxdApiResponse } from "types/apiResponse";
import { LxdOperationResponse } from "types/operation";
import { EventQueue } from "context/eventQueue";
import { LxdInstance } from "types/instance";
import { UploadState } from "types/storage";
import axios, { AxiosResponse } from "axios";

export const fetchImage = (
image: string,
Expand Down Expand Up @@ -107,3 +109,28 @@ export const createImage = (
.catch(reject);
});
};

export const uploadImage = (
body: File | FormData,
isPublic: boolean,
setUploadState: (value: UploadState) => void,
): Promise<LxdOperationResponse> => {
return new Promise((resolve, reject) => {
axios
.post(`/1.0/images`, body, {
headers: {
"X-LXD-public": JSON.stringify(isPublic),
},
onUploadProgress: (event) => {
setUploadState({
percentage: event.progress ? Math.floor(event.progress * 100) : 0,
loaded: event.loaded,
total: event.total,
});
},
})
.then((response: AxiosResponse<LxdOperationResponse>) => response.data)
.then(resolve)
.catch(reject);
});
};
6 changes: 4 additions & 2 deletions src/pages/images/ImageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ import { useDocs } from "context/useDocs";
import CustomLayout from "components/CustomLayout";
import PageHeader from "components/PageHeader";
import CustomIsoBtn from "pages/storage/actions/CustomIsoBtn";
import ExportImageTarballBtn from "./actions/ExportImageTarballBtn";
import DownloadImageBtn from "./actions/DownloadImageBtn";
import UploadImageBtn from "pages/images/actions/UploadImageBtn";

const ImageList: FC = () => {
const docBaseLink = useDocs();
Expand Down Expand Up @@ -112,7 +113,7 @@ const ImageList: FC = () => {
project={project}
image={localLxdToRemoteImage(image)}
/>,
<ExportImageTarballBtn key="export-image" image={image} />,
<DownloadImageBtn key="export-image" image={image} />,
<DeleteImageBtn key="delete" image={image} project={project} />,
]}
/>
Expand Down Expand Up @@ -229,6 +230,7 @@ const ImageList: FC = () => {
)}
</PageHeader.Left>
<PageHeader.BaseActions>
<UploadImageBtn project={project} />
<CustomIsoBtn project={project} />
</PageHeader.BaseActions>
</PageHeader>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ interface Props {
image: LxdImage;
}

const ExportImageTarballBtn: FC<Props> = ({ image }) => {
const DownloadImageBtn: FC<Props> = ({ image }) => {
const toastNotify = useToastNotification();
const [isLoading, setLoading] = useState(false);
const description = image.properties?.description ?? image.fingerprint;
Expand Down Expand Up @@ -51,4 +51,4 @@ const ExportImageTarballBtn: FC<Props> = ({ image }) => {
);
};

export default ExportImageTarballBtn;
export default DownloadImageBtn;
27 changes: 27 additions & 0 deletions src/pages/images/actions/UploadImageBtn.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { FC } from "react";
import { ActionButton } from "@canonical/react-components";
import usePortal from "react-useportal";
import UploadImageForm from "./forms/UploadImageForm";

interface Props {
project: string;
}

const UploadImageBtn: FC<Props> = ({ project }) => {
const { openPortal, closePortal, isOpen, Portal } = usePortal();

return (
<>
{isOpen && (
<Portal>
<UploadImageForm close={closePortal} project={project} />
</Portal>
)}
<ActionButton className="u-no-margin--bottom" onClick={openPortal}>
<span>Upload image</span>
</ActionButton>
</>
);
};

export default UploadImageBtn;
198 changes: 198 additions & 0 deletions src/pages/images/actions/forms/UploadImageForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
import { ChangeEvent, FC, useState } from "react";
import { useEventQueue } from "context/eventQueue";
import { useFormik } from "formik";
import { useToastNotification } from "context/toastNotificationProvider";
import { createImageAlias, uploadImage } from "api/images";
import {
ActionButton,
Button,
Form,
Input,
Modal,
} from "@canonical/react-components";
import * as Yup from "yup";
import { Link } from "react-router-dom";
import { humanFileSize } from "util/helpers";
import ProgressBar from "components/ProgressBar";
import { UploadState } from "types/storage";
import Loader from "components/Loader";
import { useQueryClient } from "@tanstack/react-query";
import { queryKeys } from "util/queryKeys";
import { LxdSyncResponse } from "types/apiResponse";
import { AxiosError } from "axios";

interface Props {
close: () => void;
project: string;
}

const UploadImageForm: FC<Props> = ({ close, project }) => {
const eventQueue = useEventQueue();
const toastNotify = useToastNotification();
const [uploadState, setUploadState] = useState<UploadState | null>(null);
const queryClient = useQueryClient();

const notifySuccess = () => {
const uploaded = <Link to={`/ui/project/${project}/images`}>uploaded</Link>;
toastNotify.success(<>Image {uploaded}.</>);
};

const changeFile = (e: ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
void formik.setFieldValue("fileList", e.target.files);
}
};

const getImageUploadBody = (fileList: FileList): File | FormData => {
if (fileList.length === 1) {
return fileList[0];
} else {
// Sorting by Size. The metadata file is very likely to be smaller than the image itself.
const formData = new FormData();
const sortedFiles = Array.from(fileList).sort((a, b) => a.size - b.size);

formData.append("metadata", sortedFiles[0]);
formData.append("rootfs.img", sortedFiles[1]);

return formData;
}
};

const formik = useFormik<{
alias: string;
isPublic: boolean;
fileList: FileList | null;
}>({
initialValues: {
alias: "",
isPublic: false,
fileList: null,
},
validationSchema: Yup.object().shape({
alias: Yup.string(),
}),
onSubmit: (values) => {
if (values.fileList) {
if (values.fileList.length > 2) {
close();
toastNotify.failure(
`Image upload failed.`,
new Error("Too many files selected"),
);
return;
}
uploadImage(
getImageUploadBody(values.fileList),
values.isPublic,
setUploadState,
)
.then((operation) => {
toastNotify.info(<>Creation of image from file started.</>);

eventQueue.set(
operation.metadata.id,
(event) => {
const fingerprint = event.metadata.metadata?.fingerprint ?? "";
if (values.alias) {
void createImageAlias(fingerprint, values.alias);
}
void queryClient.invalidateQueries({
queryKey: [queryKeys.images, project],
});
notifySuccess();
},
(msg) => {
toastNotify.failure(`Image upload failed.`, new Error(msg));
},
);
})
.catch((e: AxiosError<LxdSyncResponse<null>>) => {
const error = new Error(e.response?.data.error);
toastNotify.failure("Image upload failed", error);
})
.finally(() => {
close();
});
} else {
close();
toastNotify.failure(`Image upload failed`, new Error("Missing files"));
}
},
});

return (
<Modal
close={close}
title="Import image from file"
className="upload-image-modal"
buttonRow={
<>
{uploadState && (
<>
<ProgressBar percentage={Math.floor(uploadState.percentage)} />
<p>
{humanFileSize(uploadState.loaded)} loaded of{" "}
{humanFileSize(uploadState.total ?? 0)}
</p>
{uploadState.loaded === uploadState.total && (
<Loader text="Uploading file" />
)}
</>
)}
<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 || !formik.values.fileList}
onClick={() => void formik.submitForm()}
>
Upload image
</ActionButton>
</>
}
>
<Form
className={uploadState ? "u-hide" : ""}
onSubmit={formik.handleSubmit}
>
<Input
type="file"
name="fileList"
label="Image backup file"
onChange={changeFile}
multiple
/>
<Input
{...formik.getFieldProps("alias")}
type="text"
label="Alias"
placeholder="Enter alias"
error={formik.touched.alias ? formik.errors.alias : null}
/>
<Input
{...formik.getFieldProps("isPublic")}
type="checkbox"
label="Make the image publicly available"
error={formik.touched.isPublic ? formik.errors.isPublic : null}
/>
{/* hidden submit to enable enter key in inputs */}
<Input
type="submit"
hidden
value="Hidden input"
disabled={!formik.isValid || !formik.values.fileList}
/>
</Form>
</Modal>
);
};

export default UploadImageForm;
8 changes: 8 additions & 0 deletions src/sass/_images.scss
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,11 @@
margin-top: -7px;
}
}

.upload-image-modal {
.p-modal__dialog {
@include large {
width: 35rem;
}
}
}
5 changes: 0 additions & 5 deletions src/sass/_page_header.scss
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,6 @@
width: 45vw;
}
}

.page-header__base-actions {
align-self: flex-start;
flex: 1;
}
}

@media screen and (min-width: $breakpoint-large) and (width <= 1135px) {
Expand Down

0 comments on commit fe13643

Please sign in to comment.