blob: d4bd5ea274449f8c1e83bf28d301f90bd835a281 [file] [log] [blame]
import { AppNode, nodeLabelFull, useEnv, useMessages, useProjectId, useStateStore } from "@/lib/state";
import { Button } from "./ui/button";
import { useCallback, useEffect, useState } from "react";
import { generateDodoConfig } from "@/lib/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.viewport;
store.setNodes(inst.nodes || []);
store.setEdges(inst.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} />
</>
);
}
}