Skip to content

Commit

Permalink
feat(storage) crud for custom volume snapshots
Browse files Browse the repository at this point in the history
- removed snapshot volume type from volumes list filter.
- clicking on volume name will redirect to instance detail page if it's a container or vm volume, redirect to image list page if it's a image volume and redirect to volume details if it's a custom volume.
- added the snapshots column to the volumes list table. Number of snapshots per volume is calculated based on number of snapshot volumes that matches each volume name.
- CTA for storage volume list table. For custom storage volumes, user can add snapshots or delete volume. For instance volumes, link to instance detail and for image volumes link to images list. Show CTA only on hover volume row.
- Generalised snapshot form modal
- Snapshot form modal will now be rendered using portal
- Added storage volume snapshot api
- Create instance and custom volume snapshots using the generalised snapshot form modal
- Fixed issue with snapshot form modal retaining stale states after snapshot created, this was an issue with instance snapshot as well
- Implemented snapshots tab for storage volume detail page (heavy reference to the snapshots tab for instnace detail page)
- Code cleanup
- Fixed issue with not able to sort by the snapshots column on the storage volume list table
- Fixed issue with disabling snapshot creation if project is restricted
- Fixed tooltip wording for add snapshot CTA button on volume list table

Signed-off-by: Mason Hu <[email protected]>
  • Loading branch information
mas-who committed Dec 6, 2023
1 parent 2c26f31 commit afe4c3f
Show file tree
Hide file tree
Showing 45 changed files with 2,517 additions and 595 deletions.
8 changes: 4 additions & 4 deletions src/api/snapshots.tsx → src/api/instance-snapshots.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
pushFailure,
pushSuccess,
} from "util/helpers";
import { LxdInstance, LxdSnapshot } from "types/instance";
import { LxdInstance, LxdInstanceSnapshot } from "types/instance";
import { LxdOperationResponse } from "types/operation";
import { EventQueue } from "context/eventQueue";

Expand Down Expand Up @@ -73,7 +73,7 @@ export const deleteSnapshotBulk = (

export const restoreSnapshot = (
instance: LxdInstance,
snapshot: LxdSnapshot,
snapshot: LxdInstanceSnapshot,
restoreState: boolean,
): Promise<LxdOperationResponse> => {
return new Promise((resolve, reject) => {
Expand All @@ -92,7 +92,7 @@ export const restoreSnapshot = (

export const renameSnapshot = (
instance: LxdInstance,
snapshot: LxdSnapshot,
snapshot: LxdInstanceSnapshot,
newName: string,
): Promise<LxdOperationResponse> => {
return new Promise((resolve, reject) => {
Expand All @@ -113,7 +113,7 @@ export const renameSnapshot = (

export const updateSnapshot = (
instance: LxdInstance,
snapshot: LxdSnapshot,
snapshot: LxdInstanceSnapshot,
expiresAt: string,
): Promise<LxdOperationResponse> => {
return new Promise((resolve, reject) => {
Expand Down
159 changes: 159 additions & 0 deletions src/api/volume-snapshots.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import {
continueOrFinish,
handleResponse,
pushFailure,
pushSuccess,
} from "util/helpers";
import { LxdOperationResponse } from "types/operation";
import { LxdStorageVolume, LxdVolumeSnapshot } from "types/storage";
import { LxdApiResponse, LxdSyncResponse } from "types/apiResponse";
import { EventQueue } from "context/eventQueue";

export const createSnapshot = (args: {
volume: LxdStorageVolume;
name: string;
expiresAt: string | null;
}): Promise<LxdOperationResponse> => {
const { volume, name, expiresAt } = args;
return new Promise((resolve, reject) => {
fetch(
`/1.0/storage-pools/${volume.pool}/volumes/custom/${volume.name}/snapshots?project=${volume.project}`,
{
method: "POST",
body: JSON.stringify({
name,
expires_at: expiresAt,
}),
},
)
.then(handleResponse)
.then(resolve)
.catch(reject);
});
};

export const deleteSnapshot = (
volume: LxdStorageVolume,
snapshot: Pick<LxdVolumeSnapshot, "name">,
): Promise<LxdOperationResponse> => {
return new Promise((resolve, reject) => {
fetch(
`/1.0/storage-pools/${volume.pool}/volumes/${volume.type}/${volume.name}/snapshots/${snapshot.name}?project=${volume.project}`,
{
method: "DELETE",
},
)
.then(handleResponse)
.then(resolve)
.catch(reject);
});
};

export const deleteSnapshotBulk = (
volume: LxdStorageVolume,
snapshotNames: string[],
eventQueue: EventQueue,
): Promise<PromiseSettledResult<void>[]> => {
const results: PromiseSettledResult<void>[] = [];
return new Promise((resolve) => {
void Promise.allSettled(
snapshotNames.map(async (name) => {
return await deleteSnapshot(volume, { name }).then((operation) => {
eventQueue.set(
operation.metadata.id,
() => pushSuccess(results),
(msg) => pushFailure(results, msg),
() => continueOrFinish(results, snapshotNames.length, resolve),
);
});
}),
);
});
};

// NOTE: this api endpoint results in a synchronous operation
export const restoreSnapshot = (
volume: LxdStorageVolume,
snapshot: LxdVolumeSnapshot,
): Promise<LxdSyncResponse> => {
return new Promise((resolve, reject) => {
fetch(
`/1.0/storage-pools/${volume.pool}/volumes/${volume.type}/${volume.name}?project=${volume.project}`,
{
method: "PUT",
body: JSON.stringify({
restore: snapshot.name,
}),
},
)
.then(handleResponse)
.then(resolve)
.catch(reject);
});
};

export const renameSnapshot = (args: {
volume: LxdStorageVolume;
snapshot: LxdVolumeSnapshot;
newName: string;
}): Promise<LxdOperationResponse> => {
const { volume, snapshot, newName } = args;
return new Promise((resolve, reject) => {
fetch(
`/1.0/storage-pools/${volume.pool}/volumes/${volume.type}/${volume.name}/snapshots/${snapshot.name}?project=${volume.project}`,
{
method: "POST",
body: JSON.stringify({
name: newName,
}),
},
)
.then(handleResponse)
.then(resolve)
.catch(reject);
});
};

// NOTE: this api endpoint results in a synchronous operation
export const updateSnapshot = (args: {
volume: LxdStorageVolume;
snapshot: LxdVolumeSnapshot;
expiresAt: string | null;
description: string;
}): Promise<LxdSyncResponse> => {
const { volume, snapshot, expiresAt, description } = args;
return new Promise((resolve, reject) => {
fetch(
`/1.0/storage-pools/${volume.pool}/volumes/${volume.type}/${volume.name}/snapshots/${snapshot.name}?project=${volume.project}`,
{
method: "PUT",
body: JSON.stringify({
expires_at: expiresAt,
description: description,
}),
},
)
.then(handleResponse)
.then(resolve)
.catch(reject);
});
};

export const fetchStorageVolumeSnapshots = (args: {
pool: string;
type: string;
volumeName: string;
project: string;
}) => {
const { pool, type, volumeName, project } = args;
return new Promise<LxdVolumeSnapshot[]>((resolve, reject) => {
fetch(
`/1.0/storage-pools/${pool}/volumes/${type}/${volumeName}/snapshots?project=${project}&recursion=2`,
)
.then(handleResponse)
.then((data: LxdApiResponse<LxdVolumeSnapshot[]>) =>
resolve(data.metadata),
)
.catch(reject);
});
};
25 changes: 25 additions & 0 deletions src/components/PortalModalBtn.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import React, { BaseSyntheticEvent, FC, ReactElement } from "react";
import usePortal from "react-useportal";

interface Props {
renderModal: (closeModal: () => void) => ReactElement;
renderButton: (
openModal: (e: BaseSyntheticEvent<object, unknown, unknown>) => void,
) => ReactElement;
}

const PortalModalBtn: FC<Props> = ({ renderButton, renderModal }) => {
const { openPortal, closePortal, isOpen, Portal } = usePortal();

const modal = renderModal(closePortal);
const button = renderButton(openPortal);

return (
<>
{isOpen && <Portal>{modal}</Portal>}
{button}
</>
);
};

export default PortalModalBtn;
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ interface Props {
children?: ReactNode;
}

const SnapshotsForm: FC<Props> = ({ formik }) => {
const InstanceSnapshotsForm: FC<Props> = ({ formik }) => {
return (
<InstanceConfigurationTable
rows={[
Expand Down Expand Up @@ -78,4 +78,4 @@ const SnapshotsForm: FC<Props> = ({ formik }) => {
);
};

export default SnapshotsForm;
export default InstanceSnapshotsForm;
Original file line number Diff line number Diff line change
@@ -1,59 +1,34 @@
import React, { FC, KeyboardEvent } from "react";
import React, { KeyboardEvent } from "react";
import {
Button,
Col,
Form,
Icon,
Input,
List,
Modal,
Row,
Tooltip,
} from "@canonical/react-components";
import { getTomorrow } from "util/helpers";
import SubmitButton from "components/SubmitButton";
import { TOOLTIP_OVER_MODAL_ZINDEX } from "util/zIndex";
import { SnapshotFormValues } from "util/snapshots";
import { FormikProps } from "formik/dist/types";
import NotificationRow from "components/NotificationRow";

interface Props {
isEdit: boolean;
formik: FormikProps<SnapshotFormValues>;
formik: FormikProps<
SnapshotFormValues<{ stateful?: boolean; description?: string }>
>;
close: () => void;
isStateful: boolean;
isRunning?: boolean;
additionalFormInput?: JSX.Element;
}

const SnapshotForm: FC<Props> = ({
isEdit,
formik,
close,
isStateful,
isRunning,
}) => {
const SnapshotForm = (props: Props) => {
const { isEdit, formik, close, additionalFormInput } = props;
const handleEscKey = (e: KeyboardEvent<HTMLElement>) => {
if (e.key === "Escape") {
close();
}
};

const getStatefulInfo = () => {
if (isEdit || (isStateful && isRunning)) {
return "";
}
if (isStateful) {
return `To create a stateful snapshot,\nthe instance must be running`;
}
return (
<>
{`To create a stateful snapshot, the instance needs\n`}
the <code>migration.stateful</code> config set to true
</>
);
};
const statefulInfoMessage = getStatefulInfo();

const submitForm = () => {
void formik.submitForm();
};
Expand Down Expand Up @@ -83,7 +58,6 @@ const SnapshotForm: FC<Props> = ({
}
onKeyDown={handleEscKey}
>
<NotificationRow />
<Form onSubmit={formik.handleSubmit}>
<Input
id="name"
Expand Down Expand Up @@ -131,37 +105,7 @@ const SnapshotForm: FC<Props> = ({
/>
</Col>
</Row>
{!isEdit && (
<List
inline
items={[
<Input
key="stateful"
id="stateful"
name="stateful"
type="checkbox"
label="Stateful"
wrapperClassName="u-inline-block"
disabled={!isStateful || !isRunning}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
defaultChecked={formik.values.stateful}
/>,
...(statefulInfoMessage
? [
<Tooltip
key="stateful-info"
position="btm-left"
message={statefulInfoMessage}
zIndex={TOOLTIP_OVER_MODAL_ZINDEX}
>
<Icon name="information" />
</Tooltip>,
]
: []),
]}
/>
)}
{additionalFormInput}
</Form>
</Modal>
);
Expand Down
Loading

0 comments on commit afe4c3f

Please sign in to comment.