From e1bbb19b485493e86feb044a9111080cecb29ec3 Mon Sep 17 00:00:00 2001 From: lorumic Date: Tue, 24 Oct 2023 15:29:50 +0200 Subject: [PATCH] fix(operations) use events stream for all long-running operations WD-6846 --- src/api/images.tsx | 18 ++--- src/api/instances.tsx | 47 +++---------- src/api/operations.tsx | 39 +---------- src/api/projects.tsx | 10 +-- src/api/snapshots.tsx | 64 ++++++++--------- src/api/storage-pools.tsx | 5 +- .../images/actions/DeleteCachedImageBtn.tsx | 33 +++++---- src/pages/instances/InstanceSnapshots.tsx | 4 +- .../actions/snapshots/CreateSnapshotForm.tsx | 43 +++++++----- .../actions/snapshots/EditSnapshot.tsx | 48 +++++++------ .../actions/snapshots/SnapshotActions.tsx | 68 +++++++++++-------- .../actions/snapshots/SnapshotBulkDelete.tsx | 55 +++++++++++---- .../projects/ProjectConfigurationHeader.tsx | 28 ++++---- src/pages/storage/UploadCustomIso.tsx | 32 +++++---- src/util/helpers.tsx | 27 ++++++++ 15 files changed, 271 insertions(+), 250 deletions(-) diff --git a/src/api/images.tsx b/src/api/images.tsx index 19d14179b3..a70fbebe2e 100644 --- a/src/api/images.tsx +++ b/src/api/images.tsx @@ -1,4 +1,3 @@ -import { TIMEOUT_300, watchOperation } from "./operations"; import { handleResponse } from "util/helpers"; import { ImportImage, LxdImage } from "types/image"; import { LxdApiResponse } from "types/apiResponse"; @@ -25,20 +24,23 @@ export const fetchImageList = (project: string): Promise => { }); }; -export const deleteImage = (image: LxdImage, project: string) => { +export const deleteImage = ( + image: LxdImage, + project: string, +): Promise => { return new Promise((resolve, reject) => { fetch(`/1.0/images/${image.fingerprint}?project=${project}`, { method: "DELETE", }) .then(handleResponse) - .then((data: LxdOperationResponse) => { - watchOperation(data.operation).then(resolve).catch(reject); - }) + .then(resolve) .catch(reject); }); }; -export const importImage = (remoteImage: ImportImage) => { +export const importImage = ( + remoteImage: ImportImage, +): Promise => { return new Promise((resolve, reject) => { fetch("/1.0/images", { method: "POST", @@ -54,9 +56,7 @@ export const importImage = (remoteImage: ImportImage) => { }), }) .then(handleResponse) - .then((data: LxdOperationResponse) => { - watchOperation(data.operation, TIMEOUT_300).then(resolve).catch(reject); - }) + .then(resolve) .catch(reject); }); }; diff --git a/src/api/instances.tsx b/src/api/instances.tsx index 4ab970effc..0eabed5c9c 100644 --- a/src/api/instances.tsx +++ b/src/api/instances.tsx @@ -1,7 +1,10 @@ import { + continueOrFinish, handleEtagResponse, handleResponse, handleTextResponse, + pushFailure, + pushSuccess, } from "util/helpers"; import { LxdInstance, LxdInstanceAction } from "types/instance"; import { LxdTerminal, TerminalConnectPayload } from "types/terminal"; @@ -152,7 +155,6 @@ export const updateInstanceBulkAction = ( isForce: boolean, eventQueue: EventQueue, ): Promise[]> => { - let remainingResults = actions.length; const results: PromiseSettledResult[] = []; return new Promise((resolve) => { void Promise.allSettled( @@ -161,24 +163,9 @@ export const updateInstanceBulkAction = ( (operation) => { eventQueue.set( operation.metadata.id, - () => { - results.push({ - status: "fulfilled", - value: undefined, - }); - }, - (msg) => { - results.push({ - status: "rejected", - reason: msg, - }); - }, - () => { - remainingResults--; - if (remainingResults === 0) { - resolve(results); - } - }, + () => pushSuccess(results), + (msg) => pushFailure(results, msg), + () => continueOrFinish(results, actions.length, resolve), ); }, ); @@ -204,7 +191,6 @@ export const deleteInstanceBulk = ( instances: LxdInstance[], eventQueue: EventQueue, ): Promise[]> => { - let remainingResults = instances.length; const results: PromiseSettledResult[] = []; return new Promise((resolve) => { void Promise.allSettled( @@ -212,24 +198,9 @@ export const deleteInstanceBulk = ( return await deleteInstance(instance).then((operation) => { eventQueue.set( operation.metadata.id, - () => { - results.push({ - status: "fulfilled", - value: undefined, - }); - }, - (msg) => { - results.push({ - status: "rejected", - reason: msg, - }); - }, - () => { - remainingResults--; - if (remainingResults === 0) { - resolve(results); - } - }, + () => pushSuccess(results), + (msg) => pushFailure(results, msg), + () => continueOrFinish(results, instances.length, resolve), ); }); }), diff --git a/src/api/operations.tsx b/src/api/operations.tsx index 36029dea2a..244a137646 100644 --- a/src/api/operations.tsx +++ b/src/api/operations.tsx @@ -1,44 +1,7 @@ import { handleResponse } from "util/helpers"; -import { - LxdOperation, - LxdOperationList, - LxdOperationResponse, -} from "types/operation"; +import { LxdOperation, LxdOperationList } from "types/operation"; import { LxdApiResponse } from "types/apiResponse"; -export const TIMEOUT_300 = 300; -export const TIMEOUT_120 = 120; -export const TIMEOUT_60 = 60; -export const TIMEOUT_10 = 10; - -export const watchOperation = ( - operationUrl: string, - timeout = TIMEOUT_10, -): Promise => { - return new Promise((resolve, reject) => { - const operationParts = operationUrl.split("?"); - const baseUrl = operationParts[0]; - const queryString = operationParts.length === 1 ? "" : operationParts[1]; - fetch(`${baseUrl}/wait?timeout=${timeout}&${queryString}`) - .then(handleResponse) - .then((data: LxdOperationResponse) => { - if (data.metadata.status === "Success") { - return resolve(data); - } - if (data.metadata.status === "Running") { - throw Error( - "Timeout while waiting for the operation to succeed. Watched operation continues in the background.", - ); - } else if (data.metadata.status === "Cancelled") { - throw new Error("Cancelled"); - } else { - throw Error(data.metadata.err); - } - }) - .catch(reject); - }); -}; - const sortOperationList = (operations: LxdOperationList) => { const newestFirst = (a: LxdOperation, b: LxdOperation) => { return new Date(b.created_at).getTime() - new Date(a.created_at).getTime(); diff --git a/src/api/projects.tsx b/src/api/projects.tsx index 8b381c72a7..3f99160c1b 100644 --- a/src/api/projects.tsx +++ b/src/api/projects.tsx @@ -2,7 +2,6 @@ import { handleEtagResponse, handleResponse } from "util/helpers"; import { LxdProject } from "types/project"; import { LxdApiResponse } from "types/apiResponse"; import { LxdOperationResponse } from "types/operation"; -import { TIMEOUT_60, watchOperation } from "api/operations"; export const fetchProjects = (recursion: number): Promise => { return new Promise((resolve, reject) => { @@ -49,7 +48,10 @@ export const updateProject = (project: LxdProject) => { }); }; -export const renameProject = (oldName: string, newName: string) => { +export const renameProject = ( + oldName: string, + newName: string, +): Promise => { return new Promise((resolve, reject) => { fetch(`/1.0/projects/${oldName}`, { method: "POST", @@ -58,9 +60,7 @@ export const renameProject = (oldName: string, newName: string) => { }), }) .then(handleResponse) - .then((data: LxdOperationResponse) => { - watchOperation(data.operation, TIMEOUT_60).then(resolve).catch(reject); - }) + .then(resolve) .catch(reject); }); }; diff --git a/src/api/snapshots.tsx b/src/api/snapshots.tsx index bc64d462ea..fc30fa41f1 100644 --- a/src/api/snapshots.tsx +++ b/src/api/snapshots.tsx @@ -1,14 +1,19 @@ -import { TIMEOUT_120, TIMEOUT_60, watchOperation } from "./operations"; -import { handleResponse } from "util/helpers"; +import { + continueOrFinish, + handleResponse, + pushFailure, + pushSuccess, +} from "util/helpers"; import { LxdInstance, LxdSnapshot } from "types/instance"; import { LxdOperationResponse } from "types/operation"; +import { EventQueue } from "context/eventQueue"; export const createSnapshot = ( instance: LxdInstance, name: string, expiresAt: string | null, stateful: boolean, -) => { +): Promise => { return new Promise((resolve, reject) => { fetch( `/1.0/instances/${instance.name}/snapshots?project=${instance.project}`, @@ -22,9 +27,7 @@ export const createSnapshot = ( }, ) .then(handleResponse) - .then((data: LxdOperationResponse) => { - watchOperation(data.operation, TIMEOUT_60).then(resolve).catch(reject); - }) + .then(resolve) .catch(reject); }); }; @@ -32,7 +35,7 @@ export const createSnapshot = ( export const deleteSnapshot = ( instance: LxdInstance, snapshot: { name: string }, -) => { +): Promise => { return new Promise((resolve, reject) => { fetch( `/1.0/instances/${instance.name}/snapshots/${snapshot.name}?project=${instance.project}`, @@ -41,9 +44,7 @@ export const deleteSnapshot = ( }, ) .then(handleResponse) - .then((data: LxdOperationResponse) => { - watchOperation(data.operation, TIMEOUT_120).then(resolve).catch(reject); - }) + .then(resolve) .catch(reject); }); }; @@ -51,15 +52,22 @@ export const deleteSnapshot = ( export const deleteSnapshotBulk = ( instance: LxdInstance, snapshotNames: string[], -) => { - return new Promise((resolve, reject) => { - Promise.all( - snapshotNames.map( - async (name) => await deleteSnapshot(instance, { name }), - ), - ) - .then(resolve) - .catch(reject); + eventQueue: EventQueue, +): Promise[]> => { + const results: PromiseSettledResult[] = []; + 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), + ); + }); + }), + ); }); }; @@ -67,7 +75,7 @@ export const restoreSnapshot = ( instance: LxdInstance, snapshot: LxdSnapshot, restoreState: boolean, -) => { +): Promise => { return new Promise((resolve, reject) => { fetch(`/1.0/instances/${instance.name}?project=${instance.project}`, { method: "PUT", @@ -77,9 +85,7 @@ export const restoreSnapshot = ( }), }) .then(handleResponse) - .then((data: LxdOperationResponse) => { - watchOperation(data.operation).then(resolve).catch(reject); - }) + .then(resolve) .catch(reject); }); }; @@ -88,7 +94,7 @@ export const renameSnapshot = ( instance: LxdInstance, snapshot: LxdSnapshot, newName: string, -) => { +): Promise => { return new Promise((resolve, reject) => { fetch( `/1.0/instances/${instance.name}/snapshots/${snapshot.name}?project=${instance.project}`, @@ -100,9 +106,7 @@ export const renameSnapshot = ( }, ) .then(handleResponse) - .then((data: LxdOperationResponse) => { - watchOperation(data.operation).then(resolve).catch(reject); - }) + .then(resolve) .catch(reject); }); }; @@ -111,7 +115,7 @@ export const updateSnapshot = ( instance: LxdInstance, snapshot: LxdSnapshot, expiresAt: string, -) => { +): Promise => { return new Promise((resolve, reject) => { fetch( `/1.0/instances/${instance.name}/snapshots/${snapshot.name}?project=${instance.project}`, @@ -123,9 +127,7 @@ export const updateSnapshot = ( }, ) .then(handleResponse) - .then((data: LxdOperationResponse) => { - watchOperation(data.operation).then(resolve).catch(reject); - }) + .then(resolve) .catch(reject); }); }; diff --git a/src/api/storage-pools.tsx b/src/api/storage-pools.tsx index ac6b7e56aa..abea840505 100644 --- a/src/api/storage-pools.tsx +++ b/src/api/storage-pools.tsx @@ -8,7 +8,6 @@ import { } from "types/storage"; import { LxdApiResponse } from "types/apiResponse"; import { LxdOperationResponse } from "types/operation"; -import { TIMEOUT_300, watchOperation } from "api/operations"; import axios, { AxiosResponse } from "axios"; export const fetchStoragePool = ( @@ -199,9 +198,7 @@ export const createIsoStorageVolume = ( }, ) .then((response: AxiosResponse) => response.data) - .then((data: LxdOperationResponse) => { - watchOperation(data.operation, TIMEOUT_300).then(resolve).catch(reject); - }) + .then(resolve) .catch(reject); }); }; diff --git a/src/pages/images/actions/DeleteCachedImageBtn.tsx b/src/pages/images/actions/DeleteCachedImageBtn.tsx index c2ef43ef2f..4987ddf353 100644 --- a/src/pages/images/actions/DeleteCachedImageBtn.tsx +++ b/src/pages/images/actions/DeleteCachedImageBtn.tsx @@ -8,6 +8,7 @@ import { Icon, useNotify, } from "@canonical/react-components"; +import { useEventQueue } from "context/eventQueue"; interface Props { image: LxdImage; @@ -15,27 +16,29 @@ interface Props { } const DeleteCachedImageBtn: FC = ({ image, project }) => { + const eventQueue = useEventQueue(); const notify = useNotify(); const [isLoading, setLoading] = useState(false); const queryClient = useQueryClient(); const handleDelete = () => { setLoading(true); - deleteImage(image, project) - .then(() => { - setLoading(false); - void queryClient.invalidateQueries({ - queryKey: [queryKeys.images], - }); - void queryClient.invalidateQueries({ - queryKey: [queryKeys.projects, project], - }); - notify.success(`Image ${image.properties.description} deleted.`); - }) - .catch((e) => { - setLoading(false); - notify.failure("Image deletion failed", e); - }); + void deleteImage(image, project).then((operation) => + eventQueue.set( + operation.metadata.id, + () => { + void queryClient.invalidateQueries({ + queryKey: [queryKeys.images], + }); + void queryClient.invalidateQueries({ + queryKey: [queryKeys.projects, project], + }); + notify.success(`Image ${image.properties.description} deleted.`); + }, + (msg) => notify.failure("Image deletion failed", new Error(msg)), + () => setLoading(false), + ), + ); }; return ( diff --git a/src/pages/instances/InstanceSnapshots.tsx b/src/pages/instances/InstanceSnapshots.tsx index dbd2424f49..45e7d337d3 100644 --- a/src/pages/instances/InstanceSnapshots.tsx +++ b/src/pages/instances/InstanceSnapshots.tsx @@ -46,8 +46,8 @@ const InstanceSnapshots: FC = ({ instance }) => { setInTabNotification(success(message)); }; - const onFailure = (title: string, e: unknown) => { - setInTabNotification(failure(title, e)); + const onFailure = (title: string, e: unknown, message?: ReactNode) => { + setInTabNotification(failure(title, e, message)); }; const { project, isLoading } = useProject(); diff --git a/src/pages/instances/actions/snapshots/CreateSnapshotForm.tsx b/src/pages/instances/actions/snapshots/CreateSnapshotForm.tsx index e0babbf35c..8417e8e282 100644 --- a/src/pages/instances/actions/snapshots/CreateSnapshotForm.tsx +++ b/src/pages/instances/actions/snapshots/CreateSnapshotForm.tsx @@ -14,6 +14,7 @@ import { } from "util/snapshots"; import SnapshotForm from "./SnapshotForm"; import { useNotify } from "@canonical/react-components"; +import { useEventQueue } from "context/eventQueue"; interface Props { instance: LxdInstance; @@ -22,6 +23,7 @@ interface Props { } const CreateSnapshotForm: FC = ({ instance, close, onSuccess }) => { + const eventQueue = useEventQueue(); const notify = useNotify(); const queryClient = useQueryClient(); const controllerState = useState(null); @@ -43,22 +45,31 @@ const CreateSnapshotForm: FC = ({ instance, close, onSuccess }) => { getExpiresAt(values.expirationDate, values.expirationTime), ) : UNDEFINED_DATE; - createSnapshot(instance, values.name, expiresAt, values.stateful) - .then(() => { - void queryClient.invalidateQueries({ - predicate: (query) => query.queryKey[0] === queryKeys.instances, - }); - onSuccess( - <> - Snapshot created. - , - ); - close(); - }) - .catch((e) => { - notify.failure("Snapshot creation failed", e); - formik.setSubmitting(false); - }); + 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); + }, + ), + ); }, }); diff --git a/src/pages/instances/actions/snapshots/EditSnapshot.tsx b/src/pages/instances/actions/snapshots/EditSnapshot.tsx index c5e4559dac..4b3769af70 100644 --- a/src/pages/instances/actions/snapshots/EditSnapshot.tsx +++ b/src/pages/instances/actions/snapshots/EditSnapshot.tsx @@ -18,6 +18,7 @@ import { } from "util/snapshots"; import SnapshotForm from "./SnapshotForm"; import { useNotify } from "@canonical/react-components"; +import { useEventQueue } from "context/eventQueue"; interface Props { instance: LxdInstance; @@ -27,6 +28,7 @@ interface Props { } const EditSnapshot: FC = ({ instance, snapshot, close, onSuccess }) => { + const eventQueue = useEventQueue(); const notify = useNotify(); const queryClient = useQueryClient(); const controllerState = useState(null); @@ -49,29 +51,35 @@ const EditSnapshot: FC = ({ instance, snapshot, close, onSuccess }) => { name: newName, } as LxdSnapshot) : snapshot; - updateSnapshot(instance, targetSnapshot, expiresAt) - .then(() => { - notifyUpdateSuccess(newName ?? snapshot.name); - }) - .catch((e) => { - notify.failure("Snapshot update failed", e); - formik.setSubmitting(false); - }); + 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) => { - renameSnapshot(instance, snapshot, newName) - .then(() => { - if (expiresAt) { - update(expiresAt, newName); - } else { - notifyUpdateSuccess(newName); - } - }) - .catch((e) => { - notify.failure("Snapshot rename failed", e); - formik.setSubmitting(false); - }); + 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] = diff --git a/src/pages/instances/actions/snapshots/SnapshotActions.tsx b/src/pages/instances/actions/snapshots/SnapshotActions.tsx index 19032054c0..a308f0bdb2 100644 --- a/src/pages/instances/actions/snapshots/SnapshotActions.tsx +++ b/src/pages/instances/actions/snapshots/SnapshotActions.tsx @@ -13,6 +13,7 @@ import classnames from "classnames"; import ItemName from "components/ItemName"; import ConfirmationForce from "components/ConfirmationForce"; import EditSnapshot from "./EditSnapshot"; +import { useEventQueue } from "context/eventQueue"; interface Props { instance: LxdInstance; @@ -27,6 +28,7 @@ const SnapshotActions: FC = ({ onSuccess, onFailure, }) => { + const eventQueue = useEventQueue(); const [isModalOpen, setModalOpen] = useState(false); const [isDeleting, setDeleting] = useState(false); const [isRestoring, setRestoring] = useState(false); @@ -35,40 +37,46 @@ const SnapshotActions: FC = ({ const handleDelete = () => { setDeleting(true); - deleteSnapshot(instance, snapshot) - .then(() => - onSuccess( - <> - Snapshot deleted. - , - ), - ) - .catch((e) => onFailure("Snapshot deletion failed", e)) - .finally(() => { - setDeleting(false); - void queryClient.invalidateQueries({ - predicate: (query) => query.queryKey[0] === queryKeys.instances, - }); - }); + void deleteSnapshot(instance, snapshot).then((operation) => + eventQueue.set( + operation.metadata.id, + () => + onSuccess( + <> + Snapshot deleted. + , + ), + (msg) => onFailure("Snapshot deletion failed", new Error(msg)), + () => { + setDeleting(false); + void queryClient.invalidateQueries({ + predicate: (query) => query.queryKey[0] === queryKeys.instances, + }); + }, + ), + ); }; const handleRestore = () => { setRestoring(true); - restoreSnapshot(instance, snapshot, restoreState) - .then(() => - onSuccess( - <> - Snapshot restored. - , - ), - ) - .catch((e) => onFailure("Snapshot restore failed", e)) - .finally(() => { - setRestoring(false); - void queryClient.invalidateQueries({ - predicate: (query) => query.queryKey[0] === queryKeys.instances, - }); - }); + 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, + }); + }, + ), + ); }; return ( diff --git a/src/pages/instances/actions/snapshots/SnapshotBulkDelete.tsx b/src/pages/instances/actions/snapshots/SnapshotBulkDelete.tsx index 21309e1aee..8b04f26e2a 100644 --- a/src/pages/instances/actions/snapshots/SnapshotBulkDelete.tsx +++ b/src/pages/instances/actions/snapshots/SnapshotBulkDelete.tsx @@ -6,6 +6,8 @@ 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"; interface Props { instance: LxdInstance; @@ -13,7 +15,7 @@ interface Props { onStart: () => void; onFinish: () => void; onSuccess: (message: ReactNode) => void; - onFailure: (title: string, e: unknown) => void; + onFailure: (title: string, e: unknown, message?: ReactNode) => void; } const SnapshotBulkDelete: FC = ({ @@ -24,6 +26,7 @@ const SnapshotBulkDelete: FC = ({ onSuccess, onFailure, }) => { + const eventQueue = useEventQueue(); const [isLoading, setLoading] = useState(false); const queryClient = useQueryClient(); @@ -32,23 +35,45 @@ const SnapshotBulkDelete: FC = ({ const handleDelete = () => { setLoading(true); onStart(); - deleteSnapshotBulk(instance, snapshotNames) - .then(() => - onSuccess( - <> - {snapshotNames.length} snapshot - {snapshotNames.length > 1 && "s"} deleted. - , - ), - ) - .catch((e) => onFailure("Snapshot deletion failed.", e)) - .finally(() => { - setLoading(false); - onFinish(); + void deleteSnapshotBulk(instance, 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.instances, }); - }); + setLoading(false); + onFinish(); + }, + ); }; return ( diff --git a/src/pages/projects/ProjectConfigurationHeader.tsx b/src/pages/projects/ProjectConfigurationHeader.tsx index f7aa8f817d..c0caa56721 100644 --- a/src/pages/projects/ProjectConfigurationHeader.tsx +++ b/src/pages/projects/ProjectConfigurationHeader.tsx @@ -9,12 +9,14 @@ import { checkDuplicateName } from "util/helpers"; import DeleteProjectBtn from "./actions/DeleteProjectBtn"; import { useNotify } from "@canonical/react-components"; import HelpLink from "components/HelpLink"; +import { useEventQueue } from "context/eventQueue"; interface Props { project: LxdProject; } const ProjectConfigurationHeader: FC = ({ project }) => { + const eventQueue = useEventQueue(); const navigate = useNavigate(); const notify = useNotify(); const controllerState = useState(null); @@ -43,18 +45,20 @@ const ProjectConfigurationHeader: FC = ({ project }) => { formik.setSubmitting(false); return; } - renameProject(project.name, values.name) - .then(() => { - navigate( - `/ui/project/${values.name}/configuration`, - notify.queue(notify.success("Project renamed.")), - ); - void formik.setFieldValue("isRenaming", false); - }) - .catch((e) => { - notify.failure("Renaming failed", e); - }) - .finally(() => formik.setSubmitting(false)); + void renameProject(project.name, values.name).then((operation) => + eventQueue.set( + operation.metadata.id, + () => { + navigate( + `/ui/project/${values.name}/configuration`, + notify.queue(notify.success("Project renamed.")), + ); + void formik.setFieldValue("isRenaming", false); + }, + (msg) => notify.failure("Renaming failed", new Error(msg)), + () => formik.setSubmitting(false), + ), + ); }, }); diff --git a/src/pages/storage/UploadCustomIso.tsx b/src/pages/storage/UploadCustomIso.tsx index 7fabd829fa..50a2997ae3 100644 --- a/src/pages/storage/UploadCustomIso.tsx +++ b/src/pages/storage/UploadCustomIso.tsx @@ -11,6 +11,7 @@ import ProgressBar from "components/ProgressBar"; import { UploadState } from "types/storage"; import { humanFileSize } from "util/helpers"; import UploadCustomImageHint from "pages/storage/UploadCustomImageHint"; +import { useEventQueue } from "context/eventQueue"; interface Props { onFinish: (name: string, pool: string) => void; @@ -18,6 +19,7 @@ interface Props { } const UploadCustomIso: FC = ({ onCancel, onFinish }) => { + const eventQueue = useEventQueue(); const notify = useNotify(); const queryClient = useQueryClient(); const { project } = useProject(); @@ -69,27 +71,27 @@ const UploadCustomIso: FC = ({ onCancel, onFinish }) => { setLoading(true); const uploadController = new AbortController(); setUploadAbort(uploadController); - createIsoStorageVolume( + void createIsoStorageVolume( pool, file, name, projectName, setUploadState, uploadController, - ) - .then(() => { - onFinish(name, pool); - }) - .catch((e) => { - notify.failure("Image import failed", e); - }) - .finally(() => { - setLoading(false); - setUploadState(null); - void queryClient.invalidateQueries({ - queryKey: [queryKeys.storage, pool, queryKeys.volumes, projectName], - }); - }); + ).then((operation) => + eventQueue.set( + operation.metadata.id, + () => onFinish(name, pool), + (msg) => notify.failure("Image import failed", new Error(msg)), + () => { + setLoading(false); + setUploadState(null); + void queryClient.invalidateQueries({ + queryKey: [queryKeys.storage, pool, queryKeys.volumes, projectName], + }); + }, + ), + ); }; const changeFile = (e: React.ChangeEvent) => { diff --git a/src/util/helpers.tsx b/src/util/helpers.tsx index 551686d5ee..99ed2f44ed 100644 --- a/src/util/helpers.tsx +++ b/src/util/helpers.tsx @@ -196,5 +196,32 @@ export const getPromiseSettledCounts = ( return { fulfilledCount, rejectedCount }; }; +export const pushSuccess = (results: PromiseSettledResult[]) => { + results.push({ + status: "fulfilled", + value: undefined, + }); +}; + +export const pushFailure = ( + results: PromiseSettledResult[], + msg: string, +) => { + results.push({ + status: "rejected", + reason: msg, + }); +}; + +export const continueOrFinish = ( + results: PromiseSettledResult[], + totalLength: number, + resolve: (value: PromiseSettledResult[]) => void, +) => { + if (totalLength === results.length) { + resolve(results); + } +}; + export const logout = () => void fetch("/oidc/logout").then(() => window.location.reload());