diff --git a/src/api/snapshots.tsx b/src/api/instance-snapshots.tsx
similarity index 77%
rename from src/api/snapshots.tsx
rename to src/api/instance-snapshots.tsx
index fc30fa41f1..66b91a944a 100644
--- a/src/api/snapshots.tsx
+++ b/src/api/instance-snapshots.tsx
@@ -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,
@@ -32,7 +32,7 @@ export const createSnapshot = (
});
};
-export const deleteSnapshot = (
+export const deleteInstanceSnapshot = (
instance: LxdInstance,
snapshot: { name: string },
): Promise => {
@@ -49,7 +49,7 @@ export const deleteSnapshot = (
});
};
-export const deleteSnapshotBulk = (
+export const deleteInstanceSnapshotBulk = (
instance: LxdInstance,
snapshotNames: string[],
eventQueue: EventQueue,
@@ -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 => {
return new Promise((resolve, reject) => {
@@ -90,9 +92,9 @@ export const restoreSnapshot = (
});
};
-export const renameSnapshot = (
+export const renameInstanceSnapshot = (
instance: LxdInstance,
- snapshot: LxdSnapshot,
+ snapshot: LxdInstanceSnapshot,
newName: string,
): Promise => {
return new Promise((resolve, reject) => {
@@ -111,9 +113,9 @@ export const renameSnapshot = (
});
};
-export const updateSnapshot = (
+export const updateInstanceSnapshot = (
instance: LxdInstance,
- snapshot: LxdSnapshot,
+ snapshot: LxdInstanceSnapshot,
expiresAt: string,
): Promise => {
return new Promise((resolve, reject) => {
diff --git a/src/api/volume-snapshots.tsx b/src/api/volume-snapshots.tsx
new file mode 100644
index 0000000000..27e47c07e5
--- /dev/null
+++ b/src/api/volume-snapshots.tsx
@@ -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 => {
+ 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,
+): Promise => {
+ 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[]> => {
+ const results: PromiseSettledResult[] = [];
+ 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 => {
+ 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 => {
+ 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 => {
+ 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((resolve, reject) => {
+ fetch(
+ `/1.0/storage-pools/${pool}/volumes/${type}/${volumeName}/snapshots?project=${project}&recursion=2`,
+ )
+ .then(handleResponse)
+ .then((data: LxdApiResponse) =>
+ resolve(data.metadata),
+ )
+ .catch(reject);
+ });
+};
diff --git a/src/components/forms/SnapshotsForm.tsx b/src/components/forms/InstanceSnapshotsForm.tsx
similarity index 96%
rename from src/components/forms/SnapshotsForm.tsx
rename to src/components/forms/InstanceSnapshotsForm.tsx
index f1982a0046..4df94a433b 100644
--- a/src/components/forms/SnapshotsForm.tsx
+++ b/src/components/forms/InstanceSnapshotsForm.tsx
@@ -30,7 +30,7 @@ interface Props {
children?: ReactNode;
}
-const SnapshotsForm: FC = ({ formik }) => {
+const InstanceSnapshotsForm: FC = ({ formik }) => {
return (
= ({ formik }) => {
);
};
-export default SnapshotsForm;
+export default InstanceSnapshotsForm;
diff --git a/src/pages/instances/actions/snapshots/SnapshotForm.tsx b/src/components/forms/SnapshotForm.tsx
similarity index 59%
rename from src/pages/instances/actions/snapshots/SnapshotForm.tsx
rename to src/components/forms/SnapshotForm.tsx
index 06b11b0416..58e03e0290 100644
--- a/src/pages/instances/actions/snapshots/SnapshotForm.tsx
+++ b/src/components/forms/SnapshotForm.tsx
@@ -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;
+ formik: FormikProps<
+ SnapshotFormValues<{ stateful?: boolean; description?: string }>
+ >;
close: () => void;
- isStateful: boolean;
- isRunning?: boolean;
+ additionalFormInput?: JSX.Element;
}
-const SnapshotForm: FC = ({
- isEdit,
- formik,
- close,
- isStateful,
- isRunning,
-}) => {
+const SnapshotForm = (props: Props) => {
+ const { isEdit, formik, close, additionalFormInput } = props;
const handleEscKey = (e: KeyboardEvent) => {
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 migration.stateful
config set to true
- >
- );
- };
- const statefulInfoMessage = getStatefulInfo();
-
const submitForm = () => {
void formik.submitForm();
};
@@ -83,7 +58,6 @@ const SnapshotForm: FC = ({
}
onKeyDown={handleEscKey}
>
-
);
diff --git a/src/context/loadCustomVolumeAndSnapshots.tsx b/src/context/loadCustomVolumeAndSnapshots.tsx
new file mode 100644
index 0000000000..05a852aa5a
--- /dev/null
+++ b/src/context/loadCustomVolumeAndSnapshots.tsx
@@ -0,0 +1,28 @@
+import { fetchStorageVolume } from "api/storage-pools";
+import { fetchStorageVolumeSnapshots } from "api/volume-snapshots";
+import { LxdStorageVolume, LxdVolumeSnapshot } from "types/storage";
+import { splitVolumeSnapshotName } from "util/storageVolume";
+
+export const loadCustomVolumeAndSnapshots = async (args: {
+ pool: string;
+ project: string;
+ type: string;
+ volumeName: string;
+}): Promise => {
+ const { pool, project, type, volumeName } = args;
+ const volume = await fetchStorageVolume(pool, project, type, volumeName);
+ const volumeSnapshots = await fetchStorageVolumeSnapshots({
+ pool,
+ project,
+ type,
+ volumeName,
+ });
+
+ return {
+ ...volume,
+ snapshots: volumeSnapshots.map((snapshot) => ({
+ ...snapshot,
+ name: splitVolumeSnapshotName(snapshot.name).snapshotName,
+ })),
+ };
+};
diff --git a/src/pages/instances/CreateInstance.tsx b/src/pages/instances/CreateInstance.tsx
index 3fd7d520ba..698bd3e385 100644
--- a/src/pages/instances/CreateInstance.tsx
+++ b/src/pages/instances/CreateInstance.tsx
@@ -35,10 +35,10 @@ import SecurityPoliciesForm, {
SecurityPoliciesFormValues,
securityPoliciesPayload,
} from "components/forms/SecurityPoliciesForm";
-import SnapshotsForm, {
+import InstanceSnapshotsForm, {
SnapshotFormValues,
snapshotsPayload,
-} from "components/forms/SnapshotsForm";
+} from "components/forms/InstanceSnapshotsForm";
import CloudInitForm, {
CloudInitFormValues,
cloudInitPayload,
@@ -379,7 +379,7 @@ const CreateInstance: FC = () => {
)}
- {section === SNAPSHOTS && }
+ {section === SNAPSHOTS && }
{section === CLOUD_INIT && }
diff --git a/src/pages/instances/EditInstance.tsx b/src/pages/instances/EditInstance.tsx
index adf3b71844..92d2345ae0 100644
--- a/src/pages/instances/EditInstance.tsx
+++ b/src/pages/instances/EditInstance.tsx
@@ -20,9 +20,9 @@ import { FormDeviceValues } from "util/formDevices";
import SecurityPoliciesForm, {
SecurityPoliciesFormValues,
} from "components/forms/SecurityPoliciesForm";
-import SnapshotsForm, {
+import InstanceSnapshotsForm, {
SnapshotFormValues,
-} from "components/forms/SnapshotsForm";
+} from "components/forms/InstanceSnapshotsForm";
import CloudInitForm, {
CloudInitFormValues,
} from "components/forms/CloudInitForm";
@@ -189,7 +189,7 @@ const EditInstance: FC = ({ instance }) => {
)}
{activeSection === slugify(SNAPSHOTS) && (
-
+
)}
{activeSection === slugify(CLOUD_INIT) && (
diff --git a/src/pages/instances/InstanceDetail.tsx b/src/pages/instances/InstanceDetail.tsx
index 109674a186..325e2c5451 100644
--- a/src/pages/instances/InstanceDetail.tsx
+++ b/src/pages/instances/InstanceDetail.tsx
@@ -47,6 +47,7 @@ const InstanceDetail: FC = () => {
} = useQuery({
queryKey: [queryKeys.instances, name, project],
queryFn: () => fetchInstance(name, project),
+ refetchOnMount: (query) => query.state.isInvalidated,
});
if (error) {
diff --git a/src/pages/instances/InstanceSnapshots.tsx b/src/pages/instances/InstanceSnapshots.tsx
index cc039b39bc..3c6313908f 100644
--- a/src/pages/instances/InstanceSnapshots.tsx
+++ b/src/pages/instances/InstanceSnapshots.tsx
@@ -1,6 +1,5 @@
-import React, { FC, ReactNode, useEffect, useState } from "react";
+import React, { ReactNode, useEffect, useState } from "react";
import {
- Button,
EmptyState,
Icon,
NotificationType,
@@ -10,21 +9,21 @@ import {
} from "@canonical/react-components";
import { isoTimeToString } from "util/helpers";
import { LxdInstance } from "types/instance";
-import CreateSnapshotForm from "pages/instances/actions/snapshots/CreateSnapshotForm";
import NotificationRowLegacy from "components/NotificationRowLegacy";
-import SnapshotActions from "./actions/snapshots/SnapshotActions";
+import InstanceSnapshotActions from "./actions/snapshots/InstanceSnapshotActions";
import useEventListener from "@use-it/event-listener";
import Pagination from "components/Pagination";
import { usePagination } from "util/pagination";
import ItemName from "components/ItemName";
import SelectableMainTable from "components/SelectableMainTable";
-import SnapshotBulkDelete from "pages/instances/actions/snapshots/SnapshotBulkDelete";
-import ConfigureSnapshotsBtn from "pages/instances/actions/snapshots/ConfigureSnapshotsBtn";
+import InstanceSnapshotBulkDelete from "pages/instances/actions/snapshots/InstanceSnapshotBulkDelete";
import Loader from "components/Loader";
import { useProject } from "context/project";
import ScrollableTable from "components/ScrollableTable";
import SelectedTableNotification from "components/SelectedTableNotification";
import { useDocs } from "context/useDocs";
+import InstanceConfigureSnapshotsBtn from "./actions/snapshots/InstanceConfigureSnapshotsBtn";
+import InstanceAddSnapshotBtn from "./actions/snapshots/InstanceAddSnapshotBtn";
const collapsedViewMaxWidth = 1250;
export const figureCollapsedScreen = (): boolean =>
@@ -34,10 +33,10 @@ interface Props {
instance: LxdInstance;
}
-const InstanceSnapshots: FC = ({ instance }) => {
+const InstanceSnapshots = (props: Props) => {
+ const { instance } = props;
const docBaseLink = useDocs();
const [query, setQuery] = useState("");
- const [isModalOpen, setModalOpen] = useState(false);
const [inTabNotification, setInTabNotification] =
useState(null);
const [selectedNames, setSelectedNames] = useState([]);
@@ -54,7 +53,9 @@ const InstanceSnapshots: FC = ({ instance }) => {
const { project, isLoading } = useProject();
- const snapshotsDisabled = project?.config["restricted.snapshots"] === "block";
+ const snapshotsDisabled =
+ project?.config["restricted.snapshots"] === "block" ||
+ project?.config["restricted"] === "true"; // Saw this on the demo server restricting snapshot creation;
useEffect(() => {
const validNames = new Set(
@@ -114,7 +115,7 @@ const InstanceSnapshots: FC = ({ instance }) => {
const rows = filteredSnapshots.map((snapshot) => {
const actions = (
- = ({ instance }) => {
) : (
- {isModalOpen && (
-
setModalOpen(false)}
- onSuccess={onSuccess}
- />
- )}
{hasSnapshots && (
{selectedNames.length === 0 ? (
@@ -216,23 +210,22 @@ const InstanceSnapshots: FC
= ({ instance }) => {
aria-label="Search for snapshots"
/>
-
-
+ />
>
) : (
-
setProcessingNames(selectedNames)}
@@ -319,20 +312,19 @@ const InstanceSnapshots: FC = ({ instance }) => {
-
+
-
+ isDisabled={snapshotsDisabled}
+ />
)}
diff --git a/src/pages/instances/actions/snapshots/ConfigureSnapshotsBtn.tsx b/src/pages/instances/actions/snapshots/ConfigureSnapshotsBtn.tsx
deleted file mode 100644
index b05676f7d3..0000000000
--- a/src/pages/instances/actions/snapshots/ConfigureSnapshotsBtn.tsx
+++ /dev/null
@@ -1,48 +0,0 @@
-import React, { FC, ReactNode, useState } from "react";
-import { Button } from "@canonical/react-components";
-import ConfigureSnapshotModal from "pages/instances/actions/snapshots/ConfigureSnapshotModal";
-import { LxdInstance } from "types/instance";
-
-interface Props {
- instance: LxdInstance;
- className?: string;
- isDisabled?: boolean;
- onSuccess: (message: ReactNode) => void;
- onFailure: (title: string, e: unknown) => void;
-}
-
-const ConfigureSnapshotsBtn: FC = ({
- instance,
- className,
- isDisabled = false,
- onSuccess,
- onFailure,
-}) => {
- const [isModal, setModal] = useState(false);
-
- const closeModal = () => {
- setModal(false);
- };
-
- const openModal = () => {
- setModal(true);
- };
-
- return (
- <>
- {isModal && (
-
- )}
-
- >
- );
-};
-
-export default ConfigureSnapshotsBtn;
diff --git a/src/pages/instances/actions/snapshots/CreateSnapshotForm.tsx b/src/pages/instances/actions/snapshots/CreateSnapshotForm.tsx
deleted file mode 100644
index 8417e8e282..0000000000
--- a/src/pages/instances/actions/snapshots/CreateSnapshotForm.tsx
+++ /dev/null
@@ -1,87 +0,0 @@
-import React, { FC, ReactNode, useState } from "react";
-import { useFormik } from "formik";
-import { UNDEFINED_DATE, stringToIsoTime } from "util/helpers";
-import { createSnapshot } from "api/snapshots";
-import { queryKeys } from "util/queryKeys";
-import { useQueryClient } from "@tanstack/react-query";
-import { LxdInstance } from "types/instance";
-import ItemName from "components/ItemName";
-import {
- SnapshotFormValues,
- getExpiresAt,
- getSnapshotSchema,
- isInstanceStateful,
-} from "util/snapshots";
-import SnapshotForm from "./SnapshotForm";
-import { useNotify } from "@canonical/react-components";
-import { useEventQueue } from "context/eventQueue";
-
-interface Props {
- instance: LxdInstance;
- close: () => void;
- onSuccess: (message: ReactNode) => void;
-}
-
-const CreateSnapshotForm: FC = ({ instance, close, onSuccess }) => {
- const eventQueue = useEventQueue();
- const notify = useNotify();
- const queryClient = useQueryClient();
- const controllerState = useState(null);
-
- const formik = useFormik({
- initialValues: {
- name: "",
- stateful: false,
- expirationDate: null,
- expirationTime: null,
- },
- validateOnMount: true,
- validationSchema: getSnapshotSchema(instance, controllerState),
- onSubmit: (values) => {
- notify.clear();
- const expiresAt =
- values.expirationDate && values.expirationTime
- ? stringToIsoTime(
- getExpiresAt(values.expirationDate, values.expirationTime),
- )
- : UNDEFINED_DATE;
- void createSnapshot(
- instance,
- values.name,
- expiresAt,
- values.stateful,
- ).then((operation) =>
- eventQueue.set(
- operation.metadata.id,
- () => {
- void queryClient.invalidateQueries({
- predicate: (query) => query.queryKey[0] === queryKeys.instances,
- });
- onSuccess(
- <>
- Snapshot created.
- >,
- );
- close();
- },
- (msg) => {
- notify.failure("Snapshot creation failed", new Error(msg));
- formik.setSubmitting(false);
- },
- ),
- );
- },
- });
-
- return (
-
- );
-};
-
-export default CreateSnapshotForm;
diff --git a/src/pages/instances/actions/snapshots/EditSnapshot.tsx b/src/pages/instances/actions/snapshots/EditSnapshot.tsx
deleted file mode 100644
index 4b3769af70..0000000000
--- a/src/pages/instances/actions/snapshots/EditSnapshot.tsx
+++ /dev/null
@@ -1,136 +0,0 @@
-import React, { FC, ReactNode, useState } from "react";
-import { useFormik } from "formik";
-import {
- UNDEFINED_DATE,
- getBrowserFormatDate,
- stringToIsoTime,
-} from "util/helpers";
-import { renameSnapshot, updateSnapshot } from "api/snapshots";
-import { queryKeys } from "util/queryKeys";
-import { useQueryClient } from "@tanstack/react-query";
-import { LxdInstance, LxdSnapshot } from "types/instance";
-import ItemName from "components/ItemName";
-import {
- SnapshotFormValues,
- getExpiresAt,
- getSnapshotSchema,
- isInstanceStateful,
-} from "util/snapshots";
-import SnapshotForm from "./SnapshotForm";
-import { useNotify } from "@canonical/react-components";
-import { useEventQueue } from "context/eventQueue";
-
-interface Props {
- instance: LxdInstance;
- snapshot: LxdSnapshot;
- close: () => void;
- onSuccess: (message: ReactNode) => void;
-}
-
-const EditSnapshot: FC = ({ instance, snapshot, close, onSuccess }) => {
- const eventQueue = useEventQueue();
- const notify = useNotify();
- const queryClient = useQueryClient();
- const controllerState = useState(null);
-
- const notifyUpdateSuccess = (name: string) => {
- void queryClient.invalidateQueries({
- predicate: (query) => query.queryKey[0] === queryKeys.instances,
- });
- onSuccess(
- <>
- Snapshot saved.
- >,
- );
- close();
- };
-
- const update = (expiresAt: string, newName?: string) => {
- const targetSnapshot = newName
- ? ({
- name: newName,
- } as LxdSnapshot)
- : snapshot;
- void updateSnapshot(instance, targetSnapshot, expiresAt).then((operation) =>
- eventQueue.set(
- operation.metadata.id,
- () => notifyUpdateSuccess(newName ?? snapshot.name),
- (msg) => {
- notify.failure("Snapshot update failed", new Error(msg));
- formik.setSubmitting(false);
- },
- ),
- );
- };
-
- const rename = (newName: string, expiresAt?: string) => {
- void renameSnapshot(instance, snapshot, newName).then((operation) =>
- eventQueue.set(
- operation.metadata.id,
- () => {
- if (expiresAt) {
- update(expiresAt, newName);
- } else {
- notifyUpdateSuccess(newName);
- }
- },
- (msg) => {
- notify.failure("Snapshot rename failed", new Error(msg));
- formik.setSubmitting(false);
- },
- ),
- );
- };
-
- const [expiryDate, expiryTime] =
- snapshot.expires_at === UNDEFINED_DATE
- ? [null, null]
- : getBrowserFormatDate(new Date(snapshot.expires_at))
- .slice(0, 16)
- .split(" ");
-
- const formik = useFormik({
- initialValues: {
- name: snapshot.name,
- stateful: snapshot.stateful,
- expirationDate: expiryDate,
- expirationTime: expiryTime,
- },
- validateOnMount: true,
- validationSchema: getSnapshotSchema(
- instance,
- controllerState,
- snapshot.name,
- ),
- onSubmit: (values) => {
- notify.clear();
- const newName = values.name;
- const expiresAt =
- values.expirationDate && values.expirationTime
- ? stringToIsoTime(
- getExpiresAt(values.expirationDate, values.expirationTime),
- )
- : UNDEFINED_DATE;
- const shouldRename = newName !== snapshot.name;
- const shouldUpdate = expiresAt !== snapshot.expires_at;
- if (shouldRename && shouldUpdate) {
- rename(newName, expiresAt);
- } else if (shouldRename) {
- rename(newName);
- } else {
- update(expiresAt);
- }
- },
- });
-
- return (
-
- );
-};
-
-export default EditSnapshot;
diff --git a/src/pages/instances/actions/snapshots/InstanceAddSnapshotBtn.tsx b/src/pages/instances/actions/snapshots/InstanceAddSnapshotBtn.tsx
new file mode 100644
index 0000000000..1185f7d27b
--- /dev/null
+++ b/src/pages/instances/actions/snapshots/InstanceAddSnapshotBtn.tsx
@@ -0,0 +1,42 @@
+import React, { FC, ReactNode } from "react";
+import usePortal from "react-useportal";
+import { Button } from "@canonical/react-components";
+import { LxdInstance } from "types/instance";
+import CreateInstanceSnapshotForm from "pages/instances/forms/CreateInstanceSnapshotForm";
+
+interface Props {
+ instance: LxdInstance;
+ onSuccess: (message: ReactNode) => void;
+ onFailure: (title: string, e: unknown, message?: ReactNode) => void;
+ className?: string;
+ isDisabled?: boolean;
+}
+
+const InstanceAddSnapshotBtn: FC = (props) => {
+ const { instance, onSuccess, isDisabled, className } = props;
+ const { openPortal, closePortal, isOpen, Portal } = usePortal();
+
+ return (
+ <>
+ {isOpen && (
+
+
+
+ )}
+
+ >
+ );
+};
+
+export default InstanceAddSnapshotBtn;
diff --git a/src/pages/instances/actions/snapshots/ConfigureSnapshotModal.tsx b/src/pages/instances/actions/snapshots/InstanceConfigureSnapshotModal.tsx
similarity index 93%
rename from src/pages/instances/actions/snapshots/ConfigureSnapshotModal.tsx
rename to src/pages/instances/actions/snapshots/InstanceConfigureSnapshotModal.tsx
index dfe569f9aa..dca5f84006 100644
--- a/src/pages/instances/actions/snapshots/ConfigureSnapshotModal.tsx
+++ b/src/pages/instances/actions/snapshots/InstanceConfigureSnapshotModal.tsx
@@ -6,7 +6,7 @@ import { updateInstance } from "api/instances";
import { queryKeys } from "util/queryKeys";
import { EditInstanceFormValues } from "pages/instances/EditInstance";
import { useQueryClient } from "@tanstack/react-query";
-import SnapshotsForm from "components/forms/SnapshotsForm";
+import InstanceSnapshotsForm from "components/forms/InstanceSnapshotsForm";
import { useParams } from "react-router-dom";
import {
getInstanceEditValues,
@@ -23,7 +23,7 @@ interface Props {
onFailure: (title: string, e: unknown) => void;
}
-const ConfigureSnapshotModal: FC = ({
+const InstanceConfigureSnapshotModal: FC = ({
instance,
close,
onSuccess,
@@ -108,9 +108,9 @@ const ConfigureSnapshotModal: FC = ({
}
onKeyDown={handleEscKey}
>
-
+
);
};
-export default ConfigureSnapshotModal;
+export default InstanceConfigureSnapshotModal;
diff --git a/src/pages/instances/actions/snapshots/InstanceConfigureSnapshotsBtn.tsx b/src/pages/instances/actions/snapshots/InstanceConfigureSnapshotsBtn.tsx
new file mode 100644
index 0000000000..0b066ddc43
--- /dev/null
+++ b/src/pages/instances/actions/snapshots/InstanceConfigureSnapshotsBtn.tsx
@@ -0,0 +1,40 @@
+import React, { FC, ReactNode } from "react";
+import usePortal from "react-useportal";
+import InstanceConfigureSnapshotModal from "./InstanceConfigureSnapshotModal";
+import { Button } from "@canonical/react-components";
+import { LxdInstance } from "types/instance";
+
+interface Props {
+ instance: LxdInstance;
+ onSuccess: (message: ReactNode) => void;
+ onFailure: (title: string, e: unknown, message?: ReactNode) => void;
+ isDisabled?: boolean;
+ className?: string;
+}
+
+const InstanceConfigureSnapshotsBtn: FC = (props) => {
+ const { instance, onSuccess, onFailure, isDisabled, className } = props;
+ const { openPortal, closePortal, isOpen, Portal } = usePortal();
+
+ return (
+ <>
+ {isOpen && (
+
+
+
+
+
+ )}
+
+ >
+ );
+};
+
+export default InstanceConfigureSnapshotsBtn;
diff --git a/src/pages/instances/actions/snapshots/InstanceEditSnapshotBtn.tsx b/src/pages/instances/actions/snapshots/InstanceEditSnapshotBtn.tsx
new file mode 100644
index 0000000000..1a76eeefad
--- /dev/null
+++ b/src/pages/instances/actions/snapshots/InstanceEditSnapshotBtn.tsx
@@ -0,0 +1,47 @@
+import React, { FC, ReactNode } from "react";
+import usePortal from "react-useportal";
+import { Button, Icon } from "@canonical/react-components";
+import { LxdInstance, LxdInstanceSnapshot } from "types/instance";
+import EditInstanceSnapshotForm from "pages/instances/forms/EditInstanceSnapshotForm";
+
+interface Props {
+ instance: LxdInstance;
+ snapshot: LxdInstanceSnapshot;
+ onSuccess: (message: ReactNode) => void;
+ isDeleting: boolean;
+ isRestoring: boolean;
+}
+
+const InstanceEditSnapshotBtn: FC = (props) => {
+ const { instance, snapshot, onSuccess, isDeleting, isRestoring } = props;
+ const { openPortal, closePortal, isOpen, Portal } = usePortal();
+
+ return (
+ <>
+ {isOpen && (
+
+
+
+ )}
+
+ >
+ );
+};
+
+export default InstanceEditSnapshotBtn;
diff --git a/src/pages/instances/actions/snapshots/SnapshotActions.tsx b/src/pages/instances/actions/snapshots/InstanceSnapshotActions.tsx
similarity index 69%
rename from src/pages/instances/actions/snapshots/SnapshotActions.tsx
rename to src/pages/instances/actions/snapshots/InstanceSnapshotActions.tsx
index a308f0bdb2..b88f925103 100644
--- a/src/pages/instances/actions/snapshots/SnapshotActions.tsx
+++ b/src/pages/instances/actions/snapshots/InstanceSnapshotActions.tsx
@@ -1,35 +1,32 @@
import React, { FC, ReactNode, useState } from "react";
-import { LxdInstance, LxdSnapshot } from "types/instance";
-import { deleteSnapshot, restoreSnapshot } from "api/snapshots";
+import { LxdInstance, LxdInstanceSnapshot } from "types/instance";
+import {
+ deleteInstanceSnapshot,
+ restoreInstanceSnapshot,
+} from "api/instance-snapshots";
import { useQueryClient } from "@tanstack/react-query";
import { queryKeys } from "util/queryKeys";
-import {
- Button,
- ConfirmationButton,
- Icon,
- List,
-} from "@canonical/react-components";
+import { ConfirmationButton, Icon, List } from "@canonical/react-components";
import classnames from "classnames";
import ItemName from "components/ItemName";
import ConfirmationForce from "components/ConfirmationForce";
-import EditSnapshot from "./EditSnapshot";
import { useEventQueue } from "context/eventQueue";
+import InstanceEditSnapshotBtn from "./InstanceEditSnapshotBtn";
interface Props {
instance: LxdInstance;
- snapshot: LxdSnapshot;
+ snapshot: LxdInstanceSnapshot;
onSuccess: (message: ReactNode) => void;
onFailure: (title: string, e: unknown) => void;
}
-const SnapshotActions: FC = ({
+const InstanceSnapshotActions: FC = ({
instance,
snapshot,
onSuccess,
onFailure,
}) => {
const eventQueue = useEventQueue();
- const [isModalOpen, setModalOpen] = useState(false);
const [isDeleting, setDeleting] = useState(false);
const [isRestoring, setRestoring] = useState(false);
const [restoreState, setRestoreState] = useState(true);
@@ -37,7 +34,7 @@ const SnapshotActions: FC = ({
const handleDelete = () => {
setDeleting(true);
- void deleteSnapshot(instance, snapshot).then((operation) =>
+ void deleteInstanceSnapshot(instance, snapshot).then((operation) =>
eventQueue.set(
operation.metadata.id,
() =>
@@ -59,55 +56,43 @@ const SnapshotActions: FC = ({
const handleRestore = () => {
setRestoring(true);
- void restoreSnapshot(instance, snapshot, restoreState).then((operation) =>
- eventQueue.set(
- operation.metadata.id,
- () =>
- onSuccess(
- <>
- Snapshot restored.
- >,
- ),
- (msg) => onFailure("Snapshot restore failed", new Error(msg)),
- () => {
- setRestoring(false);
- void queryClient.invalidateQueries({
- predicate: (query) => query.queryKey[0] === queryKeys.instances,
- });
- },
- ),
+ void restoreInstanceSnapshot(instance, snapshot, restoreState).then(
+ (operation) =>
+ eventQueue.set(
+ operation.metadata.id,
+ () =>
+ onSuccess(
+ <>
+ Snapshot restored.
+ >,
+ ),
+ (msg) => onFailure("Snapshot restore failed", new Error(msg)),
+ () => {
+ setRestoring(false);
+ void queryClient.invalidateQueries({
+ predicate: (query) => query.queryKey[0] === queryKeys.instances,
+ });
+ },
+ ),
);
};
return (
<>
- {isModalOpen && (
- setModalOpen(false)}
- onSuccess={onSuccess}
- />
- )}
setModalOpen(true)}
- type="button"
- aria-label="Edit snapshot"
- title="Edit"
- >
-
- ,
+ instance={instance}
+ snapshot={snapshot}
+ onSuccess={onSuccess}
+ isDeleting={isDeleting}
+ isRestoring={isRestoring}
+ />,
= ({
);
};
-export default SnapshotActions;
+export default InstanceSnapshotActions;
diff --git a/src/pages/instances/actions/snapshots/SnapshotBulkDelete.tsx b/src/pages/instances/actions/snapshots/InstanceSnapshotBulkDelete.tsx
similarity index 92%
rename from src/pages/instances/actions/snapshots/SnapshotBulkDelete.tsx
rename to src/pages/instances/actions/snapshots/InstanceSnapshotBulkDelete.tsx
index 8b04f26e2a..b7a62910ea 100644
--- a/src/pages/instances/actions/snapshots/SnapshotBulkDelete.tsx
+++ b/src/pages/instances/actions/snapshots/InstanceSnapshotBulkDelete.tsx
@@ -1,6 +1,6 @@
import React, { FC, ReactNode, useState } from "react";
import { LxdInstance } from "types/instance";
-import { deleteSnapshotBulk } from "api/snapshots";
+import { deleteInstanceSnapshotBulk } from "api/instance-snapshots";
import { useQueryClient } from "@tanstack/react-query";
import { queryKeys } from "util/queryKeys";
import { pluralizeSnapshot } from "util/instanceBulkActions";
@@ -18,7 +18,7 @@ interface Props {
onFailure: (title: string, e: unknown, message?: ReactNode) => void;
}
-const SnapshotBulkDelete: FC = ({
+const InstanceSnapshotBulkDelete: FC = ({
instance,
snapshotNames,
onStart,
@@ -35,7 +35,7 @@ const SnapshotBulkDelete: FC = ({
const handleDelete = () => {
setLoading(true);
onStart();
- void deleteSnapshotBulk(instance, snapshotNames, eventQueue).then(
+ void deleteInstanceSnapshotBulk(instance, snapshotNames, eventQueue).then(
(results) => {
const { fulfilledCount, rejectedCount } =
getPromiseSettledCounts(results);
@@ -104,4 +104,4 @@ const SnapshotBulkDelete: FC = ({
);
};
-export default SnapshotBulkDelete;
+export default InstanceSnapshotBulkDelete;
diff --git a/src/pages/instances/actions/snapshots/useInstanceSnapshot.tsx b/src/pages/instances/actions/snapshots/useInstanceSnapshot.tsx
new file mode 100644
index 0000000000..dcdc48e2a7
--- /dev/null
+++ b/src/pages/instances/actions/snapshots/useInstanceSnapshot.tsx
@@ -0,0 +1,261 @@
+import {
+ Icon,
+ Input,
+ List,
+ Tooltip,
+ useNotify,
+} from "@canonical/react-components";
+import { useQueryClient } from "@tanstack/react-query";
+import {
+ createInstanceSnapshot,
+ renameInstanceSnapshot,
+ updateInstanceSnapshot,
+} from "api/instance-snapshots";
+import ItemName from "components/ItemName";
+import { useEventQueue } from "context/eventQueue";
+import { useFormik } from "formik";
+import React, { ReactNode, useState } from "react";
+import { LxdInstance, LxdInstanceSnapshot } from "types/instance";
+import {
+ UNDEFINED_DATE,
+ getBrowserFormatDate,
+ stringToIsoTime,
+} from "util/helpers";
+import { queryKeys } from "util/queryKeys";
+import {
+ SnapshotFormValues,
+ getExpiresAt,
+ getInstanceSnapshotSchema,
+} from "util/snapshots";
+import { TOOLTIP_OVER_MODAL_ZINDEX } from "util/zIndex";
+
+interface CreateInstanceSnapshotHook {
+ instance: LxdInstance;
+ close: () => void;
+ onSuccess: (message: ReactNode) => void;
+ isStateful: boolean;
+ isRunning: boolean;
+}
+
+interface EditInstanceSnapshotHook {
+ instance: LxdInstance;
+ snapshot: LxdInstanceSnapshot;
+ close: () => void;
+ onSuccess: (message: ReactNode) => void;
+}
+
+const useCreateInstanceSnapshot = (props: CreateInstanceSnapshotHook) => {
+ const { instance, close, onSuccess, isStateful, isRunning } = props;
+ const eventQueue = useEventQueue();
+ const notify = useNotify();
+ const queryClient = useQueryClient();
+ const controllerState = useState(null);
+
+ const formik = useFormik>({
+ initialValues: {
+ name: "",
+ stateful: false,
+ expirationDate: null,
+ expirationTime: null,
+ },
+ validateOnMount: true,
+ validationSchema: getInstanceSnapshotSchema(instance, controllerState),
+ onSubmit: (values, { resetForm }) => {
+ notify.clear();
+ const expiresAt =
+ values.expirationDate && values.expirationTime
+ ? stringToIsoTime(
+ getExpiresAt(values.expirationDate, values.expirationTime),
+ )
+ : UNDEFINED_DATE;
+ void createInstanceSnapshot(
+ instance,
+ values.name,
+ expiresAt,
+ values.stateful || false,
+ )
+ .then((operation) =>
+ eventQueue.set(
+ operation.metadata.id,
+ () => {
+ void queryClient.invalidateQueries({
+ predicate: (query) => query.queryKey[0] === queryKeys.instances,
+ });
+ onSuccess(
+ <>
+ Snapshot created.
+ >,
+ );
+ resetForm();
+ close();
+ },
+ (msg) => {
+ notify.failure("Snapshot creation failed", new Error(msg));
+ formik.setSubmitting(false);
+ close();
+ },
+ ),
+ )
+ .catch((error: Error) => {
+ notify.failure("Snapshot creation failed", error);
+ formik.setSubmitting(false);
+ close();
+ });
+ },
+ });
+
+ let statefulInfoMessage: JSX.Element | string = (
+ <>
+ {`To create a stateful snapshot, the instance needs\n`}
+ the migration.stateful
config set to true
+ >
+ );
+ if (isStateful) {
+ statefulInfoMessage = `To create a stateful snapshot,\nthe instance must be running`;
+ }
+ if (isStateful && isRunning) {
+ statefulInfoMessage = "";
+ }
+
+ const statefulCheckbox = (
+
,
+ ...(statefulInfoMessage
+ ? [
+
+
+ ,
+ ]
+ : []),
+ ]}
+ />
+ );
+
+ return {
+ formik,
+ statefulCheckbox,
+ };
+};
+
+const useEditInstanceSnapshot = (props: EditInstanceSnapshotHook) => {
+ const { instance, snapshot, close, onSuccess } = props;
+ const eventQueue = useEventQueue();
+ const notify = useNotify();
+ const queryClient = useQueryClient();
+ const controllerState = useState(null);
+
+ const notifyUpdateSuccess = (name: string) => {
+ void queryClient.invalidateQueries({
+ predicate: (query) => query.queryKey[0] === queryKeys.instances,
+ });
+ onSuccess(
+ <>
+ Snapshot saved.
+ >,
+ );
+ close();
+ };
+
+ const update = (expiresAt: string, newName?: string) => {
+ const targetSnapshot = newName
+ ? ({
+ name: newName,
+ } as LxdInstanceSnapshot)
+ : snapshot;
+ void updateInstanceSnapshot(instance, targetSnapshot, expiresAt).then(
+ (operation) =>
+ eventQueue.set(
+ operation.metadata.id,
+ () => notifyUpdateSuccess(newName ?? snapshot.name),
+ (msg) => {
+ notify.failure("Snapshot update failed", new Error(msg));
+ formik.setSubmitting(false);
+ },
+ ),
+ );
+ };
+
+ const rename = (newName: string, expiresAt?: string) => {
+ void renameInstanceSnapshot(instance, snapshot, newName).then((operation) =>
+ eventQueue.set(
+ operation.metadata.id,
+ () => {
+ if (expiresAt) {
+ update(expiresAt, newName);
+ } else {
+ notifyUpdateSuccess(newName);
+ }
+ },
+ (msg) => {
+ notify.failure("Snapshot rename failed", new Error(msg));
+ formik.setSubmitting(false);
+ },
+ ),
+ );
+ };
+
+ const [expiryDate, expiryTime] =
+ snapshot.expires_at === UNDEFINED_DATE
+ ? [null, null]
+ : getBrowserFormatDate(new Date(snapshot.expires_at))
+ .slice(0, 16)
+ .split(" ");
+
+ const formik = useFormik>({
+ initialValues: {
+ name: snapshot.name,
+ stateful: snapshot.stateful,
+ expirationDate: expiryDate,
+ expirationTime: expiryTime,
+ },
+ validateOnMount: true,
+ validationSchema: getInstanceSnapshotSchema(
+ instance,
+ controllerState,
+ snapshot.name,
+ ),
+ onSubmit: (values) => {
+ notify.clear();
+ const newName = values.name;
+ const expiresAt =
+ values.expirationDate && values.expirationTime
+ ? stringToIsoTime(
+ getExpiresAt(values.expirationDate, values.expirationTime),
+ )
+ : UNDEFINED_DATE;
+ const shouldRename = newName !== snapshot.name;
+ const shouldUpdate = expiresAt !== snapshot.expires_at;
+ if (shouldRename && shouldUpdate) {
+ rename(newName, expiresAt);
+ } else if (shouldRename) {
+ rename(newName);
+ } else {
+ update(expiresAt);
+ }
+ },
+ });
+
+ return {
+ formik,
+ };
+};
+
+export { useCreateInstanceSnapshot, useEditInstanceSnapshot };
diff --git a/src/pages/instances/forms/CreateInstanceSnapshotForm.tsx b/src/pages/instances/forms/CreateInstanceSnapshotForm.tsx
new file mode 100644
index 0000000000..4601ac548f
--- /dev/null
+++ b/src/pages/instances/forms/CreateInstanceSnapshotForm.tsx
@@ -0,0 +1,33 @@
+import React, { FC, ReactNode } from "react";
+import { LxdInstance } from "types/instance";
+import { useCreateInstanceSnapshot } from "../actions/snapshots/useInstanceSnapshot";
+import { isInstanceStateful } from "util/snapshots";
+import SnapshotForm from "components/forms/SnapshotForm";
+
+interface Props {
+ close: () => void;
+ instance: LxdInstance;
+ onSuccess: (message: ReactNode) => void;
+}
+
+const CreateInstanceSnapshotForm: FC = (props) => {
+ const { close, instance, onSuccess } = props;
+ const { formik, statefulCheckbox } = useCreateInstanceSnapshot({
+ instance,
+ close,
+ onSuccess,
+ isStateful: isInstanceStateful(instance),
+ isRunning: instance.status === "Running",
+ });
+
+ return (
+
+ );
+};
+
+export default CreateInstanceSnapshotForm;
diff --git a/src/pages/instances/forms/EditInstanceSnapshotForm.tsx b/src/pages/instances/forms/EditInstanceSnapshotForm.tsx
new file mode 100644
index 0000000000..9efabfd33f
--- /dev/null
+++ b/src/pages/instances/forms/EditInstanceSnapshotForm.tsx
@@ -0,0 +1,25 @@
+import React, { FC, ReactNode } from "react";
+import { LxdInstance, LxdInstanceSnapshot } from "types/instance";
+import { useEditInstanceSnapshot } from "../actions/snapshots/useInstanceSnapshot";
+import SnapshotForm from "components/forms/SnapshotForm";
+
+interface Props {
+ instance: LxdInstance;
+ snapshot: LxdInstanceSnapshot;
+ close: () => void;
+ onSuccess: (message: ReactNode) => void;
+}
+
+const EditInstanceSnapshotForm: FC = (props) => {
+ const { instance, snapshot, close, onSuccess } = props;
+ const { formik } = useEditInstanceSnapshot({
+ instance,
+ snapshot,
+ close,
+ onSuccess,
+ });
+
+ return ;
+};
+
+export default EditInstanceSnapshotForm;
diff --git a/src/pages/profiles/CreateProfile.tsx b/src/pages/profiles/CreateProfile.tsx
index e53bf63b09..9125abe0cf 100644
--- a/src/pages/profiles/CreateProfile.tsx
+++ b/src/pages/profiles/CreateProfile.tsx
@@ -21,10 +21,10 @@ import SecurityPoliciesForm, {
SecurityPoliciesFormValues,
securityPoliciesPayload,
} from "components/forms/SecurityPoliciesForm";
-import SnapshotsForm, {
+import InstanceSnapshotsForm, {
SnapshotFormValues,
snapshotsPayload,
-} from "components/forms/SnapshotsForm";
+} from "components/forms/InstanceSnapshotsForm";
import CloudInitForm, {
CloudInitFormValues,
cloudInitPayload,
@@ -190,7 +190,7 @@ const CreateProfile: FC = () => {
)}
- {section === SNAPSHOTS && }
+ {section === SNAPSHOTS && }
{section === CLOUD_INIT && }
diff --git a/src/pages/profiles/EditProfile.tsx b/src/pages/profiles/EditProfile.tsx
index acd1f5f3fd..d17460e969 100644
--- a/src/pages/profiles/EditProfile.tsx
+++ b/src/pages/profiles/EditProfile.tsx
@@ -20,10 +20,10 @@ import SecurityPoliciesForm, {
SecurityPoliciesFormValues,
securityPoliciesPayload,
} from "components/forms/SecurityPoliciesForm";
-import SnapshotsForm, {
+import InstanceSnapshotsForm, {
SnapshotFormValues,
snapshotsPayload,
-} from "components/forms/SnapshotsForm";
+} from "components/forms/InstanceSnapshotsForm";
import CloudInitForm, {
CloudInitFormValues,
cloudInitPayload,
@@ -213,7 +213,7 @@ const EditProfile: FC = ({ profile, featuresProfiles }) => {
)}
{activeSection === slugify(SNAPSHOTS) && (
-
+
)}
{activeSection === slugify(CLOUD_INIT) && (
diff --git a/src/pages/storage/StorageVolumeDetail.tsx b/src/pages/storage/StorageVolumeDetail.tsx
index 61753d9062..ada5f4e737 100644
--- a/src/pages/storage/StorageVolumeDetail.tsx
+++ b/src/pages/storage/StorageVolumeDetail.tsx
@@ -4,14 +4,16 @@ import { useQuery } from "@tanstack/react-query";
import { queryKeys } from "util/queryKeys";
import { Row, useNotify } from "@canonical/react-components";
import Loader from "components/Loader";
-import { fetchStorageVolume } from "api/storage-pools";
import NotificationRow from "components/NotificationRow";
import StorageVolumeHeader from "pages/storage/StorageVolumeHeader";
import StorageVolumeOverview from "pages/storage/StorageVolumeOverview";
import StorageVolumeEdit from "pages/storage/forms/StorageVolumeEdit";
import TabLinks from "components/TabLinks";
+import CustomLayout from "components/CustomLayout";
+import { loadCustomVolumeAndSnapshots } from "context/loadCustomVolumeAndSnapshots";
+import StorageVolumeSnapshots from "./StorageVolumeSnapshots";
-const tabs: string[] = ["Overview", "Configuration"];
+const tabs: string[] = ["Overview", "Configuration", "Snapshots"];
const StorageVolumeDetail: FC = () => {
const notify = useNotify();
@@ -48,7 +50,9 @@ const StorageVolumeDetail: FC = () => {
isLoading,
} = useQuery({
queryKey: [queryKeys.storage, pool, project, type, volumeName],
- queryFn: () => fetchStorageVolume(pool, project, type, volumeName),
+ queryFn: () =>
+ loadCustomVolumeAndSnapshots({ pool, project, type, volumeName }),
+ refetchOnMount: (query) => query.state.isInvalidated,
});
if (error) {
@@ -62,33 +66,37 @@ const StorageVolumeDetail: FC = () => {
}
return (
-
-
-
-
-
-
-
+ }
+ contentClassName="sotrage-volume-form"
+ >
+ {activeTab !== "snapshots" && }
+
+
- {!activeTab && (
-
-
-
- )}
+ {!activeTab && (
+
+
+
+ )}
- {activeTab === "configuration" && (
-
-
-
- )}
-
-
-
-
+ {activeTab === "configuration" && (
+
+
+
+ )}
+
+ {activeTab === "snapshots" && (
+
+
+
+ )}
+
+
);
};
diff --git a/src/pages/storage/StorageVolumeNameLink.tsx b/src/pages/storage/StorageVolumeNameLink.tsx
new file mode 100644
index 0000000000..8d531a0d44
--- /dev/null
+++ b/src/pages/storage/StorageVolumeNameLink.tsx
@@ -0,0 +1,33 @@
+import { ICONS, Icon } from "@canonical/react-components";
+import React, { FC } from "react";
+import { Link } from "react-router-dom";
+import { LxdStorageVolume } from "types/storage";
+import classnames from "classnames";
+import { generateLinkForVolumeDetail } from "util/storageVolume";
+
+interface Props {
+ volume: LxdStorageVolume;
+ project: string;
+ isExternalLink?: boolean;
+ overrideName?: string;
+ className?: string;
+}
+
+const StorageVolumeNameLink: FC = (props) => {
+ const { volume, project, isExternalLink, overrideName, className } = props;
+ return (
+
+
+
+ {overrideName ? overrideName : volume.name}
+
+
+ {isExternalLink &&
}
+
+ );
+};
+
+export default StorageVolumeNameLink;
diff --git a/src/pages/storage/StorageVolumeSnapshots.tsx b/src/pages/storage/StorageVolumeSnapshots.tsx
new file mode 100644
index 0000000000..8487ae3980
--- /dev/null
+++ b/src/pages/storage/StorageVolumeSnapshots.tsx
@@ -0,0 +1,310 @@
+import React, { ReactNode, useEffect, useState } from "react";
+import {
+ EmptyState,
+ Icon,
+ SearchBox,
+ useNotify,
+} from "@canonical/react-components";
+import { isoTimeToString } from "util/helpers";
+import VolumeSnapshotActions from "./actions/snapshots/VolumeSnapshotActions";
+import useEventListener from "@use-it/event-listener";
+import Pagination from "components/Pagination";
+import { usePagination } from "util/pagination";
+import ItemName from "components/ItemName";
+import SelectableMainTable from "components/SelectableMainTable";
+import Loader from "components/Loader";
+import { useProject } from "context/project";
+import ScrollableTable from "components/ScrollableTable";
+import SelectedTableNotification from "components/SelectedTableNotification";
+import { useDocs } from "context/useDocs";
+import { LxdStorageVolume, LxdVolumeSnapshot } from "types/storage";
+import NotificationRow from "components/NotificationRow";
+import VolumeSnapshotBulkDelete from "./actions/snapshots/VolumeSnapshotBulkDelete";
+import VolumeAddSnapshotBtn from "./actions/snapshots/VolumeAddSnapshotBtn";
+import VolumeConfigureSnapshotBtn from "./actions/snapshots/VolumeConfigureSnapshotBtn";
+
+const collapsedViewMaxWidth = 1250;
+export const figureCollapsedScreen = (): boolean =>
+ window.innerWidth <= collapsedViewMaxWidth;
+
+interface Props {
+ volume: LxdStorageVolume & { snapshots: LxdVolumeSnapshot[] };
+}
+
+const StorageVolumeSnapshots = (props: Props) => {
+ const { volume } = props;
+ const docBaseLink = useDocs();
+ const [query, setQuery] = useState("");
+ const [selectedNames, setSelectedNames] = useState([]);
+ const [processingNames, setProcessingNames] = useState([]);
+ const [isSmallScreen, setSmallScreen] = useState(figureCollapsedScreen());
+ const notify = useNotify();
+
+ const onSuccess = (message: string) => {
+ notify.queue(notify.success(message));
+ };
+
+ const onFailure = (title: string, error: unknown, message?: ReactNode) => {
+ notify.failure(title, error, message);
+ };
+
+ const { project, isLoading } = useProject();
+ const snapshotsDisabled =
+ project?.config["restricted.snapshots"] === "block" ||
+ project?.config["restricted"] === "true"; // Saw this on the demo server restricting snapshot creation
+
+ useEffect(() => {
+ const validNames = new Set(
+ volume.snapshots?.map((snapshot) => snapshot.name),
+ );
+ const validSelections = selectedNames.filter((name) =>
+ validNames.has(name),
+ );
+ if (validSelections.length !== selectedNames.length) {
+ setSelectedNames(validSelections);
+ }
+ }, [volume.snapshots, selectedNames]);
+
+ const filteredSnapshots =
+ volume.snapshots?.filter((item) => {
+ if (query) {
+ if (!item.name.toLowerCase().includes(query.toLowerCase())) {
+ return false;
+ }
+ }
+ return true;
+ }) ?? [];
+
+ const hasSnapshots = volume.snapshots && volume.snapshots.length > 0;
+
+ const headers = [
+ {
+ content: isSmallScreen ? (
+ <>
+ Name
+
+ Date created
+ >
+ ) : (
+ "Name"
+ ),
+ sortKey: isSmallScreen ? "created_at" : "name",
+ className: "name",
+ },
+ ...(isSmallScreen
+ ? []
+ : [
+ {
+ content: "Date created",
+ sortKey: "created_at",
+ className: "created",
+ },
+ ]),
+ {
+ content: "Expiry date",
+ sortKey: "expires_at",
+ className: "expiration",
+ },
+ { "aria-label": "Actions", className: "actions" },
+ ];
+
+ const rows = filteredSnapshots.map((snapshot) => {
+ const actions = (
+
+ );
+
+ return {
+ className: "u-row",
+ name: snapshot.name,
+ columns: [
+ {
+ content: (
+ <>
+
+
+
+ {isSmallScreen && (
+
+ {isoTimeToString(snapshot.created_at)}
+
+ )}
+ >
+ ),
+ role: "rowheader",
+ "aria-label": "Name",
+ className: "name",
+ },
+ ...(isSmallScreen
+ ? []
+ : [
+ {
+ content: isoTimeToString(snapshot.created_at),
+ role: "rowheader",
+ "aria-label": "Created at",
+ className: "created",
+ },
+ ]),
+ {
+ content: isoTimeToString(snapshot.expires_at),
+ role: "rowheader",
+ "aria-label": "Expires at",
+ className: "expiration",
+ },
+ {
+ content: actions,
+ role: "rowheader",
+ "aria-label": "Actions",
+ className: "u-align--right actions",
+ },
+ ],
+ sortData: {
+ name: snapshot.name.toLowerCase(),
+ created_at: snapshot.created_at,
+ expires_at: snapshot.expires_at,
+ },
+ };
+ });
+
+ const pagination = usePagination(rows, "created_at", "descending");
+
+ const resize = () => {
+ setSmallScreen(figureCollapsedScreen());
+ };
+ useEventListener("resize", resize);
+
+ return isLoading ? (
+
+ ) : (
+
+ {hasSnapshots && (
+
+ {selectedNames.length === 0 ? (
+ <>
+
+ {
+ setQuery(value);
+ }}
+ placeholder="Search for snapshots"
+ value={query}
+ aria-label="Search for snapshots"
+ />
+
+
+
+ >
+ ) : (
+
+ setProcessingNames(selectedNames)}
+ onFinish={() => setProcessingNames([])}
+ onSuccess={onSuccess}
+ onFailure={onFailure}
+ />
+
+ )}
+
+ )}
+
+ {hasSnapshots ? (
+ <>
+
+ );
+};
+
+export default StorageVolumeSnapshots;
diff --git a/src/pages/storage/StorageVolumes.tsx b/src/pages/storage/StorageVolumes.tsx
index aefb66e4e5..f32fcdc456 100644
--- a/src/pages/storage/StorageVolumes.tsx
+++ b/src/pages/storage/StorageVolumes.tsx
@@ -10,7 +10,6 @@ import {
} from "@canonical/react-components";
import Loader from "components/Loader";
import { isoTimeToString } from "util/helpers";
-import DeleteStorageVolumeBtn from "pages/storage/actions/DeleteStorageVolumeBtn";
import { loadVolumes } from "context/loadIsoVolumes";
import ScrollableTable from "components/ScrollableTable";
import { usePagination } from "util/pagination";
@@ -27,8 +26,25 @@ import StorageVolumeSize from "pages/storage/StorageVolumeSize";
import { useDocs } from "context/useDocs";
import {
contentTypeForDisplay,
+ getSnapshotsPerVolume,
+ isSnapshot,
volumeTypeForDisplay,
} from "util/storageVolume";
+import {
+ ACTIONS_COL,
+ COLUMN_WIDTHS,
+ CONTENT_TYPE_COL,
+ CREATED_AT_COL,
+ NAME_COL,
+ POOL_COL,
+ SIZE_COL,
+ SNAPSHOTS_COL,
+ TYPE_COL,
+ USED_BY_COL,
+} from "util/storageVolumeTable";
+import StorageVolumeNameLink from "./StorageVolumeNameLink";
+import CustomStorageVolumeActions from "./actions/CustomStorageVolumeActions";
+import classnames from "classnames";
const StorageVolumes: FC = () => {
const docBaseLink = useDocs();
@@ -58,6 +74,7 @@ const StorageVolumes: FC = () => {
} = useQuery({
queryKey: [queryKeys.volumes, project],
queryFn: () => loadVolumes(project),
+ refetchOnMount: (query) => query.state.isInvalidated,
});
if (error) {
@@ -65,42 +82,70 @@ const StorageVolumes: FC = () => {
}
const headers = [
- { content: "Name", sortKey: "name" },
- { content: "Pool", sortKey: "pool" },
- { content: "Type", sortKey: "type" },
- { content: "Content type", sortKey: "contentType" },
- { content: "Created at", sortKey: "createdAt" },
- { content: "Size", className: "u-align--right" },
{
- content: "Used by",
+ content: NAME_COL,
+ sortKey: "name",
+ style: { width: COLUMN_WIDTHS[NAME_COL] },
+ },
+ {
+ content: POOL_COL,
+ sortKey: "pool",
+ style: { width: COLUMN_WIDTHS[POOL_COL] },
+ },
+ {
+ content: TYPE_COL,
+ sortKey: "type",
+ style: { width: COLUMN_WIDTHS[TYPE_COL] },
+ },
+ {
+ content: CONTENT_TYPE_COL,
+ sortKey: "contentType",
+ style: { width: COLUMN_WIDTHS[CONTENT_TYPE_COL] },
+ },
+ {
+ content: CREATED_AT_COL,
+ sortKey: "createdAt",
+ style: { width: COLUMN_WIDTHS[CREATED_AT_COL] },
+ },
+ {
+ content: SIZE_COL,
+ className: "u-align--right",
+ style: { width: COLUMN_WIDTHS[SIZE_COL] },
+ },
+ {
+ content: USED_BY_COL,
sortKey: "usedBy",
className: "u-align--right used_by",
+ style: { width: COLUMN_WIDTHS[USED_BY_COL] },
+ },
+ {
+ content: SNAPSHOTS_COL,
+ sortKey: "snapshots",
+ className: "u-align--right used_by",
+ style: { width: COLUMN_WIDTHS[SNAPSHOTS_COL] },
},
{
content: "",
- className: "actions",
+ className: "actions u-align--right",
"aria-label": "Actions",
+ style: { width: COLUMN_WIDTHS[ACTIONS_COL] },
},
];
const filteredVolumes = volumes.filter((item) => {
- if (!filters.queries.every((q) => item.name.toLowerCase().includes(q))) {
+ if (isSnapshot(item)) {
return false;
}
- if (filters.pools.length > 0 && !filters.pools.includes(item.pool)) {
+
+ if (!filters.queries.every((q) => item.name.toLowerCase().includes(q))) {
return false;
}
- if (
- filters.volumeTypes.length > 0 &&
- !filters.volumeTypes.includes(item.type) &&
- (!filters.volumeTypes.includes("snapshot") || !item.name.includes("/"))
- ) {
+ if (filters.pools.length > 0 && !filters.pools.includes(item.pool)) {
return false;
}
if (
filters.volumeTypes.length > 0 &&
- !filters.volumeTypes.includes("snapshot") &&
- item.name.includes("/")
+ !filters.volumeTypes.includes(item.type)
) {
return false;
}
@@ -113,27 +158,25 @@ const StorageVolumes: FC = () => {
return true;
});
+ const snapshotPerVolumeLookup = getSnapshotsPerVolume(volumes);
const rows = filteredVolumes.map((volume) => {
const volumeType = volumeTypeForDisplay(volume);
const contentType = contentTypeForDisplay(volume);
return {
+ className: "u-row",
columns: [
{
- // If the volume name contains a slash, it's a snapshot, and we don't want to link to it
- content: volume.name.includes("/") ? (
- volume.name
- ) : (
-
-
- {volume.name}
-
-
+ content: (
+
),
role: "cell",
- "aria-label": "Name",
+ style: { width: COLUMN_WIDTHS[NAME_COL] },
+ "aria-label": NAME_COL,
},
{
content: (
@@ -142,50 +185,77 @@ const StorageVolumes: FC = () => {
),
role: "cell",
- "aria-label": "Pool",
+ style: { width: COLUMN_WIDTHS[POOL_COL] },
+ "aria-label": POOL_COL,
},
{
content: volumeType,
role: "cell",
- "aria-label": "Type",
+ "aria-label": TYPE_COL,
+ style: { width: COLUMN_WIDTHS[TYPE_COL] },
},
{
content: contentType,
role: "cell",
- "aria-label": "Content type",
+ "aria-label": CONTENT_TYPE_COL,
+ style: { width: COLUMN_WIDTHS[CONTENT_TYPE_COL] },
},
{
content: isoTimeToString(volume.created_at),
role: "cell",
- "aria-label": "Created at",
+ "aria-label": CREATED_AT_COL,
+ style: { width: COLUMN_WIDTHS[CREATED_AT_COL] },
},
{
content: ,
role: "cell",
- "aria-label": "Size",
+ "aria-label": SIZE_COL,
className: "u-align--right",
+ style: { width: COLUMN_WIDTHS[SIZE_COL] },
},
{
className: "u-align--right used_by",
content: volume.used_by?.length ?? 0,
role: "cell",
- "aria-label": "Used by",
+ "aria-label": USED_BY_COL,
+ style: { width: COLUMN_WIDTHS[USED_BY_COL] },
+ },
+ {
+ className: "u-align--right",
+ content: snapshotPerVolumeLookup[volume.name]?.length ?? 0,
+ role: "cell",
+ "aria-label": SNAPSHOTS_COL,
+ style: { width: COLUMN_WIDTHS[SNAPSHOTS_COL] },
},
{
className: "actions u-align--right",
- content: (
- <>
- {
- notify.success(`Storage volume ${volume.name} deleted.`);
- }}
+ className={classnames(
+ "storage-volume-actions",
+ "u-no-margin--bottom",
+ )}
/>
- >
- ),
+ ) : (
+
+ ),
role: "cell",
- "aria-label": "Actions",
+ "aria-label": ACTIONS_COL,
+ style: { width: COLUMN_WIDTHS[ACTIONS_COL] },
},
],
sortData: {
@@ -195,6 +265,7 @@ const StorageVolumes: FC = () => {
type: volumeType,
createdAt: volume.created_at,
usedBy: volume.used_by?.length ?? 0,
+ snapshots: snapshotPerVolumeLookup[volume.name]?.length ?? 0,
},
};
});
diff --git a/src/pages/storage/StorageVolumesFilter.tsx b/src/pages/storage/StorageVolumesFilter.tsx
index c4d4b4b74a..c117808991 100644
--- a/src/pages/storage/StorageVolumesFilter.tsx
+++ b/src/pages/storage/StorageVolumesFilter.tsx
@@ -22,13 +22,7 @@ interface Props {
volumes: LxdStorageVolume[];
}
-const volumeTypes: string[] = [
- "Container",
- "VM",
- "Snapshot",
- "Image",
- "Custom",
-];
+const volumeTypes: string[] = ["Container", "VM", "Image", "Custom"];
export const QUERY = "query";
export const POOL = "pool";
diff --git a/src/pages/storage/actions/CustomStorageVolumeActions.tsx b/src/pages/storage/actions/CustomStorageVolumeActions.tsx
new file mode 100644
index 0000000000..33e595d4e8
--- /dev/null
+++ b/src/pages/storage/actions/CustomStorageVolumeActions.tsx
@@ -0,0 +1,39 @@
+import React, { FC } from "react";
+import classnames from "classnames";
+import { List, useNotify } from "@canonical/react-components";
+import { LxdStorageVolume } from "types/storage";
+import DeleteStorageVolumeBtn from "./DeleteStorageVolumeBtn";
+import VolumeAddSnapshotBtn from "./snapshots/VolumeAddSnapshotBtn";
+
+interface Props {
+ volume: LxdStorageVolume;
+ project: string;
+ className?: string;
+}
+
+const CustomStorageVolumeActions: FC = ({
+ volume,
+ className,
+ project,
+}) => {
+ const notify = useNotify();
+ return (
+
,
+ {
+ notify.success(`Storage volume ${volume.name} deleted.`);
+ }}
+ />,
+ ]}
+ />
+ );
+};
+
+export default CustomStorageVolumeActions;
diff --git a/src/pages/storage/actions/DeleteStorageVolumeBtn.tsx b/src/pages/storage/actions/DeleteStorageVolumeBtn.tsx
index 581d98b033..c364604bb0 100644
--- a/src/pages/storage/actions/DeleteStorageVolumeBtn.tsx
+++ b/src/pages/storage/actions/DeleteStorageVolumeBtn.tsx
@@ -77,6 +77,9 @@ const DeleteStorageVolumeBtn: FC = ({
project,
],
});
+ void queryClient.invalidateQueries({
+ predicate: (query) => query.queryKey[0] === queryKeys.volumes,
+ });
});
};
diff --git a/src/pages/storage/actions/snapshots/VolumeAddSnapshotBtn.tsx b/src/pages/storage/actions/snapshots/VolumeAddSnapshotBtn.tsx
new file mode 100644
index 0000000000..925ad4bc23
--- /dev/null
+++ b/src/pages/storage/actions/snapshots/VolumeAddSnapshotBtn.tsx
@@ -0,0 +1,57 @@
+import React, { FC } from "react";
+import { LxdStorageVolume } from "types/storage";
+import { Button, Icon } from "@canonical/react-components";
+import usePortal from "react-useportal";
+import CreateVolumeSnapshotForm from "pages/storage/forms/CreateVolumeSnapshotForm";
+
+interface Props {
+ volume: LxdStorageVolume;
+ isCTA?: boolean;
+ isDisabled?: boolean;
+ className?: string;
+}
+
+const VolumeAddSnapshotBtn: FC = ({
+ volume,
+ isCTA,
+ isDisabled,
+ className,
+}) => {
+ const { openPortal, closePortal, isOpen, Portal } = usePortal();
+
+ return (
+ <>
+ {isOpen ? (
+
+
+
+ ) : null}
+ {isCTA ? (
+
+ ) : (
+
+ )}
+ >
+ );
+};
+
+export default VolumeAddSnapshotBtn;
diff --git a/src/pages/storage/actions/snapshots/VolumeConfigureSnapshotBtn.tsx b/src/pages/storage/actions/snapshots/VolumeConfigureSnapshotBtn.tsx
new file mode 100644
index 0000000000..5ee01103ee
--- /dev/null
+++ b/src/pages/storage/actions/snapshots/VolumeConfigureSnapshotBtn.tsx
@@ -0,0 +1,40 @@
+import React, { FC, ReactNode } from "react";
+import usePortal from "react-useportal";
+import { Button } from "@canonical/react-components";
+import VolumeConfigureSnapshotModal from "./VolumeConfigureSnapshotModal";
+import { LxdStorageVolume } from "types/storage";
+
+interface Props {
+ volume: LxdStorageVolume;
+ onSuccess: (message: string) => void;
+ onFailure: (title: string, e: unknown, message?: ReactNode) => void;
+ isDisabled?: boolean;
+ className?: string;
+}
+
+const VolumeConfigureSnapshotBtn: FC = (props) => {
+ const { volume, onSuccess, onFailure, isDisabled, className } = props;
+ const { openPortal, closePortal, isOpen, Portal } = usePortal();
+
+ return (
+ <>
+ {isOpen && (
+
+
+
+
+
+ )}
+
+ >
+ );
+};
+
+export default VolumeConfigureSnapshotBtn;
diff --git a/src/pages/storage/actions/snapshots/VolumeConfigureSnapshotModal.tsx b/src/pages/storage/actions/snapshots/VolumeConfigureSnapshotModal.tsx
new file mode 100644
index 0000000000..2ef01667ad
--- /dev/null
+++ b/src/pages/storage/actions/snapshots/VolumeConfigureSnapshotModal.tsx
@@ -0,0 +1,113 @@
+import React, { FC, KeyboardEvent } from "react";
+import { Button, Modal } from "@canonical/react-components";
+import { useFormik } from "formik";
+import { queryKeys } from "util/queryKeys";
+import { useQueryClient } from "@tanstack/react-query";
+import SubmitButton from "components/SubmitButton";
+import { LxdStorageVolume } from "types/storage";
+import {
+ StorageVolumeFormValues,
+ volumeFormToPayload,
+} from "pages/storage/forms/StorageVolumeForm";
+import { getStorageVolumeEditValues } from "util/storageVolumeEdit";
+import { updateStorageVolume } from "api/storage-pools";
+import StorageVolumeSnapshotsForm from "../../forms/StorageVolumeSnapshotsForm";
+
+interface Props {
+ volume: LxdStorageVolume;
+ close: () => void;
+ onSuccess: (message: string) => void;
+ onFailure: (title: string, e: unknown) => void;
+}
+
+const VolumeConfigureSnapshotModal: FC = ({
+ volume,
+ close,
+ onSuccess,
+ onFailure,
+}) => {
+ const queryClient = useQueryClient();
+
+ const formik = useFormik({
+ initialValues: getStorageVolumeEditValues(volume),
+ onSubmit: (values) => {
+ const saveVolume = volumeFormToPayload(values, volume.project);
+ void updateStorageVolume(volume.pool, volume.project, {
+ ...saveVolume,
+ etag: volume.etag,
+ })
+ .then(() => {
+ onSuccess("Configuration updated.");
+ void queryClient.invalidateQueries({
+ queryKey: [queryKeys.storage],
+ predicate: (query) =>
+ query.queryKey[0] === queryKeys.volumes ||
+ query.queryKey[0] === queryKeys.storage,
+ });
+ })
+ .catch((e: Error) => {
+ onFailure("Configuration update failed", e);
+ })
+ .finally(() => {
+ close();
+ formik.setSubmitting(false);
+ });
+ },
+ });
+
+ const handleEscKey = (e: KeyboardEvent) => {
+ if (e.key === "Escape") {
+ close();
+ }
+ };
+
+ return (
+
+
+
+
+ ) : (
+ <>
+
+ void formik.submitForm()}
+ />
+ >
+ )
+ }
+ onKeyDown={handleEscKey}
+ >
+
+
+ );
+};
+
+export default VolumeConfigureSnapshotModal;
diff --git a/src/pages/storage/actions/snapshots/VolumeEditSnapshotBtn.tsx b/src/pages/storage/actions/snapshots/VolumeEditSnapshotBtn.tsx
new file mode 100644
index 0000000000..9aec25b8c7
--- /dev/null
+++ b/src/pages/storage/actions/snapshots/VolumeEditSnapshotBtn.tsx
@@ -0,0 +1,45 @@
+import React, { FC } from "react";
+import usePortal from "react-useportal";
+import { Button, Icon } from "@canonical/react-components";
+import { LxdStorageVolume, LxdVolumeSnapshot } from "types/storage";
+import EditVolumeSnapshotForm from "pages/storage/forms/EditVolumeSnapshotForm";
+
+interface Props {
+ volume: LxdStorageVolume;
+ snapshot: LxdVolumeSnapshot;
+ isDeleting: boolean;
+ isRestoring: boolean;
+}
+
+const VolumeEditSnapshotBtn: FC = (props) => {
+ const { volume, snapshot, isDeleting, isRestoring } = props;
+ const { openPortal, closePortal, isOpen, Portal } = usePortal();
+
+ return (
+ <>
+ {isOpen && (
+
+
+
+ )}
+
+ >
+ );
+};
+
+export default VolumeEditSnapshotBtn;
diff --git a/src/pages/storage/actions/snapshots/VolumeSnapshotActions.tsx b/src/pages/storage/actions/snapshots/VolumeSnapshotActions.tsx
new file mode 100644
index 0000000000..083b602a40
--- /dev/null
+++ b/src/pages/storage/actions/snapshots/VolumeSnapshotActions.tsx
@@ -0,0 +1,140 @@
+import React, { FC, useState } from "react";
+import { LxdStorageVolume, LxdVolumeSnapshot } from "types/storage";
+import {
+ deleteVolumeSnapshot,
+ restoreVolumeSnapshot,
+} from "api/volume-snapshots";
+import { useQueryClient } from "@tanstack/react-query";
+import { queryKeys } from "util/queryKeys";
+import { ConfirmationButton, Icon, List } from "@canonical/react-components";
+import classnames from "classnames";
+import ItemName from "components/ItemName";
+import { useEventQueue } from "context/eventQueue";
+import VolumeEditSnapshotBtn from "./VolumeEditSnapshotBtn";
+
+interface Props {
+ volume: LxdStorageVolume;
+ snapshot: LxdVolumeSnapshot;
+ onSuccess: (message: string) => void;
+ onFailure: (title: string, error: Error) => void;
+}
+
+const VolumeSnapshotActions: FC = ({
+ volume,
+ snapshot,
+ onSuccess,
+ onFailure,
+}) => {
+ const eventQueue = useEventQueue();
+ const [isDeleting, setDeleting] = useState(false);
+ const [isRestoring, setRestoring] = useState(false);
+ const queryClient = useQueryClient();
+
+ const handleDelete = () => {
+ setDeleting(true);
+ void deleteVolumeSnapshot(volume, snapshot).then((operation) =>
+ eventQueue.set(
+ operation.metadata.id,
+ () => onSuccess(`Snapshot ${snapshot.name} deleted`),
+ (msg) => onFailure("Snapshot deletion failed", new Error(msg)),
+ () => {
+ setDeleting(false);
+ void queryClient.invalidateQueries({
+ predicate: (query) =>
+ query.queryKey[0] === queryKeys.volumes ||
+ query.queryKey[0] === queryKeys.storage,
+ });
+ },
+ ),
+ );
+ };
+
+ const handleRestore = () => {
+ setRestoring(true);
+ void restoreVolumeSnapshot(volume, snapshot)
+ .then(() => {
+ onSuccess(`Snapshot ${snapshot.name} restored`);
+ })
+ .catch((error: Error) => {
+ onFailure("Snapshot restore failed", error);
+ })
+ .finally(() => {
+ setRestoring(false);
+ void queryClient.invalidateQueries({
+ predicate: (query) =>
+ query.queryKey[0] === queryKeys.volumes ||
+ query.queryKey[0] === queryKeys.storage,
+ });
+ });
+ };
+
+ return (
+ <>
+
,
+
+ This will restore snapshot .
+
+ This action cannot be undone, and can result in data loss.
+
+ ),
+ confirmButtonLabel: "Restore",
+ confirmButtonAppearance: "positive",
+ onConfirm: handleRestore,
+ }}
+ disabled={isDeleting || isRestoring}
+ shiftClickEnabled
+ showShiftClickHint
+ >
+
+ ,
+
+ This will permanently delete snapshot{" "}
+ .
+ This action cannot be undone, and can result in data loss.
+
+ ),
+ confirmButtonLabel: "Delete",
+ onConfirm: handleDelete,
+ }}
+ disabled={isDeleting || isRestoring}
+ shiftClickEnabled
+ showShiftClickHint
+ >
+
+ ,
+ ]}
+ />
+ >
+ );
+};
+
+export default VolumeSnapshotActions;
diff --git a/src/pages/storage/actions/snapshots/VolumeSnapshotBulkDelete.tsx b/src/pages/storage/actions/snapshots/VolumeSnapshotBulkDelete.tsx
new file mode 100644
index 0000000000..ff837168a3
--- /dev/null
+++ b/src/pages/storage/actions/snapshots/VolumeSnapshotBulkDelete.tsx
@@ -0,0 +1,108 @@
+import React, { FC, ReactNode, useState } from "react";
+import { deleteVolumeSnapshotBulk } from "api/volume-snapshots";
+import { useQueryClient } from "@tanstack/react-query";
+import { queryKeys } from "util/queryKeys";
+import { pluralizeSnapshot } from "util/instanceBulkActions";
+import { ConfirmationButton, Icon } from "@canonical/react-components";
+import classnames from "classnames";
+import { useEventQueue } from "context/eventQueue";
+import { getPromiseSettledCounts } from "util/helpers";
+import { LxdStorageVolume } from "types/storage";
+
+interface Props {
+ volume: LxdStorageVolume;
+ snapshotNames: string[];
+ onStart: () => void;
+ onFinish: () => void;
+ onSuccess: (message: string) => void;
+ onFailure: (title: string, e: unknown, message?: ReactNode) => void;
+}
+
+const VolumeSnapshotBulkDelete: FC = ({
+ volume,
+ snapshotNames,
+ onStart,
+ onFinish,
+ onSuccess,
+ onFailure,
+}) => {
+ const eventQueue = useEventQueue();
+ const [isLoading, setLoading] = useState(false);
+ const queryClient = useQueryClient();
+
+ const count = snapshotNames.length;
+
+ const handleDelete = () => {
+ setLoading(true);
+ onStart();
+ void deleteVolumeSnapshotBulk(volume, snapshotNames, eventQueue).then(
+ (results) => {
+ const { fulfilledCount, rejectedCount } =
+ getPromiseSettledCounts(results);
+ if (fulfilledCount === count) {
+ onSuccess(
+ `${snapshotNames.length} snapshot${
+ snapshotNames.length > 1 && "s"
+ } deleted`,
+ );
+ } else if (rejectedCount === count) {
+ onFailure(
+ "Snapshot bulk deletion failed",
+ undefined,
+ <>
+ {count} {pluralizeSnapshot(count)} could not be deleted.
+ >,
+ );
+ } else {
+ onFailure(
+ "Snapshot bulk deletion partially failed",
+ undefined,
+ <>
+ {fulfilledCount} {pluralizeSnapshot(fulfilledCount)}{" "}
+ deleted.
+
+ {rejectedCount} {pluralizeSnapshot(rejectedCount)} could
+ not be deleted.
+ >,
+ );
+ }
+ void queryClient.invalidateQueries({
+ predicate: (query) =>
+ query.queryKey[0] === queryKeys.volumes ||
+ query.queryKey[0] === queryKeys.storage,
+ });
+ setLoading(false);
+ onFinish();
+ },
+ );
+ };
+
+ return (
+
+ This will permanently delete {count}{" "}
+ {pluralizeSnapshot(count)}
+ .
+ This action cannot be undone, and can result in data loss.
+
+ ),
+ confirmButtonLabel: "Delete",
+ onConfirm: handleDelete,
+ }}
+ disabled={isLoading}
+ className={classnames({ "has-icon": isLoading })}
+ onHoverText="Delete snapshots"
+ shiftClickEnabled
+ showShiftClickHint
+ >
+ {isLoading && }
+ Delete snapshots
+
+ );
+};
+
+export default VolumeSnapshotBulkDelete;
diff --git a/src/pages/storage/actions/snapshots/useVolumeSnapshot.tsx b/src/pages/storage/actions/snapshots/useVolumeSnapshot.tsx
new file mode 100644
index 0000000000..affb194190
--- /dev/null
+++ b/src/pages/storage/actions/snapshots/useVolumeSnapshot.tsx
@@ -0,0 +1,225 @@
+import { useNotify } from "@canonical/react-components";
+import { useQueryClient } from "@tanstack/react-query";
+import {
+ createVolumeSnapshot,
+ renameVolumeSnapshot,
+ updateVolumeSnapshot,
+} from "api/volume-snapshots";
+import AutoExpandingTextArea from "components/AutoExpandingTextArea";
+// import ItemName from "components/ItemName";
+import { useEventQueue } from "context/eventQueue";
+import { useFormik } from "formik";
+import React, { useState } from "react";
+import { LxdStorageVolume, LxdVolumeSnapshot } from "types/storage";
+import {
+ UNDEFINED_DATE,
+ getBrowserFormatDate,
+ stringToIsoTime,
+} from "util/helpers";
+import { queryKeys } from "util/queryKeys";
+import {
+ SnapshotFormValues,
+ getExpiresAt,
+ getVolumeSnapshotSchema,
+} from "util/snapshots";
+
+interface CreateVolumeSnapshotHook {
+ volume: LxdStorageVolume;
+ close: () => void;
+}
+
+interface EditVolumeSnapshotHook {
+ volume: LxdStorageVolume;
+ snapshot: LxdVolumeSnapshot;
+ close: () => void;
+}
+
+const useCreateVolumeSnapshot = (props: CreateVolumeSnapshotHook) => {
+ const { volume, close } = props;
+ const eventQueue = useEventQueue();
+ const notify = useNotify();
+ const queryClient = useQueryClient();
+ const controllerState = useState(null);
+
+ const formik = useFormik({
+ initialValues: {
+ name: "",
+ expirationDate: null,
+ expirationTime: null,
+ },
+ validateOnMount: true,
+ validationSchema: getVolumeSnapshotSchema(volume, controllerState),
+ onSubmit: (values, { resetForm }) => {
+ notify.clear();
+ const expiresAt =
+ values.expirationDate && values.expirationTime
+ ? stringToIsoTime(
+ getExpiresAt(values.expirationDate, values.expirationTime),
+ )
+ : UNDEFINED_DATE;
+ void createVolumeSnapshot({
+ volume,
+ name: values.name,
+ expiresAt,
+ })
+ .then((operation) => {
+ eventQueue.set(
+ operation.metadata.id,
+ () => {
+ void queryClient.invalidateQueries({
+ predicate: (query) =>
+ query.queryKey[0] === queryKeys.volumes ||
+ query.queryKey[0] === queryKeys.storage,
+ });
+ notify.queue(notify.success(`Snapshot ${values.name} created.`));
+ close();
+ resetForm();
+ },
+ (msg) => {
+ notify.failure("Snapshot creation failed", new Error(msg));
+ formik.setSubmitting(false);
+ },
+ );
+ })
+ .catch((error: Error) => {
+ notify.failure("Snapshot creation failed", error);
+ formik.setSubmitting(false);
+ close();
+ });
+ },
+ });
+
+ return {
+ formik,
+ };
+};
+
+const useEditVolumeSnapshot = (props: EditVolumeSnapshotHook) => {
+ const { volume, snapshot, close } = props;
+ const eventQueue = useEventQueue();
+ const notify = useNotify();
+ const queryClient = useQueryClient();
+ const controllerState = useState(null);
+
+ const notifyUpdateSuccess = (name: string) => {
+ void queryClient.invalidateQueries({
+ predicate: (query) =>
+ query.queryKey[0] === queryKeys.volumes ||
+ query.queryKey[0] === queryKeys.storage,
+ });
+ notify.queue(notify.success(`Snapshot ${name} saved.`));
+ formik.setSubmitting(false);
+ close();
+ };
+
+ const update = (
+ expiresAt: string | null,
+ description: string,
+ newName?: string,
+ ) => {
+ // NOTE: volume snapshot update api call is synchronous, so can't use events api
+ void updateVolumeSnapshot({
+ volume,
+ snapshot: { ...snapshot, name: newName || snapshot.name },
+ expiresAt,
+ description,
+ })
+ .then(() => {
+ notifyUpdateSuccess(newName || snapshot.name);
+ })
+ .catch((error: Error) => {
+ notify.failure("Snapshot update failed", error);
+ formik.setSubmitting(false);
+ });
+ };
+
+ const rename = (
+ newName: string,
+ expiresAt?: string | null,
+ description?: string,
+ ) => {
+ void renameVolumeSnapshot({
+ volume,
+ snapshot,
+ newName,
+ }).then((operation) =>
+ eventQueue.set(
+ operation.metadata.id,
+ () => {
+ if (expiresAt || description) {
+ update(expiresAt || null, description || "", newName);
+ } else {
+ notifyUpdateSuccess(newName);
+ }
+ },
+ (msg) => {
+ notify.failure("Snapshot rename failed", new Error(msg));
+ formik.setSubmitting(false);
+ },
+ ),
+ );
+ };
+
+ const [expiryDate, expiryTime] = !snapshot.expires_at
+ ? [null, null]
+ : getBrowserFormatDate(new Date(snapshot.expires_at))
+ .slice(0, 16)
+ .split(" ");
+
+ const formik = useFormik>({
+ initialValues: {
+ name: snapshot.name,
+ expirationDate: expiryDate,
+ expirationTime: expiryTime,
+ description: snapshot.description,
+ },
+ validateOnMount: true,
+ validationSchema: getVolumeSnapshotSchema(
+ volume,
+ controllerState,
+ snapshot.name,
+ ),
+ onSubmit: (values) => {
+ notify.clear();
+ const newName = values.name;
+ const expiresAt =
+ values.expirationDate && values.expirationTime
+ ? stringToIsoTime(
+ getExpiresAt(values.expirationDate, values.expirationTime),
+ )
+ : null;
+ const newDescription = values.description;
+ const shouldRename = newName !== snapshot.name;
+ const shouldUpdate =
+ expiresAt !== snapshot.expires_at ||
+ newDescription !== snapshot.description;
+ if (shouldRename && shouldUpdate) {
+ rename(newName, expiresAt, newDescription);
+ } else if (shouldRename) {
+ rename(newName);
+ } else {
+ update(expiresAt, newDescription || "");
+ }
+ },
+ });
+
+ const descriptionTextarea = (
+
+ );
+
+ return {
+ formik,
+ descriptionTextarea,
+ };
+};
+
+export { useCreateVolumeSnapshot, useEditVolumeSnapshot };
diff --git a/src/pages/storage/forms/CreateVolumeSnapshotForm.tsx b/src/pages/storage/forms/CreateVolumeSnapshotForm.tsx
new file mode 100644
index 0000000000..1c539589ec
--- /dev/null
+++ b/src/pages/storage/forms/CreateVolumeSnapshotForm.tsx
@@ -0,0 +1,21 @@
+import React, { FC } from "react";
+import { LxdStorageVolume } from "types/storage";
+import { useCreateVolumeSnapshot } from "../actions/snapshots/useVolumeSnapshot";
+import SnapshotForm from "components/forms/SnapshotForm";
+
+interface Props {
+ close: () => void;
+ volume: LxdStorageVolume;
+}
+
+const CreateVolumeSnapshotForm: FC = (props) => {
+ const { close, volume } = props;
+ const { formik } = useCreateVolumeSnapshot({
+ volume,
+ close,
+ });
+
+ return ;
+};
+
+export default CreateVolumeSnapshotForm;
diff --git a/src/pages/storage/forms/EditVolumeSnapshotForm.tsx b/src/pages/storage/forms/EditVolumeSnapshotForm.tsx
new file mode 100644
index 0000000000..422be5e24d
--- /dev/null
+++ b/src/pages/storage/forms/EditVolumeSnapshotForm.tsx
@@ -0,0 +1,30 @@
+import React, { FC } from "react";
+import { LxdStorageVolume, LxdVolumeSnapshot } from "types/storage";
+import { useEditVolumeSnapshot } from "../actions/snapshots/useVolumeSnapshot";
+import SnapshotForm from "components/forms/SnapshotForm";
+
+interface Props {
+ volume: LxdStorageVolume;
+ snapshot: LxdVolumeSnapshot;
+ close: () => void;
+}
+
+const EditVolumeSnapshotForm: FC = (props) => {
+ const { volume, snapshot, close } = props;
+ const { formik, descriptionTextarea } = useEditVolumeSnapshot({
+ volume,
+ snapshot,
+ close: close,
+ });
+
+ return (
+
+ );
+};
+
+export default EditVolumeSnapshotForm;
diff --git a/src/pages/storage/forms/StorageVolumeCreate.tsx b/src/pages/storage/forms/StorageVolumeCreate.tsx
index c7c3954eec..972336b11c 100644
--- a/src/pages/storage/forms/StorageVolumeCreate.tsx
+++ b/src/pages/storage/forms/StorageVolumeCreate.tsx
@@ -60,6 +60,9 @@ const StorageVolumeCreate: FC = () => {
void queryClient.invalidateQueries({
queryKey: [queryKeys.projects, project],
});
+ void queryClient.invalidateQueries({
+ predicate: (query) => query.queryKey[0] === queryKeys.volumes,
+ });
navigate(
`/ui/project/${project}/storage/volumes`,
notify.queue(
diff --git a/src/pages/storage/forms/StorageVolumeForm.tsx b/src/pages/storage/forms/StorageVolumeForm.tsx
index fc91a59a73..2ebada3bb9 100644
--- a/src/pages/storage/forms/StorageVolumeForm.tsx
+++ b/src/pages/storage/forms/StorageVolumeForm.tsx
@@ -18,7 +18,11 @@ import StorageVolumeFormBlock from "pages/storage/forms/StorageVolumeFormBlock";
import StorageVolumeFormZFS from "pages/storage/forms/StorageVolumeFormZFS";
import { FormikProps } from "formik/dist/types";
import { getVolumeKey } from "util/storageVolume";
-import { LxdStorageVolume, LxdStorageVolumeContentType } from "types/storage";
+import {
+ LxdStorageVolume,
+ LxdStorageVolumeContentType,
+ LxdStorageVolumeType,
+} from "types/storage";
import { slugify } from "util/slugify";
export interface StorageVolumeFormValues {
@@ -27,7 +31,7 @@ export interface StorageVolumeFormValues {
pool: string;
size?: string;
content_type: LxdStorageVolumeContentType;
- type: string;
+ type: LxdStorageVolumeType;
security_shifted?: string;
security_unmapped?: string;
snapshots_expiry?: string;
diff --git a/src/pages/storage/forms/StorageVolumeSnapshotsForm.tsx b/src/pages/storage/forms/StorageVolumeSnapshotsForm.tsx
new file mode 100644
index 0000000000..5723bdd935
--- /dev/null
+++ b/src/pages/storage/forms/StorageVolumeSnapshotsForm.tsx
@@ -0,0 +1,80 @@
+import React, { FC, ReactNode } from "react";
+import { Input } from "@canonical/react-components";
+import InstanceConfigurationTable from "components/forms/InstanceConfigurationTable";
+import SnapshotScheduleInput from "components/SnapshotScheduleInput";
+import { getStorageConfigurationRow } from "pages/storage/forms/StorageConfigurationRow";
+import { useDocs } from "context/useDocs";
+import { FormikProps } from "formik";
+import { StorageVolumeFormValues } from "pages/storage/forms/StorageVolumeForm";
+
+interface Props {
+ formik: FormikProps;
+ children?: ReactNode;
+}
+
+const VolumeSnapshotsForm: FC = ({ formik }) => {
+ const docBaseLink = useDocs();
+ return (
+
+ Pongo2 template string that represents the snapshot name (used
+ for scheduled snapshots and unnamed snapshots), see{" "}
+
+ Automatic snapshot names
+
+ >
+ }
+ type="text"
+ />
+ ),
+ }),
+
+ getStorageConfigurationRow({
+ formik,
+ label: "Expire after",
+ name: "snapshots_expiry",
+ help: "Controls when snapshots are to be deleted",
+ defaultValue: "",
+ children: (
+
+ ),
+ }),
+
+ getStorageConfigurationRow({
+ formik,
+ label: "Schedule",
+ name: "snapshots_schedule",
+ defaultValue: "",
+ children: (
+
+ void formik.setFieldValue("snapshots_schedule", val)
+ }
+ />
+ ),
+ }),
+ ]}
+ />
+ );
+};
+
+export default VolumeSnapshotsForm;
diff --git a/src/sass/_instance_detail_snapshots.scss b/src/sass/_snapshots.scss
similarity index 65%
rename from src/sass/_instance_detail_snapshots.scss
rename to src/sass/_snapshots.scss
index 2d6efb7ef9..95155e8bba 100644
--- a/src/sass/_instance_detail_snapshots.scss
+++ b/src/sass/_snapshots.scss
@@ -11,33 +11,6 @@
margin-bottom: 0;
}
- .snapshot-creation-modal {
- text-align: left !important;
-
- .p-modal__dialog {
- min-width: 30rem;
- padding-bottom: 0;
- padding-left: $sph--x-large;
- padding-right: $sph--x-large;
- }
-
- .p-form__label {
- margin-bottom: 0;
- }
-
- .p-inline-list {
- margin-bottom: $spv--small;
- }
-
- .p-inline-list__item {
- margin-right: $sph--small;
- }
-
- .expiration-wrapper {
- grid-gap: $sph--large;
- }
- }
-
.name {
min-width: 170px;
}
diff --git a/src/sass/_storage.scss b/src/sass/_storage.scss
index 679f252656..750f606ddf 100644
--- a/src/sass/_storage.scss
+++ b/src/sass/_storage.scss
@@ -10,6 +10,24 @@
}
}
+.storage-pool-table {
+ .size {
+ width: 14rem;
+ }
+
+ .actions {
+ width: 5rem;
+ }
+
+ button {
+ margin-top: -6px;
+ }
+
+ .p-meter {
+ margin-top: 4px;
+ }
+}
+
.storage-volume-table {
.used_by {
width: 5rem;
@@ -22,6 +40,34 @@
button {
margin-top: -6px;
}
+
+ .volume-name-link {
+ max-width: 90%;
+ }
+
+ .u-row:hover {
+ background-color: $colors--light-theme--background-hover;
+ }
+
+ .actions-list {
+ margin-top: -7px;
+ }
+
+ .storage-volume-actions button {
+ padding: 0;
+ }
+
+ @media screen and (pointer: fine) {
+ .storage-volume-actions {
+ visibility: hidden;
+ }
+
+ .u-row:hover {
+ .storage-volume-actions {
+ visibility: visible;
+ }
+ }
+ }
}
.custom-iso-table {
@@ -47,21 +93,3 @@
margin-top: -7px;
}
}
-
-.storage-pool-table {
- .size {
- width: 14rem;
- }
-
- .actions {
- width: 5rem;
- }
-
- button {
- margin-top: -6px;
- }
-
- .p-meter {
- margin-top: 4px;
- }
-}
diff --git a/src/sass/styles.scss b/src/sass/styles.scss
index 82a00828eb..1a558c9474 100644
--- a/src/sass/styles.scss
+++ b/src/sass/styles.scss
@@ -43,6 +43,7 @@
@include vf-p-icon-units;
@include vf-p-icon-video-play;
@include vf-p-icon-warning-grey;
+@include vf-p-icon-add-canvas;
$border-thin: 1px solid $color-mid-light !default;
@@ -62,7 +63,6 @@ $border-thin: 1px solid $color-mid-light !default;
@import "instance_detail_overview";
@import "instance_detail_page";
@import "instance_detail_panel";
-@import "instance_detail_snapshots";
@import "instance_detail_terminal";
@import "instance_list";
@import "network_detail_overview";
@@ -84,6 +84,7 @@ $border-thin: 1px solid $color-mid-light !default;
@import "scrollable_table";
@import "selectable_main_table";
@import "settings_page";
+@import "snapshots";
@import "storage";
@import "storage_detail_overview";
@import "storage_pool_form";
@@ -208,6 +209,11 @@ $border-thin: 1px solid $color-mid-light !default;
flex-direction: row-reverse;
}
+.u-flex {
+ align-items: center;
+ display: flex;
+}
+
.u-no-border {
border: none !important;
}
@@ -259,3 +265,32 @@ $border-thin: 1px solid $color-mid-light !default;
.no-border-top {
border-top: none !important;
}
+
+.snapshot-creation-modal {
+ text-align: left !important;
+
+ .p-modal__dialog {
+ min-width: 30rem;
+ padding-bottom: 0;
+ padding-left: $sph--x-large;
+ padding-right: $sph--x-large;
+ }
+
+ .p-form__label {
+ margin-bottom: 0;
+ }
+
+ .p-inline-list {
+ margin-bottom: $spv--small;
+ }
+
+ .p-inline-list__item {
+ margin-right: $sph--small;
+ }
+
+ .expiration-wrapper {
+ grid-gap: $sph--large;
+ padding-left: 0;
+ padding-right: 0;
+ }
+}
diff --git a/src/types/apiResponse.d.ts b/src/types/apiResponse.d.ts
index 288756834b..e87413557f 100644
--- a/src/types/apiResponse.d.ts
+++ b/src/types/apiResponse.d.ts
@@ -1,3 +1,14 @@
+import { AnyObject } from "yup";
+
export interface LxdApiResponse {
metadata: T;
}
+
+export interface LxdSyncResponse {
+ type: "sync";
+ status: string;
+ status_code: number;
+ error_code: number;
+ error: string;
+ metadata: AnyObject;
+}
diff --git a/src/types/instance.d.ts b/src/types/instance.d.ts
index b819f14cb6..44d0615142 100644
--- a/src/types/instance.d.ts
+++ b/src/types/instance.d.ts
@@ -52,7 +52,7 @@ interface LxdInstanceState {
status: string;
}
-interface LxdSnapshot {
+interface LxdInstanceSnapshot {
name: string;
created_at: string;
expires_at: string;
@@ -93,7 +93,7 @@ export interface LxdInstance {
profiles: string[];
project: string;
restore?: string;
- snapshots: LxdSnapshot[] | null;
+ snapshots: LxdInstanceSnapshot[] | null;
state?: LxdInstanceState;
stateful: boolean;
status: LxdInstanceStatus;
diff --git a/src/types/storage.d.ts b/src/types/storage.d.ts
index 0ba6b19d21..db04aaedf7 100644
--- a/src/types/storage.d.ts
+++ b/src/types/storage.d.ts
@@ -2,7 +2,7 @@ export interface LxdStoragePool {
config?: {
size?: string;
source?: string;
- } & Record;
+ } & Record;
description: string;
driver: string;
locations?: string[];
@@ -14,6 +14,12 @@ export interface LxdStoragePool {
export type LxdStorageVolumeContentType = "filesystem" | "block" | "iso";
+export type LxdStorageVolumeType =
+ | "container"
+ | "virtual-machine"
+ | "image"
+ | "custom";
+
export interface LxdStorageVolume {
config: {
"block.filesystem"?: string;
@@ -39,7 +45,7 @@ export interface LxdStorageVolume {
name: string;
pool: string;
project: string;
- type: string;
+ type: LxdStorageVolumeType;
used_by?: string[];
etag?: string;
}
@@ -67,3 +73,10 @@ export interface UploadState {
loaded: number;
total?: number;
}
+
+export interface LxdVolumeSnapshot {
+ name: string;
+ created_at: string;
+ expires_at: string;
+ description?: string;
+}
diff --git a/src/util/instanceEdit.tsx b/src/util/instanceEdit.tsx
index ccfc5f9b16..121836f885 100644
--- a/src/util/instanceEdit.tsx
+++ b/src/util/instanceEdit.tsx
@@ -6,7 +6,7 @@ import { getInstanceConfigKeys } from "util/instanceConfigFields";
import { instanceEditDetailPayload } from "pages/instances/forms/EditInstanceDetails";
import { resourceLimitsPayload } from "components/forms/ResourceLimitsForm";
import { securityPoliciesPayload } from "components/forms/SecurityPoliciesForm";
-import { snapshotsPayload } from "components/forms/SnapshotsForm";
+import { snapshotsPayload } from "components/forms/InstanceSnapshotsForm";
import { cloudInitPayload } from "components/forms/CloudInitForm";
import { getUnhandledKeyValues } from "util/formFields";
import { EditInstanceFormValues } from "pages/instances/EditInstance";
diff --git a/src/util/snapshots.tsx b/src/util/snapshots.tsx
index 726abf6588..f542d9058e 100644
--- a/src/util/snapshots.tsx
+++ b/src/util/snapshots.tsx
@@ -6,43 +6,20 @@ import {
getTomorrow,
} from "./helpers";
import * as Yup from "yup";
+import { LxdStorageVolume } from "types/storage";
-export interface SnapshotFormValues {
+/*** General snapshot utils ***/
+export type SnapshotFormValues = {
name: string;
- stateful: boolean;
expirationDate: string | null;
expirationTime: string | null;
-}
-
-export const isInstanceStateful = (instance: LxdInstance) => {
- return Boolean(instance.config["migration.stateful"]);
+} & {
+ [K in keyof AdditionalProps]: AdditionalProps[K];
};
export const getExpiresAt = (expirationDate: string, expirationTime: string) =>
`${expirationDate}T${expirationTime}`;
-export const testDuplicateSnapshotName = (
- instance: LxdInstance,
- controllerState: AbortControllerState,
- excludeName?: string,
-): [string, string, TestFunction] => {
- return [
- "deduplicate",
- "Snapshot name already in use",
- (value?: string) => {
- return (
- (excludeName && value === excludeName) ||
- checkDuplicateName(
- value,
- instance.project,
- controllerState,
- `instances/${instance.name}/snapshots`,
- )
- );
- },
- ];
-};
-
export const testValidDate = (): [
string,
string,
@@ -98,7 +75,34 @@ export const testValidTime = (): [
];
};
-export const getSnapshotSchema = (
+/*** Instance snapshot utils ***/
+export const isInstanceStateful = (instance: LxdInstance) => {
+ return Boolean(instance.config["migration.stateful"]);
+};
+
+export const testDuplicateInsatnceSnapshotName = (
+ instance: LxdInstance,
+ controllerState: AbortControllerState,
+ excludeName?: string,
+): [string, string, TestFunction] => {
+ return [
+ "deduplicate",
+ "Snapshot name already in use",
+ (value?: string) => {
+ return (
+ (excludeName && value === excludeName) ||
+ checkDuplicateName(
+ value,
+ instance.project,
+ controllerState,
+ `instances/${instance.name}/snapshots`,
+ )
+ );
+ },
+ ];
+};
+
+export const getInstanceSnapshotSchema = (
instance: LxdInstance,
controllerState: AbortControllerState,
snapshotName?: string,
@@ -106,7 +110,11 @@ export const getSnapshotSchema = (
return Yup.object().shape({
name: Yup.string()
.test(
- ...testDuplicateSnapshotName(instance, controllerState, snapshotName),
+ ...testDuplicateInsatnceSnapshotName(
+ instance,
+ controllerState,
+ snapshotName,
+ ),
)
.matches(/^[A-Za-z0-9-_.:]+$/, {
message:
@@ -124,3 +132,57 @@ export const getSnapshotSchema = (
stateful: Yup.boolean(),
});
};
+
+/*** Volume snapshot utils ***/
+export const testDuplicateVolumeSnapshotName = (
+ volume: LxdStorageVolume,
+ controllerState: AbortControllerState,
+ excludeName?: string,
+): [string, string, TestFunction] => {
+ return [
+ "deduplicate",
+ "Snapshot name already in use",
+ (value?: string) => {
+ return (
+ (excludeName && value === excludeName) ||
+ checkDuplicateName(
+ value,
+ volume.project,
+ controllerState,
+ `storage-pools/${volume.pool}/volumes/custom/${volume.name}/snapshots`,
+ )
+ );
+ },
+ ];
+};
+
+export const getVolumeSnapshotSchema = (
+ volume: LxdStorageVolume,
+ controllerState: AbortControllerState,
+ snapshotName?: string,
+) => {
+ return Yup.object().shape({
+ name: Yup.string()
+ .test(
+ ...testDuplicateVolumeSnapshotName(
+ volume,
+ controllerState,
+ snapshotName,
+ ),
+ )
+ .matches(/^[A-Za-z0-9-_.:]+$/, {
+ message:
+ "Please enter only alphanumeric characters, underscores (_), periods (.), hyphens (-), and colons (:) in this field",
+ }),
+ expirationDate: Yup.string()
+ .nullable()
+ .optional()
+ .test(...testValidDate())
+ .test(...testFutureDate()),
+ expirationTime: Yup.string()
+ .nullable()
+ .optional()
+ .test(...testValidTime()),
+ description: Yup.string().optional(),
+ });
+};
diff --git a/src/util/storageVolume.spec.tsx b/src/util/storageVolume.spec.tsx
new file mode 100644
index 0000000000..92fe14ac61
--- /dev/null
+++ b/src/util/storageVolume.spec.tsx
@@ -0,0 +1,215 @@
+import { LxdStorageVolume } from "types/storage";
+import { getSnapshotsPerVolume } from "./storageVolume";
+
+describe("getSnapshotsPerVolume", () => {
+ it("no snapshot volumes", () => {
+ const volumes: LxdStorageVolume[] = [
+ {
+ config: {},
+ description: "",
+ name: "instance-1",
+ type: "container",
+ used_by: ["/1.0/instances/instance-1"],
+ location: "none",
+ content_type: "filesystem",
+ project: "default",
+ created_at: "2023-11-24T13:12:34.236303935Z",
+ pool: "default",
+ },
+ {
+ config: {},
+ description: "",
+ name: "instance-2",
+ type: "container",
+ used_by: ["/1.0/instances/instance-2"],
+ location: "none",
+ content_type: "filesystem",
+ project: "default",
+ created_at: "2023-11-24T13:13:26.586439414Z",
+ pool: "default",
+ },
+ {
+ config: {},
+ description: "",
+ name: "instance-3",
+ type: "container",
+ used_by: ["/1.0/instances/instance-3"],
+ location: "none",
+ content_type: "filesystem",
+ project: "default",
+ created_at: "2023-11-24T13:23:29.083858845Z",
+ pool: "default",
+ },
+ {
+ config: {
+ size: "1GiB",
+ },
+ description: "",
+ name: "custom-storage-1",
+ type: "custom",
+ used_by: [],
+ location: "none",
+ content_type: "filesystem",
+ project: "default",
+ created_at: "2023-11-29T12:40:55.693180467Z",
+ pool: "default",
+ },
+ ];
+
+ const actual = getSnapshotsPerVolume(volumes);
+ const expected = {};
+ expect(actual).toEqual(expected);
+ });
+
+ it("have snapshot volumes", () => {
+ const volumes: LxdStorageVolume[] = [
+ {
+ config: {},
+ description: "",
+ name: "instance-1",
+ type: "container",
+ used_by: ["/1.0/instances/instance-1"],
+ location: "none",
+ content_type: "filesystem",
+ project: "default",
+ created_at: "2023-11-24T13:12:34.236303935Z",
+ pool: "default",
+ },
+ {
+ config: {},
+ description: "",
+ name: "instance-1/instance-1-snapshot-1",
+ type: "container",
+ used_by: ["/1.0/instances/instance-1/snapshots/instance-1-snapshot-1"],
+ location: "none",
+ content_type: "filesystem",
+ project: "default",
+ created_at: "2023-11-24T13:27:00.768096193Z",
+ pool: "default",
+ },
+ {
+ config: {},
+ description: "",
+ name: "instance-1/instance-1-snapshot-2",
+ type: "container",
+ used_by: ["/1.0/instances/instance-1/snapshots/instance-1-snapshot-2"],
+ location: "none",
+ content_type: "filesystem",
+ project: "default",
+ created_at: "2023-11-24T13:27:12.213363162Z",
+ pool: "default",
+ },
+ {
+ config: {},
+ description: "",
+ name: "instance-1/instance-1-snapshot-3",
+ type: "container",
+ used_by: ["/1.0/instances/instance-1/snapshots/instance-1-snapshot-3"],
+ location: "none",
+ content_type: "filesystem",
+ project: "default",
+ created_at: "2023-11-24T13:27:22.113883819Z",
+ pool: "default",
+ },
+ {
+ config: {},
+ description: "",
+ name: "instance-2",
+ type: "container",
+ used_by: ["/1.0/instances/instance-2"],
+ location: "none",
+ content_type: "filesystem",
+ project: "default",
+ created_at: "2023-11-24T13:13:26.586439414Z",
+ pool: "default",
+ },
+ {
+ config: {},
+ description: "",
+ name: "instance-2/snapshot-1",
+ type: "container",
+ used_by: ["/1.0/instances/instance-2/snapshots/snapshot-1"],
+ location: "none",
+ content_type: "filesystem",
+ project: "default",
+ created_at: "2023-11-30T13:24:19.76724278Z",
+ pool: "default",
+ },
+ {
+ config: {},
+ description: "",
+ name: "instance-2/snapshot-2",
+ type: "container",
+ used_by: ["/1.0/instances/instance-2/snapshots/snapshot-2"],
+ location: "none",
+ content_type: "filesystem",
+ project: "default",
+ created_at: "2023-11-30T13:24:27.812801069Z",
+ pool: "default",
+ },
+ {
+ config: {},
+ description: "",
+ name: "instance-3",
+ type: "container",
+ used_by: ["/1.0/instances/instance-3"],
+ location: "none",
+ content_type: "filesystem",
+ project: "default",
+ created_at: "2023-11-24T13:23:29.083858845Z",
+ pool: "default",
+ },
+ {
+ config: {
+ size: "1GiB",
+ },
+ description: "",
+ name: "custom-storage-1",
+ type: "custom",
+ used_by: [],
+ location: "none",
+ content_type: "filesystem",
+ project: "default",
+ created_at: "2023-11-29T12:40:55.693180467Z",
+ pool: "default",
+ },
+ {
+ config: {},
+ description: "",
+ name: "vm-1",
+ type: "virtual-machine",
+ used_by: ["/1.0/instances/vm-1"],
+ location: "none",
+ content_type: "block",
+ project: "default",
+ created_at: "2023-11-29T15:18:56.68295803Z",
+ pool: "default",
+ },
+ {
+ config: {},
+ description: "",
+ name: "vm-1/snapshot-1",
+ type: "virtual-machine",
+ used_by: ["/1.0/instances/vm-1/snapshots/snapshot-1"],
+ location: "none",
+ content_type: "block",
+ project: "default",
+ created_at: "2023-11-30T13:24:54.725406799Z",
+ pool: "default",
+ },
+ ];
+
+ const actual = getSnapshotsPerVolume(volumes);
+ const expected = {
+ "instance-1": [
+ "instance-1-snapshot-1",
+ "instance-1-snapshot-2",
+ "instance-1-snapshot-3",
+ ],
+ "instance-2": ["snapshot-1", "snapshot-2"],
+ "vm-1": ["snapshot-1"],
+ };
+
+ expect(actual).toEqual(expected);
+ });
+});
diff --git a/src/util/storageVolume.tsx b/src/util/storageVolume.tsx
index 39ee1a2abf..84698775d7 100644
--- a/src/util/storageVolume.tsx
+++ b/src/util/storageVolume.tsx
@@ -2,7 +2,7 @@ import {
AbortControllerState,
capitalizeFirstLetter,
checkDuplicateName,
-} from "util/helpers";
+} from "./helpers";
import { AnyObject, TestContext, TestFunction } from "yup";
import { LxdStoragePool, LxdStorageVolume } from "types/storage";
import { StorageVolumeFormValues } from "pages/storage/forms/StorageVolumeForm";
@@ -86,12 +86,11 @@ export const getLxdDefault = (
};
export const volumeTypeForDisplay = (volume: LxdStorageVolume) => {
- const typePrefix =
+ const volumeDisplayName =
volume.type === "virtual-machine"
? "VM"
: capitalizeFirstLetter(volume.type);
- const typeSuffix = volume.name.includes("/") ? " (snapshot)" : "";
- return `${typePrefix}${typeSuffix}`;
+ return volumeDisplayName;
};
export const contentTypeForDisplay = (volume: LxdStorageVolume) => {
@@ -99,3 +98,52 @@ export const contentTypeForDisplay = (volume: LxdStorageVolume) => {
? "ISO"
: capitalizeFirstLetter(volume.content_type);
};
+
+export const generateLinkForVolumeDetail = (args: {
+ volume: LxdStorageVolume;
+ project: string;
+}) => {
+ const { volume, project } = args;
+ let path = `storage/detail/${volume.pool}/${volume.type}/${volume.name}`;
+
+ // NOTE: name of a volume created from an instance is exactly the same as the instance name
+ if (volume.type === "container" || volume.type === "virtual-machine") {
+ path = `instances/detail/${volume.name}`;
+ }
+
+ if (volume.type === "image") {
+ path = "images";
+ }
+
+ return `/ui/project/${project}/${path}`;
+};
+
+export const isSnapshot = (volume: LxdStorageVolume) => {
+ return volume.name.includes("/");
+};
+
+export const splitVolumeSnapshotName = (fullVolumeName: string) => {
+ const fullVolumeNameSplit = fullVolumeName.split("/");
+ const snapshotName = fullVolumeNameSplit.pop() || "";
+ const volumeName = fullVolumeNameSplit.join("");
+ return {
+ snapshotName,
+ volumeName,
+ };
+};
+
+export const getSnapshotsPerVolume = (volumes: LxdStorageVolume[]) => {
+ const snapshotPerVolumeLookup: { [volumeName: string]: string[] } = {};
+ for (const volume of volumes) {
+ if (isSnapshot(volume)) {
+ const { volumeName, snapshotName } = splitVolumeSnapshotName(volume.name);
+ if (!snapshotPerVolumeLookup[volumeName]) {
+ snapshotPerVolumeLookup[volumeName] = [];
+ }
+
+ snapshotPerVolumeLookup[volumeName].push(snapshotName);
+ }
+ }
+
+ return snapshotPerVolumeLookup;
+};
diff --git a/src/util/storageVolumeTable.tsx b/src/util/storageVolumeTable.tsx
new file mode 100644
index 0000000000..c7bb71e830
--- /dev/null
+++ b/src/util/storageVolumeTable.tsx
@@ -0,0 +1,21 @@
+export const NAME_COL = "Name";
+export const POOL_COL = "Pool";
+export const TYPE_COL = "Type";
+export const CONTENT_TYPE_COL = "Content Type";
+export const CREATED_AT_COL = "Create At";
+export const SIZE_COL = "Size";
+export const USED_BY_COL = "Used By";
+export const SNAPSHOTS_COL = "Snapshots";
+export const ACTIONS_COL = "Actions";
+
+export const COLUMN_WIDTHS: Record = {
+ [NAME_COL]: "19%",
+ [POOL_COL]: "9%",
+ [TYPE_COL]: "10%",
+ [CONTENT_TYPE_COL]: "10%",
+ [CREATED_AT_COL]: "15%",
+ [SIZE_COL]: "7%",
+ [USED_BY_COL]: "7%",
+ [SNAPSHOTS_COL]: "9%",
+ [ACTIONS_COL]: "14%",
+};
diff --git a/tests/helpers/snapshots.ts b/tests/helpers/snapshots.ts
index f2dc9fd055..7de9788427 100644
--- a/tests/helpers/snapshots.ts
+++ b/tests/helpers/snapshots.ts
@@ -6,7 +6,7 @@ export const randomSnapshotName = (): string => {
return `playwright-snapshot-${randomNameSuffix()}`;
};
-export const createSnapshot = async (
+export const createInstanceSnapshot = async (
page: Page,
instance: string,
snapshot: string,
@@ -29,7 +29,7 @@ export const createSnapshot = async (
await page.waitForSelector(`text=Snapshot ${snapshot} created.`, TIMEOUT);
};
-export const restoreSnapshot = async (page: Page, snapshot: string) => {
+export const restoreInstanceSnapshot = async (page: Page, snapshot: string) => {
await page
.getByRole("row", { name: "Name" })
.filter({ hasText: snapshot })
@@ -47,7 +47,7 @@ export const restoreSnapshot = async (page: Page, snapshot: string) => {
await page.waitForSelector(`text=Snapshot ${snapshot} restored.`, TIMEOUT);
};
-export const editSnapshot = async (
+export const editInstanceSnapshot = async (
page: Page,
oldName: string,
newName: string,
@@ -72,7 +72,7 @@ export const editSnapshot = async (
await page.getByText("Apr 28, 2093, 12:23 PM").click();
};
-export const deleteSnapshot = async (page: Page, snapshot: string) => {
+export const deleteInstanceSnapshot = async (page: Page, snapshot: string) => {
await page
.getByRole("row", { name: "Name" })
.filter({ hasText: snapshot })
@@ -89,3 +89,81 @@ export const deleteSnapshot = async (page: Page, snapshot: string) => {
await page.waitForSelector(`text=Snapshot ${snapshot} deleted.`, TIMEOUT);
};
+
+export const createStorageVolumeSnapshot = async (
+ page: Page,
+ volume: string,
+ snapshot: string,
+) => {
+ // Create snapshot
+ await page.getByRole("link", { name: volume }).click();
+ await page.getByTestId("tab-link-Snapshots").click();
+ await page.getByRole("button", { name: "Create snapshot" }).click();
+ await page.getByLabel("Snapshot name").click();
+ await page.getByLabel("Snapshot name").fill(snapshot);
+ await page.getByRole("button", { name: "Create", exact: true }).click();
+ await page.waitForSelector(`text=Snapshot ${snapshot} created.`, TIMEOUT);
+};
+
+export const restoreStorageVolumeSnapshot = async (
+ page: Page,
+ snapshot: string,
+) => {
+ await page
+ .getByRole("row", { name: "Name" })
+ .filter({ hasText: snapshot })
+ .hover();
+ await page.getByRole("button", { name: "Restore" }).click();
+ await page
+ .getByRole("dialog", { name: "Confirm restore" })
+ .getByRole("button", { name: "Restore" })
+ .click();
+ await page.waitForSelector(`text=Snapshot ${snapshot} restored`, TIMEOUT);
+};
+
+export const editStorageVolumeSnapshot = async (
+ page: Page,
+ oldName: string,
+ newName: string,
+) => {
+ await page
+ .getByRole("row", { name: "Name" })
+ .filter({ hasText: oldName })
+ .hover();
+ await page
+ .getByRole("row", { name: "Name" })
+ .filter({ hasText: oldName })
+ .getByRole("button", { name: "Edit snapshot" })
+ .click();
+ await page.getByLabel("Snapshot name").click();
+ await page.getByLabel("Snapshot name").fill(newName);
+ await page.getByLabel("Expiry date").click();
+ await page.getByLabel("Expiry date").fill("2093-04-28");
+ await page.getByLabel("Expiry time").click();
+ await page.getByLabel("Expiry time").fill("12:23");
+ await page.getByRole("button", { name: "Save" }).click();
+ await page.getByText(`Snapshot ${newName} saved.`).click();
+ await page.getByText("Apr 28, 2093, 12:23 PM").click();
+ await page.waitForSelector(`text=Snapshot ${newName} saved.`, TIMEOUT);
+};
+
+export const deleteStorageVolumeSnapshot = async (
+ page: Page,
+ snapshot: string,
+) => {
+ await page
+ .getByRole("row", { name: "Name" })
+ .filter({ hasText: snapshot })
+ .hover();
+ await page
+ .getByRole("row", { name: "Name" })
+ .filter({ hasText: snapshot })
+ .getByRole("button", { name: "Delete" })
+ .click();
+ await page
+ .getByRole("dialog", { name: "Confirm delete" })
+ .getByRole("button", { name: "Delete" })
+ .click();
+
+ await page.waitForSelector(`text=Snapshot ${snapshot} deleted`, TIMEOUT);
+};
diff --git a/tests/snapshots.spec.ts b/tests/snapshots.spec.ts
index 186bc9cd14..b016903444 100644
--- a/tests/snapshots.spec.ts
+++ b/tests/snapshots.spec.ts
@@ -5,25 +5,52 @@ import {
randomInstanceName,
} from "./helpers/instances";
import {
- createSnapshot,
- deleteSnapshot,
- editSnapshot,
+ createInstanceSnapshot,
+ deleteInstanceSnapshot,
+ editInstanceSnapshot,
randomSnapshotName,
- restoreSnapshot,
+ restoreInstanceSnapshot,
+ createStorageVolumeSnapshot,
+ restoreStorageVolumeSnapshot,
+ editStorageVolumeSnapshot,
+ deleteStorageVolumeSnapshot,
} from "./helpers/snapshots";
+import {
+ createVolume,
+ deleteVolume,
+ randomVolumeName,
+} from "./helpers/storageVolume";
-test("snapshot create, restore, edit and remove", async ({ page }) => {
+test("instance snapshot create, restore, edit and remove", async ({ page }) => {
const instance = randomInstanceName();
await createInstance(page, instance);
const snapshot = randomSnapshotName();
- await createSnapshot(page, instance, snapshot);
- await restoreSnapshot(page, snapshot);
+ await createInstanceSnapshot(page, instance, snapshot);
+ await restoreInstanceSnapshot(page, snapshot);
const newName = `${snapshot}-rename`;
- await editSnapshot(page, snapshot, newName);
+ await editInstanceSnapshot(page, snapshot, newName);
- await deleteSnapshot(page, newName);
+ await deleteInstanceSnapshot(page, newName);
await deleteInstance(page, instance);
});
+
+test("custom storage volume snapshot create, restore, edit and remove", async ({
+ page,
+}) => {
+ const volume = randomVolumeName();
+ await createVolume(page, volume);
+
+ const snapshot = randomSnapshotName();
+ await createStorageVolumeSnapshot(page, volume, snapshot);
+ await restoreStorageVolumeSnapshot(page, snapshot);
+
+ const newName = `${snapshot}-rename`;
+ await editStorageVolumeSnapshot(page, snapshot, newName);
+
+ await deleteStorageVolumeSnapshot(page, newName);
+
+ await deleteVolume(page, volume);
+});
diff --git a/tests/storage.spec.ts b/tests/storage.spec.ts
index 5358a4fcd7..aff07ac15e 100644
--- a/tests/storage.spec.ts
+++ b/tests/storage.spec.ts
@@ -12,7 +12,11 @@ import {
editVolume,
randomVolumeName,
saveVolume,
+ visitVolume,
} from "./helpers/storageVolume";
+import { activateOverride, setInput } from "./helpers/configuration";
+import { TIMEOUT } from "./helpers/constants";
+import { randomSnapshotName } from "./helpers/snapshots";
test("storage pool create, edit and remove", async ({ page }) => {
const pool = randomPoolName();
@@ -42,3 +46,48 @@ test("storage volume create, edit and remove", async ({ page }) => {
await deleteVolume(page, volume);
});
+
+test("storage volume edit snapshot configuration", async ({ page }) => {
+ const volume = randomVolumeName();
+ await createVolume(page, volume);
+ await visitVolume(page, volume);
+ await page.getByTestId("tab-link-Snapshots").click();
+ await page.getByText("See configuration").click();
+ await page.getByText("Edit configuration").click();
+
+ await setInput(
+ page,
+ "Snapshot name pattern",
+ "Enter name pattern",
+ "snap123",
+ );
+ await setInput(page, "Expire after", "Enter expiry expression", "3m");
+ await activateOverride(page, "Schedule");
+ await page.getByPlaceholder("Enter cron expression").last().fill("@daily");
+ await page.getByRole("button", { name: "Save" }).click();
+ await page.waitForSelector(`text=Configuration updated.`, TIMEOUT);
+
+ await deleteVolume(page, volume);
+});
+
+test("custom storage volume add snapshot from CTA", async ({ page }) => {
+ const volume = randomVolumeName();
+ await createVolume(page, volume);
+ await page
+ .getByRole("row", { name: "Name" })
+ .filter({ hasText: volume })
+ .hover();
+ await page
+ .getByRole("row", { name: "Name" })
+ .filter({ hasText: volume })
+ .getByRole("button", { name: "Add Snapshot" })
+ .click();
+
+ const snapshot = randomSnapshotName();
+ await page.getByLabel("Snapshot name").click();
+ await page.getByLabel("Snapshot name").fill(snapshot);
+ await page.getByRole("button", { name: "Create", exact: true }).click();
+ await page.waitForSelector(`text=Snapshot ${snapshot} created.`, TIMEOUT);
+
+ await deleteVolume(page, volume);
+});