Canvas: Wait during deploy and reload

Change-Id: I8f39a46e263c99dd342b640474de421043ff0d38
diff --git a/apps/canvas/front/src/components/actions.tsx b/apps/canvas/front/src/components/actions.tsx
index ec0d28a..c7481c1 100644
--- a/apps/canvas/front/src/components/actions.tsx
+++ b/apps/canvas/front/src/components/actions.tsx
@@ -11,7 +11,7 @@
 	DropdownMenuContent,
 	DropdownMenuTrigger,
 } from "./ui/dropdown-menu";
-import { Menu } from "lucide-react";
+import { LoaderCircle, Menu } from "lucide-react";
 
 function toNodeType(t: string): string {
 	if (t === "ingress") {
@@ -34,6 +34,27 @@
 	const [ok, setOk] = useState(false);
 	const [loading, setLoading] = useState(false);
 	const [reloading, setReloading] = useState(false);
+	const info = useCallback(
+		(title: string, description?: string, duration?: number) => {
+			return toast({
+				title,
+				description,
+				duration: duration ?? 2000,
+			});
+		},
+		[toast],
+	);
+	const error = useCallback(
+		(title: string, description?: string, duration?: number) => {
+			return toast({
+				variant: "destructive",
+				title,
+				description,
+				duration: duration ?? 5000,
+			});
+		},
+		[toast],
+	);
 	useEffect(() => {
 		setOk(!messages.some((m) => m.type === "FATAL"));
 	}, [messages, setOk]);
@@ -72,7 +93,6 @@
 			return;
 		}
 		setLoading(true);
-		store.setMode("deploy");
 		try {
 			const config = generateDodoConfig(projectId, nodes, env);
 			if (config == null) {
@@ -89,28 +109,18 @@
 				}),
 			});
 			if (resp.ok) {
-				toast({
-					title: "Deployment succeeded",
-				});
+				store.setMode("deploy");
+				info("Deployment succeeded");
 				monitor();
 			} else {
-				toast({
-					variant: "destructive",
-					title: "Deployment failed",
-					description: await resp.text(),
-				});
+				error("Deployment failed", await resp.text());
 			}
 		} catch (e) {
-			store.setMode("edit");
-			console.log(e);
-			toast({
-				variant: "destructive",
-				title: "Deployment failed",
-			});
+			error("Deployment failed", e instanceof Error ? e.message : undefined);
 		} finally {
 			setLoading(false);
 		}
-	}, [projectId, instance, nodes, env, setLoading, toast, monitor, store]);
+	}, [projectId, instance, nodes, env, setLoading, info, error, monitor, store]);
 	const save = useCallback(async () => {
 		if (projectId == null) {
 			return;
@@ -123,17 +133,11 @@
 			body: JSON.stringify(instance.toObject()),
 		});
 		if (resp.ok) {
-			toast({
-				title: "Save succeeded",
-			});
+			info("Save succeeded");
 		} else {
-			toast({
-				variant: "destructive",
-				title: "Save failed",
-				description: await resp.text(),
-			});
+			error("Save failed", await resp.text());
 		}
-	}, [projectId, instance, toast]);
+	}, [projectId, instance, info, error]);
 	const restoreSaved = useCallback(async () => {
 		if (projectId == null) {
 			return;
@@ -166,22 +170,17 @@
 		if (resp.ok) {
 			clear();
 			store.setProject(undefined);
-			toast({
-				title: "Project deleted",
-			});
+			info("Project deleted");
 		} else {
-			toast({
-				variant: "destructive",
-				title: "Failed to delete project",
-				description: await resp.text(),
-			});
+			error("Failed to delete project", await resp.text());
 		}
-	}, [store, clear, projectId, toast]);
+	}, [store, clear, projectId, info, error]);
 	const reload = useCallback(async () => {
 		if (projectId == null) {
 			return;
 		}
 		setReloading(true);
+		const { dismiss } = info("Reloading services", "This may take a while...", Infinity);
 		try {
 			const resp = await fetch(`/api/project/${projectId}/reload`, {
 				method: "POST",
@@ -190,26 +189,19 @@
 				},
 			});
 			if (resp.ok) {
-				toast({
-					title: "Reload triggered successfully",
-				});
+				dismiss();
+				info("Reloaded services successfully");
 			} else {
-				toast({
-					variant: "destructive",
-					title: "Reload failed",
-					description: await resp.text(),
-				});
+				dismiss();
+				error("Reload failed", await resp.text());
 			}
 		} catch (e) {
-			console.log(e);
-			toast({
-				variant: "destructive",
-				title: "Reload failed",
-			});
+			dismiss();
+			error("Reload failed", e instanceof Error ? e.message : undefined);
 		} finally {
 			setReloading(false);
 		}
-	}, [projectId, toast]);
+	}, [projectId, info, error]);
 	const removeDeployment = useCallback(async () => {
 		if (projectId == null) {
 			return;
@@ -226,33 +218,29 @@
 				},
 			});
 			if (resp.ok) {
-				toast({
-					title: "Deployment removed successfully",
-				});
+				info("Deployment removed successfully");
 				store.setMode("edit");
 			} else {
 				const errorData = await resp.json();
-				toast({
-					variant: "destructive",
-					title: "Failed to remove deployment",
-					description: errorData.error || "Unknown error",
-				});
+				error("Failed to remove deployment", errorData.error || "Unknown error");
 			}
 		} catch (e) {
-			console.log(e);
-			toast({
-				variant: "destructive",
-				title: "Failed to remove deployment",
-			});
+			error("Failed to remove deployment", e instanceof Error ? e.message : undefined);
 		} finally {
 			setReloading(false);
 		}
-	}, [projectId, toast, store]);
-	const [deployProps, setDeployProps] = useState({});
-	const [reloadProps, setReloadProps] = useState({});
+	}, [projectId, info, error, store]);
+	const [deployProps, setDeployProps] = useState<{ loading?: boolean; disabled?: boolean }>({
+		loading: false,
+		disabled: false,
+	});
+	const [reloadProps, setReloadProps] = useState<{ loading?: boolean; disabled?: boolean }>({
+		loading: false,
+		disabled: false,
+	});
 	useEffect(() => {
 		if (loading) {
-			setDeployProps({ loading: true });
+			setDeployProps({ loading: true, disabled: true });
 		} else if (ok) {
 			setDeployProps({ disabled: false });
 		} else {
@@ -260,7 +248,7 @@
 		}
 
 		if (reloading) {
-			setReloadProps({ loading: true });
+			setReloadProps({ loading: true, disabled: true });
 		} else {
 			setReloadProps({ disabled: projectId === undefined });
 		}
@@ -282,21 +270,28 @@
 								className="cursor-pointer hover:bg-gray-200"
 								{...reloadProps}
 							>
-								Reload Services
+								{reloadProps.loading ? (
+									<>
+										<LoaderCircle className="animate-spin" />
+										Reloading...
+									</>
+								) : (
+									"Reload services"
+								)}
 							</DropdownMenuItem>
 							<DropdownMenuItem
 								onClick={removeDeployment}
 								disabled={projectId === undefined}
 								className="cursor-pointer hover:bg-gray-200"
 							>
-								Remove Deployment
+								Remove deployment
 							</DropdownMenuItem>
 							<DropdownMenuItem
 								onClick={deleteProject}
 								disabled={projectId === undefined}
 								className="cursor-pointer hover:bg-gray-200"
 							>
-								Delete Project
+								Delete project
 							</DropdownMenuItem>
 						</DropdownMenuGroup>
 					</DropdownMenuContent>
@@ -307,7 +302,14 @@
 		return (
 			<div className="flex flex-row gap-1 items-center">
 				<Button onClick={deploy} {...deployProps}>
-					Deploy
+					{deployProps.loading ? (
+						<>
+							<LoaderCircle className="animate-spin" />
+							Deploying...
+						</>
+					) : (
+						"Deploy"
+					)}
 				</Button>
 				<Button onClick={save}>Save</Button>
 				<DropdownMenu>
@@ -331,7 +333,7 @@
 								disabled={projectId === undefined}
 								className="cursor-pointer hover:bg-gray-200"
 							>
-								Delete Project
+								Delete project
 							</DropdownMenuItem>
 						</DropdownMenuGroup>
 					</DropdownMenuContent>