blob: dbb5ea698666f83c81d3f97d8cb2fca280c8735d [file] [log] [blame]
gio8fad76a2025-05-22 14:01:23 +00001import { AppNode, nodeLabelFull, useEnv, useMessages, useProjectId, useStateStore } from "@/lib/state";
gio5f2f1002025-03-20 18:38:48 +04002import { Button } from "./ui/button";
3import { useCallback, useEffect, useState } from "react";
4import { generateDodoConfig } from "@/lib/config";
5import { useNodes, useReactFlow } from "@xyflow/react";
6import { useToast } from "@/hooks/use-toast";
gio818da4e2025-05-12 14:45:35 +00007import {
8 DropdownMenuGroup,
9 DropdownMenuItem,
10 DropdownMenu,
11 DropdownMenuContent,
12 DropdownMenuTrigger,
13} from "./ui/dropdown-menu";
gioe2b955a2025-05-15 15:41:05 +000014import { LoaderCircle, Menu } from "lucide-react";
gio5f2f1002025-03-20 18:38:48 +040015
gioda708652025-04-30 14:57:38 +040016function toNodeType(t: string): string {
giod0026612025-05-08 13:00:36 +000017 if (t === "ingress") {
18 return "gateway-https";
19 } else if (t === "service") {
20 return "app";
21 } else {
22 return t;
23 }
gioda708652025-04-30 14:57:38 +040024}
25
gio5f2f1002025-03-20 18:38:48 +040026export function Actions() {
giod0026612025-05-08 13:00:36 +000027 const { toast } = useToast();
28 const store = useStateStore();
29 const projectId = useProjectId();
30 const nodes = useNodes<AppNode>();
31 const env = useEnv();
32 const messages = useMessages();
33 const instance = useReactFlow();
34 const [ok, setOk] = useState(false);
35 const [loading, setLoading] = useState(false);
gio7d813702025-05-08 18:29:52 +000036 const [reloading, setReloading] = useState(false);
gioe2b955a2025-05-15 15:41:05 +000037 const info = useCallback(
38 (title: string, description?: string, duration?: number) => {
39 return toast({
40 title,
41 description,
42 duration: duration ?? 2000,
43 });
44 },
45 [toast],
46 );
47 const error = useCallback(
48 (title: string, description?: string, duration?: number) => {
49 return toast({
50 variant: "destructive",
51 title,
52 description,
53 duration: duration ?? 5000,
54 });
55 },
56 [toast],
57 );
giod0026612025-05-08 13:00:36 +000058 useEffect(() => {
59 setOk(!messages.some((m) => m.type === "FATAL"));
60 }, [messages, setOk]);
61 const monitor = useCallback(async () => {
62 const m = async function () {
63 const resp = await fetch(`/api/project/${projectId}/status`, {
64 method: "GET",
65 headers: {
66 "Content-Type": "application/json",
67 },
68 });
69 if (resp.status !== 200) {
70 return;
71 }
72 const data: { type: string; name: string; status: string }[] = await resp.json();
73 console.log(data);
74 for (const n of nodes) {
75 if (n.type === "network") {
76 continue;
77 }
gio8fad76a2025-05-22 14:01:23 +000078 const d = data.find((d) => n.type === toNodeType(d.type) && nodeLabelFull(n) === d.name);
giod0026612025-05-08 13:00:36 +000079 if (d !== undefined) {
80 store.updateNodeData(n.id, {
81 state: d?.status,
82 });
83 }
84 }
85 if (data.find((d) => d.status !== "success" && d.status != "failure") !== undefined) {
86 setTimeout(m, 1000);
87 }
88 };
89 setTimeout(m, 100);
90 }, [projectId, nodes, store]);
91 const deploy = useCallback(async () => {
92 if (projectId == null) {
93 return;
94 }
95 setLoading(true);
96 try {
gio7d813702025-05-08 18:29:52 +000097 const config = generateDodoConfig(projectId, nodes, env);
giod0026612025-05-08 13:00:36 +000098 if (config == null) {
99 throw new Error("MUST NOT REACH!");
100 }
101 const resp = await fetch(`/api/project/${projectId}/deploy`, {
102 method: "POST",
103 headers: {
104 "Content-Type": "application/json",
105 },
106 body: JSON.stringify({
giobd37a2b2025-05-15 04:28:42 +0000107 state: instance.toObject(),
giod0026612025-05-08 13:00:36 +0000108 config,
109 }),
110 });
111 if (resp.ok) {
gioe2b955a2025-05-15 15:41:05 +0000112 store.setMode("deploy");
113 info("Deployment succeeded");
giod0026612025-05-08 13:00:36 +0000114 monitor();
115 } else {
gio8fad76a2025-05-22 14:01:23 +0000116 error("Deployment failed");
giod0026612025-05-08 13:00:36 +0000117 }
gio62237142025-05-19 10:39:40 +0000118 store.refreshEnv();
gio8fad76a2025-05-22 14:01:23 +0000119 } catch {
120 error("Deployment failed");
giod0026612025-05-08 13:00:36 +0000121 } finally {
122 setLoading(false);
123 }
gioe2b955a2025-05-15 15:41:05 +0000124 }, [projectId, instance, nodes, env, setLoading, info, error, monitor, store]);
giod0026612025-05-08 13:00:36 +0000125 const save = useCallback(async () => {
126 if (projectId == null) {
127 return;
128 }
129 const resp = await fetch(`/api/project/${projectId}/saved`, {
130 method: "POST",
131 headers: {
132 "Content-Type": "application/json",
133 },
134 body: JSON.stringify(instance.toObject()),
135 });
136 if (resp.ok) {
gioe2b955a2025-05-15 15:41:05 +0000137 info("Save succeeded");
giod0026612025-05-08 13:00:36 +0000138 } else {
gioe2b955a2025-05-15 15:41:05 +0000139 error("Save failed", await resp.text());
giod0026612025-05-08 13:00:36 +0000140 }
gioe2b955a2025-05-15 15:41:05 +0000141 }, [projectId, instance, info, error]);
giod0026612025-05-08 13:00:36 +0000142 const restoreSaved = useCallback(async () => {
143 if (projectId == null) {
144 return;
145 }
gio818da4e2025-05-12 14:45:35 +0000146 const resp = await fetch(`/api/project/${projectId}/saved/${store.mode === "deploy" ? "deploy" : "draft"}`, {
giod0026612025-05-08 13:00:36 +0000147 method: "GET",
148 });
149 const inst = await resp.json();
150 const { x = 0, y = 0, zoom = 1 } = inst.viewport;
151 store.setNodes(inst.nodes || []);
152 store.setEdges(inst.edges || []);
153 instance.setViewport({ x, y, zoom });
154 }, [projectId, instance, store]);
155 const clear = useCallback(() => {
156 store.setEdges([]);
gio6d8b71c2025-05-19 12:57:35 +0000157 store.setNodes(store.nodes.filter((n) => n.type === "network"));
158 }, [store]);
gio818da4e2025-05-12 14:45:35 +0000159 const edit = useCallback(async () => {
160 store.setMode("edit");
161 }, [store]);
gio74ab7852025-05-13 13:19:31 +0000162 // TODO(gio): refresh projects
giod0026612025-05-08 13:00:36 +0000163 const deleteProject = useCallback(async () => {
164 if (projectId == null) {
165 return;
166 }
gio33046722025-05-16 14:49:55 +0000167 if (!confirm("Are you sure you want to delete this project? This action cannot be undone.")) {
168 return;
169 }
giod0026612025-05-08 13:00:36 +0000170 const resp = await fetch(`/api/project/${projectId}`, {
171 method: "DELETE",
gioa71316d2025-05-24 09:41:36 +0400172 headers: {
173 "Content-Type": "application/json",
174 },
175 body: JSON.stringify({ state: JSON.stringify(instance.toObject()) }),
giod0026612025-05-08 13:00:36 +0000176 });
177 if (resp.ok) {
178 clear();
179 store.setProject(undefined);
gioe2b955a2025-05-15 15:41:05 +0000180 info("Project deleted");
giod0026612025-05-08 13:00:36 +0000181 } else {
gioe2b955a2025-05-15 15:41:05 +0000182 error("Failed to delete project", await resp.text());
giod0026612025-05-08 13:00:36 +0000183 }
gioa71316d2025-05-24 09:41:36 +0400184 }, [store, clear, projectId, info, error, instance]);
gio7d813702025-05-08 18:29:52 +0000185 const reload = useCallback(async () => {
186 if (projectId == null) {
187 return;
188 }
189 setReloading(true);
gioe2b955a2025-05-15 15:41:05 +0000190 const { dismiss } = info("Reloading services", "This may take a while...", Infinity);
gio7d813702025-05-08 18:29:52 +0000191 try {
192 const resp = await fetch(`/api/project/${projectId}/reload`, {
193 method: "POST",
194 headers: {
195 "Content-Type": "application/json",
196 },
197 });
198 if (resp.ok) {
gioe2b955a2025-05-15 15:41:05 +0000199 dismiss();
200 info("Reloaded services successfully");
gio7d813702025-05-08 18:29:52 +0000201 } else {
gioe2b955a2025-05-15 15:41:05 +0000202 dismiss();
203 error("Reload failed", await resp.text());
gio7d813702025-05-08 18:29:52 +0000204 }
205 } catch (e) {
gioe2b955a2025-05-15 15:41:05 +0000206 dismiss();
207 error("Reload failed", e instanceof Error ? e.message : undefined);
gio7d813702025-05-08 18:29:52 +0000208 } finally {
209 setReloading(false);
210 }
gioe2b955a2025-05-15 15:41:05 +0000211 }, [projectId, info, error]);
giobd37a2b2025-05-15 04:28:42 +0000212 const removeDeployment = useCallback(async () => {
213 if (projectId == null) {
214 return;
215 }
216 if (!confirm("Are you sure you want to remove this deployment? This action cannot be undone.")) {
217 return;
218 }
219 setReloading(true);
220 try {
221 const resp = await fetch(`/api/project/${projectId}/remove-deployment`, {
222 method: "POST",
223 headers: {
224 "Content-Type": "application/json",
225 },
226 });
227 if (resp.ok) {
gioe2b955a2025-05-15 15:41:05 +0000228 info("Deployment removed successfully");
giobd37a2b2025-05-15 04:28:42 +0000229 store.setMode("edit");
230 } else {
231 const errorData = await resp.json();
gioe2b955a2025-05-15 15:41:05 +0000232 error("Failed to remove deployment", errorData.error || "Unknown error");
giobd37a2b2025-05-15 04:28:42 +0000233 }
234 } catch (e) {
gioe2b955a2025-05-15 15:41:05 +0000235 error("Failed to remove deployment", e instanceof Error ? e.message : undefined);
giobd37a2b2025-05-15 04:28:42 +0000236 } finally {
giob1c5c452025-05-21 04:16:54 +0000237 store.refreshEnv();
giobd37a2b2025-05-15 04:28:42 +0000238 setReloading(false);
239 }
gioe2b955a2025-05-15 15:41:05 +0000240 }, [projectId, info, error, store]);
241 const [deployProps, setDeployProps] = useState<{ loading?: boolean; disabled?: boolean }>({
242 loading: false,
243 disabled: false,
244 });
245 const [reloadProps, setReloadProps] = useState<{ loading?: boolean; disabled?: boolean }>({
246 loading: false,
247 disabled: false,
248 });
giod0026612025-05-08 13:00:36 +0000249 useEffect(() => {
250 if (loading) {
gioe2b955a2025-05-15 15:41:05 +0000251 setDeployProps({ loading: true, disabled: true });
giod0026612025-05-08 13:00:36 +0000252 } else if (ok) {
gio7d813702025-05-08 18:29:52 +0000253 setDeployProps({ disabled: false });
giod0026612025-05-08 13:00:36 +0000254 } else {
gio7d813702025-05-08 18:29:52 +0000255 setDeployProps({ disabled: true });
giod0026612025-05-08 13:00:36 +0000256 }
gio7d813702025-05-08 18:29:52 +0000257
258 if (reloading) {
gioe2b955a2025-05-15 15:41:05 +0000259 setReloadProps({ loading: true, disabled: true });
gio7d813702025-05-08 18:29:52 +0000260 } else {
261 setReloadProps({ disabled: projectId === undefined });
262 }
263 }, [ok, loading, reloading, projectId]);
gio818da4e2025-05-12 14:45:35 +0000264 if (store.mode === "deploy") {
265 return (
266 <div className="flex flex-row gap-1 items-center">
267 <Button onClick={edit} {...reloadProps}>
268 Edit
269 </Button>
270 <DropdownMenu>
271 <DropdownMenuTrigger>
272 <Menu className="rounded-md bg-gray-200 opacity-50" />
273 </DropdownMenuTrigger>
274 <DropdownMenuContent className="w-56">
275 <DropdownMenuGroup>
276 <DropdownMenuItem
277 onClick={reload}
278 className="cursor-pointer hover:bg-gray-200"
279 {...reloadProps}
280 >
gioe2b955a2025-05-15 15:41:05 +0000281 {reloadProps.loading ? (
282 <>
283 <LoaderCircle className="animate-spin" />
284 Reloading...
285 </>
286 ) : (
287 "Reload services"
288 )}
gio818da4e2025-05-12 14:45:35 +0000289 </DropdownMenuItem>
290 <DropdownMenuItem
giobd37a2b2025-05-15 04:28:42 +0000291 onClick={removeDeployment}
292 disabled={projectId === undefined}
293 className="cursor-pointer hover:bg-gray-200"
294 >
gioe2b955a2025-05-15 15:41:05 +0000295 Remove deployment
giobd37a2b2025-05-15 04:28:42 +0000296 </DropdownMenuItem>
297 <DropdownMenuItem
gio818da4e2025-05-12 14:45:35 +0000298 onClick={deleteProject}
299 disabled={projectId === undefined}
300 className="cursor-pointer hover:bg-gray-200"
301 >
gioe2b955a2025-05-15 15:41:05 +0000302 Delete project
gio818da4e2025-05-12 14:45:35 +0000303 </DropdownMenuItem>
304 </DropdownMenuGroup>
305 </DropdownMenuContent>
306 </DropdownMenu>
307 </div>
308 );
309 } else {
310 return (
311 <div className="flex flex-row gap-1 items-center">
312 <Button onClick={deploy} {...deployProps}>
gioe2b955a2025-05-15 15:41:05 +0000313 {deployProps.loading ? (
314 <>
315 <LoaderCircle className="animate-spin" />
316 Deploying...
317 </>
318 ) : (
319 "Deploy"
320 )}
gio818da4e2025-05-12 14:45:35 +0000321 </Button>
322 <Button onClick={save}>Save</Button>
323 <DropdownMenu>
324 <DropdownMenuTrigger>
325 <Menu />
326 </DropdownMenuTrigger>
327 <DropdownMenuContent className="w-56">
328 <DropdownMenuGroup>
329 <DropdownMenuItem
330 onClick={restoreSaved}
331 disabled={projectId === undefined}
332 className="cursor-pointer hover:bg-gray-200"
333 >
334 Restore
335 </DropdownMenuItem>
336 <DropdownMenuItem onClick={clear} className="cursor-pointer hover:bg-gray-200">
337 Clear
338 </DropdownMenuItem>
339 <DropdownMenuItem
340 onClick={deleteProject}
341 disabled={projectId === undefined}
342 className="cursor-pointer hover:bg-gray-200"
343 >
gioe2b955a2025-05-15 15:41:05 +0000344 Delete project
gio818da4e2025-05-12 14:45:35 +0000345 </DropdownMenuItem>
346 </DropdownMenuGroup>
347 </DropdownMenuContent>
348 </DropdownMenu>
349 </div>
350 );
351 }
giof8acc612025-04-26 08:20:55 +0400352}