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} > -
= ({ /> - {!isEdit && ( - , - ...(statefulInfoMessage - ? [ - - - , - ] - : []), - ]} - /> - )} + {additionalFormInput} ); 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 ? ( + <> + 0 && ( + item.name)} + /> + ) + } + keyword="snapshot" + /> + + snapshot.name)} + onUpdateSort={pagination.updateSort} + defaultSort="created_at" + defaultSortDirection="descending" + /> + + + ) : ( + } + title="No snapshots found" + > +

+ {snapshotsDisabled ? ( + <> + Snapshots are disabled for project{" "} + . + + ) : ( + "There are no snapshots for this volume." + )} +

+

+ + Learn more about snapshots + + +

+ + +
+ )} +
+ ); +}; + +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); +});