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
    - moved request to get volume snapshots from StorageVolumeDetail to StorageVolumeSnapshots component
    - removed loadCustomVolumeAndSnapshots.tsx as it is not needed
    - removed unecessary refetchOnMount
    - removed description input for custom storage volume snapshots
    - improved logic for determining if snapshot is disabled for the project
    - split snapshots utils into generic, instance and volume specific files
    - updated LxdSyncResponse interface with generic type
    - handle undefined case for isSnapshotDisabled in utils/snapshots.tsx
    - consistent props destructuring for components throughout
    - removed useInstanceSnapshot and useVolumeSnapshot hooks
    - moved NotificationRow below TabLinks in StorageVolumeDetail.tsx
    - improve small screen viewing for storage volumes table with collapsed columns
    - added disable snapshot creation tooltips for instance and volume snapshots
    - minor styling fixes
    - spelling fix
    - moved generateLinkForVolumeDetail from utils file to StorageVolumeNameLink component

Signed-off-by: Mason Hu <[email protected]>
  • Loading branch information
mas-who committed Dec 8, 2023
1 parent 2fac990 commit 225aa1e
Show file tree
Hide file tree
Showing 54 changed files with 2,809 additions and 584 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
165 changes: 165 additions & 0 deletions src/api/volume-snapshots.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
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";
import { splitVolumeSnapshotName } from "util/storageVolume";

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<unknown>> => {
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;
}): Promise<LxdSyncResponse<unknown>> => {
const { volume, snapshot, expiresAt } = 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,
}),
},
)
.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.map((snapshot) => ({
...snapshot,
name: splitVolumeSnapshotName(snapshot.name).snapshotName,
})),
),
)
.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
Expand Up @@ -3,57 +3,30 @@ 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 }>>;
close: () => void;
isStateful: boolean;
isRunning?: boolean;
additionalFormInput?: JSX.Element;
}

const SnapshotForm: FC<Props> = ({
isEdit,
formik,
close,
isStateful,
isRunning,
}) => {
const SnapshotForm: FC<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 +56,6 @@ const SnapshotForm: FC<Props> = ({
}
onKeyDown={handleEscKey}
>
<NotificationRow />
<Form onSubmit={formik.handleSubmit}>
<Input
id="name"
Expand Down Expand Up @@ -131,37 +103,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 225aa1e

Please sign in to comment.