Skip to content

Commit

Permalink
feat(instances) add image creation from instance snapshot
Browse files Browse the repository at this point in the history
Signed-off-by: David Edler <david.edler@canonical.com>
  • Loading branch information
edlerd committed Apr 15, 2024
1 parent 2942dca commit aeaa188
Show file tree
Hide file tree
Showing 12 changed files with 241 additions and 23 deletions.
36 changes: 27 additions & 9 deletions src/api/images.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@ import {
pushFailure,
pushSuccess,
} from "util/helpers";
import { ImportImage, LxdImage } from "types/image";
import { LxdImage } from "types/image";
import { LxdApiResponse } from "types/apiResponse";
import { LxdOperationResponse } from "types/operation";
import { EventQueue } from "context/eventQueue";
import { LxdInstance, LxdInstanceSnapshot } from "types/instance";

export const fetchImage = (
image: string,
Expand Down Expand Up @@ -72,20 +73,19 @@ export const deleteImageBulk = (
});
};

export const importImage = (
remoteImage: ImportImage,
export const createImageFromInstanceSnapshot = (
instance: LxdInstance,
snapshot: LxdInstanceSnapshot,
isPublic: boolean,
): Promise<LxdOperationResponse> => {
return new Promise((resolve, reject) => {
fetch("/1.0/images", {
method: "POST",
body: JSON.stringify({
auto_update: true,
public: isPublic,
source: {
alias: remoteImage.aliases.split(",")[0],
mode: "pull",
protocol: "simplestreams",
type: "image",
server: remoteImage.server,
type: "snapshot",
name: `${instance.name}/${snapshot.name}`,
},
}),
})
Expand All @@ -94,3 +94,21 @@ export const importImage = (
.catch(reject);
});
};

export const createImageAlias = (
fingerprint: string,
alias: string,
): Promise<void> => {
return new Promise((resolve, reject) => {
fetch("/1.0/images/aliases", {
method: "POST",
body: JSON.stringify({
target: fingerprint,
name: alias,
}),
})
.then(handleResponse)
.then(resolve)
.catch(reject);
});
};
2 changes: 1 addition & 1 deletion src/components/forms/SnapshotForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ const SnapshotForm: FC<Props> = (props) => {
disabled={!formik.isValid}
onClick={() => void formik.submitForm()}
>
{isEdit ? "Save" : "Create"}
{isEdit ? "Save changes" : "Create snapshot"}
</ActionButton>
</>
}
Expand Down
5 changes: 3 additions & 2 deletions src/context/eventQueue.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { createContext, FC, ReactNode, useContext } from "react";
import { LxdEvent } from "types/event";

export interface EventQueue {
get: (operationId: string) => EventCallback | undefined;
set: (
operationId: string,
onSuccess: () => void,
onSuccess: (event: LxdEvent) => void,
onFailure: (msg: string) => void,
onFinish?: () => void,
) => void;
Expand All @@ -22,7 +23,7 @@ interface Props {
}

interface EventCallback {
onSuccess: () => void;
onSuccess: (event: LxdEvent) => void;
onFailure: (msg: string) => void;
onFinish?: () => void;
}
Expand Down
2 changes: 1 addition & 1 deletion src/pages/instances/Events.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ const Events: FC = () => {
return;
}
if (event.metadata.status === "Success") {
eventCallback.onSuccess();
eventCallback.onSuccess(event);
eventCallback.onFinish?.();
eventQueue.remove(event.metadata.id);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { FC } from "react";
import usePortal from "react-useportal";
import { Button, Icon } from "@canonical/react-components";
import { LxdInstance, LxdInstanceSnapshot } from "types/instance";
import CreateImageFromInstanceSnapshotForm from "pages/instances/forms/CreateImageFromInstanceSnapshotForm";

interface Props {
instance: LxdInstance;
snapshot: LxdInstanceSnapshot;
isDeleting: boolean;
isRestoring: boolean;
}

const CreateImageFromInstanceSnapshotBtn: FC<Props> = ({
instance,
snapshot,
isDeleting,
isRestoring,
}) => {
const { openPortal, closePortal, isOpen, Portal } = usePortal();

return (
<>
{isOpen && (
<Portal>
<CreateImageFromInstanceSnapshotForm
close={closePortal}
instance={instance}
snapshot={snapshot}
/>
</Portal>
)}
<Button
appearance="base"
hasIcon
dense={true}
disabled={isDeleting || isRestoring}
onClick={openPortal}
type="button"
aria-label="Create image"
title="Create image"
>
<Icon name="export" />
</Button>
</>
);
};

export default CreateImageFromInstanceSnapshotBtn;
12 changes: 10 additions & 2 deletions src/pages/instances/actions/snapshots/InstanceSnapshotActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import ItemName from "components/ItemName";
import ConfirmationForce from "components/ConfirmationForce";
import { useEventQueue } from "context/eventQueue";
import InstanceEditSnapshotBtn from "./InstanceEditSnapshotBtn";
import CreateImageFromInstanceSnapshotBtn from "pages/instances/actions/snapshots/CreateImageFromInstanceSnapshotBtn";

interface Props {
instance: LxdInstance;
Expand Down Expand Up @@ -102,6 +103,13 @@ const InstanceSnapshotActions: FC<Props> = ({
isDeleting={isDeleting}
isRestoring={isRestoring}
/>,
<CreateImageFromInstanceSnapshotBtn
key="publish"
instance={instance}
snapshot={snapshot}
isRestoring={isRestoring}
isDeleting={isDeleting}
/>,
<ConfirmationButton
key="restore"
appearance="base"
Expand All @@ -123,7 +131,7 @@ const InstanceSnapshotActions: FC<Props> = ({
force={[restoreState, setRestoreState]}
/>
) : undefined,
confirmButtonLabel: "Restore",
confirmButtonLabel: "Restore snapshot",
confirmButtonAppearance: "positive",
close: () => setRestoreState(true),
onConfirm: handleRestore,
Expand All @@ -148,7 +156,7 @@ const InstanceSnapshotActions: FC<Props> = ({
This action cannot be undone, and can result in data loss.
</p>
),
confirmButtonLabel: "Delete",
confirmButtonLabel: "Delete snapshot",
onConfirm: handleDelete,
}}
disabled={isDeleting || isRestoring}
Expand Down
136 changes: 136 additions & 0 deletions src/pages/instances/forms/CreateImageFromInstanceSnapshotForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { FC } from "react";
import { LxdInstance, LxdInstanceSnapshot } from "types/instance";
import { useEventQueue } from "context/eventQueue";
import { useFormik } from "formik";
import { useToastNotification } from "context/toastNotificationProvider";
import { createImageAlias, createImageFromInstanceSnapshot } from "api/images";
import {
ActionButton,
Button,
Form,
Input,
Modal,
} from "@canonical/react-components";
import * as Yup from "yup";
import { Link } from "react-router-dom";

interface Props {
instance: LxdInstance;
snapshot: LxdInstanceSnapshot;
close: () => void;
}

const CreateImageFromInstanceSnapshotForm: FC<Props> = ({
instance,
snapshot,
close,
}) => {
const eventQueue = useEventQueue();
const toastNotify = useToastNotification();

const notifySuccess = () => {
const created = (
<Link to={`/ui/project/${instance.project}/images`}>created</Link>
);
toastNotify.success(
<>
Image {created} from snapshot <b>{snapshot.name}</b>.
</>,
);
};

const formik = useFormik<{ alias: string; isPublic: boolean }>({
initialValues: {
alias: "",
isPublic: false,
},
validationSchema: Yup.object().shape({
alias: Yup.string(),
}),
onSubmit: (values) => {
const alias = values.alias;

createImageFromInstanceSnapshot(instance, snapshot, values.isPublic)
.then((operation) => {
toastNotify.info(
<>
Creation of image from snapshot <b>{snapshot.name}</b> started.
</>,
);
close();
eventQueue.set(
operation.metadata.id,
(event) => {
if (alias) {
const fingerprint = event.metadata.metadata?.fingerprint ?? "";
void createImageAlias(fingerprint, alias).then(notifySuccess);
} else {
notifySuccess();
}
},
(msg) => {
toastNotify.failure(
`Image creation from snapshot "${snapshot.name}" failed.`,
new Error(msg),
);
},
);
})
.catch((e) => {
toastNotify.failure(
`Image creation from snapshot "${snapshot.name}" failed.`,
e,
);
});
},
});

return (
<Modal
close={close}
title="Create image from instance snapshot"
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}
onClick={() => void formik.submitForm()}
>
Create image
</ActionButton>
</>
}
>
<Form onSubmit={formik.handleSubmit}>
<Input type="text" label="Instance" value={instance.name} disabled />
<Input type="text" label="Snapshot" value={snapshot.name} disabled />
<Input
{...formik.getFieldProps("alias")}
type="text"
label="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" />
</Form>
</Modal>
);
};

export default CreateImageFromInstanceSnapshotForm;
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ const VolumeSnapshotActions: FC<Props> = ({ volume, snapshot }) => {
This action cannot be undone, and can result in data loss.
</p>
),
confirmButtonLabel: "Delete",
confirmButtonLabel: "Delete snapshot",
onConfirm: handleDelete,
}}
disabled={isDeleting || isRestoring}
Expand Down
1 change: 1 addition & 0 deletions src/sass/_toast.scss
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@

.individual-notification {
margin: 0.5rem 0;
overflow: hidden;
}

.dismiss {
Expand Down
3 changes: 3 additions & 0 deletions src/types/event.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ export interface LxdEvent {
id: string;
action: string;
description: string;
metadata?: {
fingerprint?: string;
};
source: string;
resources: {
instances: [string];
Expand Down
Loading

0 comments on commit aeaa188

Please sign in to comment.