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
- Fixes based on David's comments:
    - Error handling for instance and volume snapshot creation when project is restricted
    - Moved VolumeSnapshotsForm out of the generic component folder
- Added tests for custom volume snapshot crud
- More changes for David's review:
    - unique naming for instance and volume snapshot api methods
    - removed PortalModalBtn and created addition button components for readability
    - each file should contain one component

Signed-off-by: Mason Hu <mason.hu@canonical.com>
  • Loading branch information
mas-who committed Dec 6, 2023
1 parent 2c26f31 commit 945e540
Show file tree
Hide file tree
Showing 56 changed files with 2,815 additions and 641 deletions.
38 changes: 20 additions & 18 deletions src/api/snapshots.tsx → src/api/instance-snapshots.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@ 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";

export const createSnapshot = (
export const createInstanceSnapshot = (
instance: LxdInstance,
name: string,
expiresAt: string | null,
Expand All @@ -32,7 +32,7 @@ export const createSnapshot = (
});
};

export const deleteSnapshot = (
export const deleteInstanceSnapshot = (
instance: LxdInstance,
snapshot: { name: string },
): Promise<LxdOperationResponse> => {
Expand All @@ -49,7 +49,7 @@ export const deleteSnapshot = (
});
};

export const deleteSnapshotBulk = (
export const deleteInstanceSnapshotBulk = (
instance: LxdInstance,
snapshotNames: string[],
eventQueue: EventQueue,
Expand All @@ -58,22 +58,24 @@ export const deleteSnapshotBulk = (
return new Promise((resolve) => {
void Promise.allSettled(
snapshotNames.map(async (name) => {
return await deleteSnapshot(instance, { name }).then((operation) => {
eventQueue.set(
operation.metadata.id,
() => pushSuccess(results),
(msg) => pushFailure(results, msg),
() => continueOrFinish(results, snapshotNames.length, resolve),
);
});
return await deleteInstanceSnapshot(instance, { name }).then(
(operation) => {
eventQueue.set(
operation.metadata.id,
() => pushSuccess(results),
(msg) => pushFailure(results, msg),
() => continueOrFinish(results, snapshotNames.length, resolve),
);
},
);
}),
);
});
};

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

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

export const updateSnapshot = (
export const updateInstanceSnapshot = (
instance: LxdInstance,
snapshot: LxdSnapshot,
snapshot: LxdInstanceSnapshot,
expiresAt: string,
): Promise<LxdOperationResponse> => {
return new Promise((resolve, reject) => {
Expand Down
161 changes: 161 additions & 0 deletions src/api/volume-snapshots.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
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 createVolumeSnapshot = (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 deleteVolumeSnapshot = (
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 deleteVolumeSnapshotBulk = (
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 deleteVolumeSnapshot(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 restoreVolumeSnapshot = (
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 renameVolumeSnapshot = (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 updateVolumeSnapshot = (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);
});
};
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 945e540

Please sign in to comment.