blob: 3047fdfd3f5924a75a81351319f0c3c0d041483c [file] [log] [blame]
gioc31bf142025-06-16 07:48:20 +00001import { 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";
gioc31bf142025-06-16 07:48:20 +00004import { generateDodoConfig, AppNode } from "config";
gio5f2f1002025-03-20 18:38:48 +04005import { 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";
gio1037ee22025-06-26 09:25:43 +000014import { Ellipsis, LoaderCircle, Plus } from "lucide-react";
gio8e74dc02025-06-13 10:19:26 +000015import { ImportModal } from "./import-modal";
gio1037ee22025-06-26 09:25:43 +000016import { Dialog, DialogContent, DialogHeader, DialogTitle } from "./ui/dialog";
17import { Resources } from "./resources";
gio5f2f1002025-03-20 18:38:48 +040018
gioda708652025-04-30 14:57:38 +040019function toNodeType(t: string): string {
giod0026612025-05-08 13:00:36 +000020 if (t === "ingress") {
21 return "gateway-https";
22 } else if (t === "service") {
23 return "app";
24 } else {
25 return t;
26 }
gioda708652025-04-30 14:57:38 +040027}
28
gio678746b2025-07-06 14:45:27 +000029export function Actions() {
giod0026612025-05-08 13:00:36 +000030 const { toast } = useToast();
31 const store = useStateStore();
32 const projectId = useProjectId();
33 const nodes = useNodes<AppNode>();
34 const env = useEnv();
35 const messages = useMessages();
36 const instance = useReactFlow();
37 const [ok, setOk] = useState(false);
38 const [loading, setLoading] = useState(false);
gio7d813702025-05-08 18:29:52 +000039 const [reloading, setReloading] = useState(false);
gio8e74dc02025-06-13 10:19:26 +000040 const [showImportModal, setShowImportModal] = useState(false);
gio1037ee22025-06-26 09:25:43 +000041 const [showResourcesModal, setShowResourcesModal] = useState(false);
gioe2b955a2025-05-15 15:41:05 +000042 const info = useCallback(
43 (title: string, description?: string, duration?: number) => {
44 return toast({
45 title,
46 description,
47 duration: duration ?? 2000,
48 });
49 },
50 [toast],
51 );
52 const error = useCallback(
53 (title: string, description?: string, duration?: number) => {
54 return toast({
55 variant: "destructive",
56 title,
57 description,
58 duration: duration ?? 5000,
59 });
60 },
61 [toast],
62 );
giod0026612025-05-08 13:00:36 +000063 useEffect(() => {
64 setOk(!messages.some((m) => m.type === "FATAL"));
65 }, [messages, setOk]);
66 const monitor = useCallback(async () => {
67 const m = async function () {
68 const resp = await fetch(`/api/project/${projectId}/status`, {
69 method: "GET",
70 headers: {
71 "Content-Type": "application/json",
72 },
73 });
74 if (resp.status !== 200) {
75 return;
76 }
77 const data: { type: string; name: string; status: string }[] = await resp.json();
giod0026612025-05-08 13:00:36 +000078 for (const n of nodes) {
79 if (n.type === "network") {
80 continue;
81 }
gio8fad76a2025-05-22 14:01:23 +000082 const d = data.find((d) => n.type === toNodeType(d.type) && nodeLabelFull(n) === d.name);
giod0026612025-05-08 13:00:36 +000083 if (d !== undefined) {
84 store.updateNodeData(n.id, {
85 state: d?.status,
86 });
87 }
88 }
89 if (data.find((d) => d.status !== "success" && d.status != "failure") !== undefined) {
90 setTimeout(m, 1000);
91 }
92 };
93 setTimeout(m, 100);
94 }, [projectId, nodes, store]);
95 const deploy = useCallback(async () => {
96 if (projectId == null) {
97 return;
98 }
99 setLoading(true);
100 try {
gio7d813702025-05-08 18:29:52 +0000101 const config = generateDodoConfig(projectId, nodes, env);
giod0026612025-05-08 13:00:36 +0000102 if (config == null) {
103 throw new Error("MUST NOT REACH!");
104 }
105 const resp = await fetch(`/api/project/${projectId}/deploy`, {
106 method: "POST",
107 headers: {
108 "Content-Type": "application/json",
109 },
110 body: JSON.stringify({
giobd37a2b2025-05-15 04:28:42 +0000111 state: instance.toObject(),
giod0026612025-05-08 13:00:36 +0000112 config,
113 }),
114 });
115 if (resp.ok) {
gioe2b955a2025-05-15 15:41:05 +0000116 store.setMode("deploy");
117 info("Deployment succeeded");
giod0026612025-05-08 13:00:36 +0000118 monitor();
119 } else {
gio8fad76a2025-05-22 14:01:23 +0000120 error("Deployment failed");
giod0026612025-05-08 13:00:36 +0000121 }
gio62237142025-05-19 10:39:40 +0000122 store.refreshEnv();
gio8fad76a2025-05-22 14:01:23 +0000123 } catch {
124 error("Deployment failed");
giod0026612025-05-08 13:00:36 +0000125 } finally {
126 setLoading(false);
127 }
gioe2b955a2025-05-15 15:41:05 +0000128 }, [projectId, instance, nodes, env, setLoading, info, error, monitor, store]);
giod0026612025-05-08 13:00:36 +0000129 const save = useCallback(async () => {
130 if (projectId == null) {
131 return;
132 }
133 const resp = await fetch(`/api/project/${projectId}/saved`, {
134 method: "POST",
135 headers: {
136 "Content-Type": "application/json",
137 },
gio10ff1342025-07-05 10:22:15 +0000138 body: JSON.stringify({
139 type: "graph",
140 graph: instance.toObject(),
141 }),
giod0026612025-05-08 13:00:36 +0000142 });
143 if (resp.ok) {
gioe2b955a2025-05-15 15:41:05 +0000144 info("Save succeeded");
giod0026612025-05-08 13:00:36 +0000145 } else {
gioe2b955a2025-05-15 15:41:05 +0000146 error("Save failed", await resp.text());
giod0026612025-05-08 13:00:36 +0000147 }
gioe2b955a2025-05-15 15:41:05 +0000148 }, [projectId, instance, info, error]);
giod0026612025-05-08 13:00:36 +0000149 const restoreSaved = useCallback(async () => {
150 if (projectId == null) {
151 return;
152 }
gio818da4e2025-05-12 14:45:35 +0000153 const resp = await fetch(`/api/project/${projectId}/saved/${store.mode === "deploy" ? "deploy" : "draft"}`, {
giod0026612025-05-08 13:00:36 +0000154 method: "GET",
155 });
156 const inst = await resp.json();
gioc31bf142025-06-16 07:48:20 +0000157 const { x = 0, y = 0, zoom = 1 } = inst.state.viewport;
158 store.setNodes(inst.state.nodes || []);
159 store.setEdges(inst.state.edges || []);
giod0026612025-05-08 13:00:36 +0000160 instance.setViewport({ x, y, zoom });
161 }, [projectId, instance, store]);
162 const clear = useCallback(() => {
163 store.setEdges([]);
gio6d8b71c2025-05-19 12:57:35 +0000164 store.setNodes(store.nodes.filter((n) => n.type === "network"));
165 }, [store]);
gio818da4e2025-05-12 14:45:35 +0000166 const edit = useCallback(async () => {
167 store.setMode("edit");
168 }, [store]);
gio74ab7852025-05-13 13:19:31 +0000169 // TODO(gio): refresh projects
giod0026612025-05-08 13:00:36 +0000170 const deleteProject = useCallback(async () => {
171 if (projectId == null) {
172 return;
173 }
gio33046722025-05-16 14:49:55 +0000174 if (!confirm("Are you sure you want to delete this project? This action cannot be undone.")) {
175 return;
176 }
giod0026612025-05-08 13:00:36 +0000177 const resp = await fetch(`/api/project/${projectId}`, {
178 method: "DELETE",
gioa71316d2025-05-24 09:41:36 +0400179 headers: {
180 "Content-Type": "application/json",
181 },
182 body: JSON.stringify({ state: JSON.stringify(instance.toObject()) }),
giod0026612025-05-08 13:00:36 +0000183 });
184 if (resp.ok) {
185 clear();
186 store.setProject(undefined);
gioe2b955a2025-05-15 15:41:05 +0000187 info("Project deleted");
giod0026612025-05-08 13:00:36 +0000188 } else {
gioe2b955a2025-05-15 15:41:05 +0000189 error("Failed to delete project", await resp.text());
giod0026612025-05-08 13:00:36 +0000190 }
gioa71316d2025-05-24 09:41:36 +0400191 }, [store, clear, projectId, info, error, instance]);
gio7d813702025-05-08 18:29:52 +0000192 const reload = useCallback(async () => {
193 if (projectId == null) {
194 return;
195 }
196 setReloading(true);
gioe2b955a2025-05-15 15:41:05 +0000197 const { dismiss } = info("Reloading services", "This may take a while...", Infinity);
gio7d813702025-05-08 18:29:52 +0000198 try {
199 const resp = await fetch(`/api/project/${projectId}/reload`, {
200 method: "POST",
201 headers: {
202 "Content-Type": "application/json",
203 },
204 });
205 if (resp.ok) {
gioe2b955a2025-05-15 15:41:05 +0000206 dismiss();
207 info("Reloaded services successfully");
gio7d813702025-05-08 18:29:52 +0000208 } else {
gioe2b955a2025-05-15 15:41:05 +0000209 dismiss();
210 error("Reload failed", await resp.text());
gio7d813702025-05-08 18:29:52 +0000211 }
212 } catch (e) {
gioe2b955a2025-05-15 15:41:05 +0000213 dismiss();
214 error("Reload failed", e instanceof Error ? e.message : undefined);
gio7d813702025-05-08 18:29:52 +0000215 } finally {
216 setReloading(false);
217 }
gioe2b955a2025-05-15 15:41:05 +0000218 }, [projectId, info, error]);
giobd37a2b2025-05-15 04:28:42 +0000219 const removeDeployment = useCallback(async () => {
220 if (projectId == null) {
221 return;
222 }
223 if (!confirm("Are you sure you want to remove this deployment? This action cannot be undone.")) {
224 return;
225 }
226 setReloading(true);
227 try {
228 const resp = await fetch(`/api/project/${projectId}/remove-deployment`, {
229 method: "POST",
230 headers: {
231 "Content-Type": "application/json",
232 },
233 });
234 if (resp.ok) {
gioe2b955a2025-05-15 15:41:05 +0000235 info("Deployment removed successfully");
giobd37a2b2025-05-15 04:28:42 +0000236 store.setMode("edit");
237 } else {
238 const errorData = await resp.json();
gioe2b955a2025-05-15 15:41:05 +0000239 error("Failed to remove deployment", errorData.error || "Unknown error");
giobd37a2b2025-05-15 04:28:42 +0000240 }
241 } catch (e) {
gioe2b955a2025-05-15 15:41:05 +0000242 error("Failed to remove deployment", e instanceof Error ? e.message : undefined);
giobd37a2b2025-05-15 04:28:42 +0000243 } finally {
giob1c5c452025-05-21 04:16:54 +0000244 store.refreshEnv();
giobd37a2b2025-05-15 04:28:42 +0000245 setReloading(false);
246 }
gioe2b955a2025-05-15 15:41:05 +0000247 }, [projectId, info, error, store]);
248 const [deployProps, setDeployProps] = useState<{ loading?: boolean; disabled?: boolean }>({
249 loading: false,
250 disabled: false,
251 });
252 const [reloadProps, setReloadProps] = useState<{ loading?: boolean; disabled?: boolean }>({
253 loading: false,
254 disabled: false,
255 });
giod0026612025-05-08 13:00:36 +0000256 useEffect(() => {
257 if (loading) {
gioe2b955a2025-05-15 15:41:05 +0000258 setDeployProps({ loading: true, disabled: true });
giod0026612025-05-08 13:00:36 +0000259 } else if (ok) {
gio7d813702025-05-08 18:29:52 +0000260 setDeployProps({ disabled: false });
giod0026612025-05-08 13:00:36 +0000261 } else {
gio7d813702025-05-08 18:29:52 +0000262 setDeployProps({ disabled: true });
giod0026612025-05-08 13:00:36 +0000263 }
gio7d813702025-05-08 18:29:52 +0000264
265 if (reloading) {
gioe2b955a2025-05-15 15:41:05 +0000266 setReloadProps({ loading: true, disabled: true });
gio7d813702025-05-08 18:29:52 +0000267 } else {
268 setReloadProps({ disabled: projectId === undefined });
269 }
270 }, [ok, loading, reloading, projectId]);
gio818da4e2025-05-12 14:45:35 +0000271 if (store.mode === "deploy") {
272 return (
273 <div className="flex flex-row gap-1 items-center">
gio678746b2025-07-06 14:45:27 +0000274 <Button
275 onClick={() => store.setBuildMode(store.buildMode === "overview" ? "canvas" : "overview")}
276 {...reloadProps}
277 >
278 {store.buildMode === "overview" ? "Canvas" : "Overview"}
279 </Button>
gio818da4e2025-05-12 14:45:35 +0000280 <Button onClick={edit} {...reloadProps}>
281 Edit
282 </Button>
283 <DropdownMenu>
284 <DropdownMenuTrigger>
gio3d0bf032025-06-05 06:57:26 +0000285 <Button size="icon">
286 <Ellipsis />
287 </Button>
gio818da4e2025-05-12 14:45:35 +0000288 </DropdownMenuTrigger>
289 <DropdownMenuContent className="w-56">
290 <DropdownMenuGroup>
291 <DropdownMenuItem
292 onClick={reload}
293 className="cursor-pointer hover:bg-gray-200"
294 {...reloadProps}
295 >
gioe2b955a2025-05-15 15:41:05 +0000296 {reloadProps.loading ? (
297 <>
298 <LoaderCircle className="animate-spin" />
299 Reloading...
300 </>
301 ) : (
302 "Reload services"
303 )}
gio818da4e2025-05-12 14:45:35 +0000304 </DropdownMenuItem>
305 <DropdownMenuItem
giobd37a2b2025-05-15 04:28:42 +0000306 onClick={removeDeployment}
307 disabled={projectId === undefined}
308 className="cursor-pointer hover:bg-gray-200"
309 >
gioe2b955a2025-05-15 15:41:05 +0000310 Remove deployment
giobd37a2b2025-05-15 04:28:42 +0000311 </DropdownMenuItem>
312 <DropdownMenuItem
gio818da4e2025-05-12 14:45:35 +0000313 onClick={deleteProject}
314 disabled={projectId === undefined}
315 className="cursor-pointer hover:bg-gray-200"
316 >
gioe2b955a2025-05-15 15:41:05 +0000317 Delete project
gio818da4e2025-05-12 14:45:35 +0000318 </DropdownMenuItem>
319 </DropdownMenuGroup>
320 </DropdownMenuContent>
321 </DropdownMenu>
322 </div>
323 );
324 } else {
325 return (
gio8e74dc02025-06-13 10:19:26 +0000326 <>
327 <div className="flex flex-row gap-1 items-center">
gio678746b2025-07-06 14:45:27 +0000328 <Button onClick={() => store.setBuildMode(store.buildMode === "overview" ? "canvas" : "overview")}>
329 {store.buildMode === "overview" ? "Canvas" : "Overview"}
330 </Button>
331 <Button onClick={() => setShowResourcesModal(true)}>
332 <Plus className="w-4 h-4 mr-1" />
333 Add
334 </Button>
gio8e74dc02025-06-13 10:19:26 +0000335 <Button onClick={deploy} {...deployProps}>
336 {deployProps.loading ? (
337 <>
338 <LoaderCircle className="animate-spin" />
339 Deploying...
340 </>
341 ) : (
342 "Deploy"
343 )}
344 </Button>
345 <Button onClick={save}>Save</Button>
346 <Button onClick={() => setShowImportModal(true)}>Import</Button>
347 <DropdownMenu>
348 <DropdownMenuTrigger>
349 <Button size="icon">
350 <Ellipsis />
351 </Button>
352 </DropdownMenuTrigger>
353 <DropdownMenuContent className="w-56">
354 <DropdownMenuGroup>
355 <DropdownMenuItem
356 onClick={restoreSaved}
357 disabled={projectId === undefined}
358 className="cursor-pointer hover:bg-gray-200"
359 >
360 Restore
361 </DropdownMenuItem>
362 <DropdownMenuItem onClick={clear} className="cursor-pointer hover:bg-gray-200">
363 Clear
364 </DropdownMenuItem>
365 <DropdownMenuItem
366 onClick={deleteProject}
367 disabled={projectId === undefined}
368 className="cursor-pointer hover:bg-gray-200"
369 >
370 Delete project
371 </DropdownMenuItem>
372 </DropdownMenuGroup>
373 </DropdownMenuContent>
374 </DropdownMenu>
375 </div>
376 <ImportModal open={showImportModal} onOpenChange={setShowImportModal} />
gio1037ee22025-06-26 09:25:43 +0000377 <Dialog open={showResourcesModal} onOpenChange={setShowResourcesModal}>
378 <DialogContent className="sm:max-w-md">
379 <DialogHeader>
380 <DialogTitle>Add Resources</DialogTitle>
381 </DialogHeader>
382 <div className="py-4">
383 <Resources onResourceAdded={() => setShowResourcesModal(false)} />
384 </div>
385 </DialogContent>
386 </Dialog>
gio8e74dc02025-06-13 10:19:26 +0000387 </>
gio818da4e2025-05-12 14:45:35 +0000388 );
389 }
giof8acc612025-04-26 08:20:55 +0400390}