| import { nodeLabelFull, useEnv, useMessages, useProjectId, useStateStore } from "@/lib/state"; |
| import { Button } from "./ui/button"; |
| import { useCallback, useEffect, useState } from "react"; |
| import { generateDodoConfig, AppNode } from "config"; |
| import { useNodes, useReactFlow } from "@xyflow/react"; |
| import { useToast } from "@/hooks/use-toast"; |
| import { |
| DropdownMenuGroup, |
| DropdownMenuItem, |
| DropdownMenu, |
| DropdownMenuContent, |
| DropdownMenuTrigger, |
| } from "./ui/dropdown-menu"; |
| import { Ellipsis, LoaderCircle } from "lucide-react"; |
| import { ImportModal } from "./import-modal"; |
| |
| function toNodeType(t: string): string { |
| if (t === "ingress") { |
| return "gateway-https"; |
| } else if (t === "service") { |
| return "app"; |
| } else { |
| return t; |
| } |
| } |
| |
| export function Actions() { |
| const { toast } = useToast(); |
| const store = useStateStore(); |
| const projectId = useProjectId(); |
| const nodes = useNodes<AppNode>(); |
| const env = useEnv(); |
| const messages = useMessages(); |
| const instance = useReactFlow(); |
| const [ok, setOk] = useState(false); |
| const [loading, setLoading] = useState(false); |
| const [reloading, setReloading] = useState(false); |
| const [showImportModal, setShowImportModal] = 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]); |
| const monitor = useCallback(async () => { |
| const m = async function () { |
| const resp = await fetch(`/api/project/${projectId}/status`, { |
| method: "GET", |
| headers: { |
| "Content-Type": "application/json", |
| }, |
| }); |
| if (resp.status !== 200) { |
| return; |
| } |
| const data: { type: string; name: string; status: string }[] = await resp.json(); |
| for (const n of nodes) { |
| if (n.type === "network") { |
| continue; |
| } |
| const d = data.find((d) => n.type === toNodeType(d.type) && nodeLabelFull(n) === d.name); |
| if (d !== undefined) { |
| store.updateNodeData(n.id, { |
| state: d?.status, |
| }); |
| } |
| } |
| if (data.find((d) => d.status !== "success" && d.status != "failure") !== undefined) { |
| setTimeout(m, 1000); |
| } |
| }; |
| setTimeout(m, 100); |
| }, [projectId, nodes, store]); |
| const deploy = useCallback(async () => { |
| if (projectId == null) { |
| return; |
| } |
| setLoading(true); |
| try { |
| const config = generateDodoConfig(projectId, nodes, env); |
| if (config == null) { |
| throw new Error("MUST NOT REACH!"); |
| } |
| const resp = await fetch(`/api/project/${projectId}/deploy`, { |
| method: "POST", |
| headers: { |
| "Content-Type": "application/json", |
| }, |
| body: JSON.stringify({ |
| state: instance.toObject(), |
| config, |
| }), |
| }); |
| if (resp.ok) { |
| store.setMode("deploy"); |
| info("Deployment succeeded"); |
| monitor(); |
| } else { |
| error("Deployment failed"); |
| } |
| store.refreshEnv(); |
| } catch { |
| error("Deployment failed"); |
| } finally { |
| setLoading(false); |
| } |
| }, [projectId, instance, nodes, env, setLoading, info, error, monitor, store]); |
| const save = useCallback(async () => { |
| if (projectId == null) { |
| return; |
| } |
| const resp = await fetch(`/api/project/${projectId}/saved`, { |
| method: "POST", |
| headers: { |
| "Content-Type": "application/json", |
| }, |
| body: JSON.stringify(instance.toObject()), |
| }); |
| if (resp.ok) { |
| info("Save succeeded"); |
| } else { |
| error("Save failed", await resp.text()); |
| } |
| }, [projectId, instance, info, error]); |
| const restoreSaved = useCallback(async () => { |
| if (projectId == null) { |
| return; |
| } |
| const resp = await fetch(`/api/project/${projectId}/saved/${store.mode === "deploy" ? "deploy" : "draft"}`, { |
| method: "GET", |
| }); |
| const inst = await resp.json(); |
| const { x = 0, y = 0, zoom = 1 } = inst.state.viewport; |
| store.setNodes(inst.state.nodes || []); |
| store.setEdges(inst.state.edges || []); |
| instance.setViewport({ x, y, zoom }); |
| }, [projectId, instance, store]); |
| const clear = useCallback(() => { |
| store.setEdges([]); |
| store.setNodes(store.nodes.filter((n) => n.type === "network")); |
| }, [store]); |
| const edit = useCallback(async () => { |
| store.setMode("edit"); |
| }, [store]); |
| // TODO(gio): refresh projects |
| const deleteProject = useCallback(async () => { |
| if (projectId == null) { |
| return; |
| } |
| if (!confirm("Are you sure you want to delete this project? This action cannot be undone.")) { |
| return; |
| } |
| const resp = await fetch(`/api/project/${projectId}`, { |
| method: "DELETE", |
| headers: { |
| "Content-Type": "application/json", |
| }, |
| body: JSON.stringify({ state: JSON.stringify(instance.toObject()) }), |
| }); |
| if (resp.ok) { |
| clear(); |
| store.setProject(undefined); |
| info("Project deleted"); |
| } else { |
| error("Failed to delete project", await resp.text()); |
| } |
| }, [store, clear, projectId, info, error, instance]); |
| 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", |
| headers: { |
| "Content-Type": "application/json", |
| }, |
| }); |
| if (resp.ok) { |
| dismiss(); |
| info("Reloaded services successfully"); |
| } else { |
| dismiss(); |
| error("Reload failed", await resp.text()); |
| } |
| } catch (e) { |
| dismiss(); |
| error("Reload failed", e instanceof Error ? e.message : undefined); |
| } finally { |
| setReloading(false); |
| } |
| }, [projectId, info, error]); |
| const removeDeployment = useCallback(async () => { |
| if (projectId == null) { |
| return; |
| } |
| if (!confirm("Are you sure you want to remove this deployment? This action cannot be undone.")) { |
| return; |
| } |
| setReloading(true); |
| try { |
| const resp = await fetch(`/api/project/${projectId}/remove-deployment`, { |
| method: "POST", |
| headers: { |
| "Content-Type": "application/json", |
| }, |
| }); |
| if (resp.ok) { |
| info("Deployment removed successfully"); |
| store.setMode("edit"); |
| } else { |
| const errorData = await resp.json(); |
| error("Failed to remove deployment", errorData.error || "Unknown error"); |
| } |
| } catch (e) { |
| error("Failed to remove deployment", e instanceof Error ? e.message : undefined); |
| } finally { |
| store.refreshEnv(); |
| setReloading(false); |
| } |
| }, [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, disabled: true }); |
| } else if (ok) { |
| setDeployProps({ disabled: false }); |
| } else { |
| setDeployProps({ disabled: true }); |
| } |
| |
| if (reloading) { |
| setReloadProps({ loading: true, disabled: true }); |
| } else { |
| setReloadProps({ disabled: projectId === undefined }); |
| } |
| }, [ok, loading, reloading, projectId]); |
| if (store.mode === "deploy") { |
| return ( |
| <div className="flex flex-row gap-1 items-center"> |
| <Button onClick={edit} {...reloadProps}> |
| Edit |
| </Button> |
| <DropdownMenu> |
| <DropdownMenuTrigger> |
| <Button size="icon"> |
| <Ellipsis /> |
| </Button> |
| </DropdownMenuTrigger> |
| <DropdownMenuContent className="w-56"> |
| <DropdownMenuGroup> |
| <DropdownMenuItem |
| onClick={reload} |
| className="cursor-pointer hover:bg-gray-200" |
| {...reloadProps} |
| > |
| {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 |
| </DropdownMenuItem> |
| <DropdownMenuItem |
| onClick={deleteProject} |
| disabled={projectId === undefined} |
| className="cursor-pointer hover:bg-gray-200" |
| > |
| Delete project |
| </DropdownMenuItem> |
| </DropdownMenuGroup> |
| </DropdownMenuContent> |
| </DropdownMenu> |
| </div> |
| ); |
| } else { |
| return ( |
| <> |
| <div className="flex flex-row gap-1 items-center"> |
| <Button onClick={deploy} {...deployProps}> |
| {deployProps.loading ? ( |
| <> |
| <LoaderCircle className="animate-spin" /> |
| Deploying... |
| </> |
| ) : ( |
| "Deploy" |
| )} |
| </Button> |
| <Button onClick={save}>Save</Button> |
| <Button onClick={() => setShowImportModal(true)}>Import</Button> |
| <DropdownMenu> |
| <DropdownMenuTrigger> |
| <Button size="icon"> |
| <Ellipsis /> |
| </Button> |
| </DropdownMenuTrigger> |
| <DropdownMenuContent className="w-56"> |
| <DropdownMenuGroup> |
| <DropdownMenuItem |
| onClick={restoreSaved} |
| disabled={projectId === undefined} |
| className="cursor-pointer hover:bg-gray-200" |
| > |
| Restore |
| </DropdownMenuItem> |
| <DropdownMenuItem onClick={clear} className="cursor-pointer hover:bg-gray-200"> |
| Clear |
| </DropdownMenuItem> |
| <DropdownMenuItem |
| onClick={deleteProject} |
| disabled={projectId === undefined} |
| className="cursor-pointer hover:bg-gray-200" |
| > |
| Delete project |
| </DropdownMenuItem> |
| </DropdownMenuGroup> |
| </DropdownMenuContent> |
| </DropdownMenu> |
| </div> |
| <ImportModal open={showImportModal} onOpenChange={setShowImportModal} /> |
| </> |
| ); |
| } |
| } |