blob: d87f4582b087188904de834815d8ecc9638b7051 [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";
gio3d0bf032025-06-05 06:57:26 +000014import { Ellipsis, LoaderCircle } 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();
giod0026612025-05-08 13:00:36 +000073 for (const n of nodes) {
74 if (n.type === "network") {
75 continue;
76 }
gio8fad76a2025-05-22 14:01:23 +000077 const d = data.find((d) => n.type === toNodeType(d.type) && nodeLabelFull(n) === d.name);
giod0026612025-05-08 13:00:36 +000078 if (d !== undefined) {
79 store.updateNodeData(n.id, {
80 state: d?.status,
81 });
82 }
83 }
84 if (data.find((d) => d.status !== "success" && d.status != "failure") !== undefined) {
85 setTimeout(m, 1000);
86 }
87 };
88 setTimeout(m, 100);
89 }, [projectId, nodes, store]);
90 const deploy = useCallback(async () => {
91 if (projectId == null) {
92 return;
93 }
94 setLoading(true);
95 try {
gio7d813702025-05-08 18:29:52 +000096 const config = generateDodoConfig(projectId, nodes, env);
giod0026612025-05-08 13:00:36 +000097 if (config == null) {
98 throw new Error("MUST NOT REACH!");
99 }
100 const resp = await fetch(`/api/project/${projectId}/deploy`, {
101 method: "POST",
102 headers: {
103 "Content-Type": "application/json",
104 },
105 body: JSON.stringify({
giobd37a2b2025-05-15 04:28:42 +0000106 state: instance.toObject(),
giod0026612025-05-08 13:00:36 +0000107 config,
108 }),
109 });
110 if (resp.ok) {
gioe2b955a2025-05-15 15:41:05 +0000111 store.setMode("deploy");
112 info("Deployment succeeded");
giod0026612025-05-08 13:00:36 +0000113 monitor();
114 } else {
gio8fad76a2025-05-22 14:01:23 +0000115 error("Deployment failed");
giod0026612025-05-08 13:00:36 +0000116 }
gio62237142025-05-19 10:39:40 +0000117 store.refreshEnv();
gio8fad76a2025-05-22 14:01:23 +0000118 } catch {
119 error("Deployment failed");
giod0026612025-05-08 13:00:36 +0000120 } finally {
121 setLoading(false);
122 }
gioe2b955a2025-05-15 15:41:05 +0000123 }, [projectId, instance, nodes, env, setLoading, info, error, monitor, store]);
giod0026612025-05-08 13:00:36 +0000124 const save = useCallback(async () => {
125 if (projectId == null) {
126 return;
127 }
128 const resp = await fetch(`/api/project/${projectId}/saved`, {
129 method: "POST",
130 headers: {
131 "Content-Type": "application/json",
132 },
133 body: JSON.stringify(instance.toObject()),
134 });
135 if (resp.ok) {
gioe2b955a2025-05-15 15:41:05 +0000136 info("Save succeeded");
giod0026612025-05-08 13:00:36 +0000137 } else {
gioe2b955a2025-05-15 15:41:05 +0000138 error("Save failed", await resp.text());
giod0026612025-05-08 13:00:36 +0000139 }
gioe2b955a2025-05-15 15:41:05 +0000140 }, [projectId, instance, info, error]);
giod0026612025-05-08 13:00:36 +0000141 const restoreSaved = useCallback(async () => {
142 if (projectId == null) {
143 return;
144 }
gio818da4e2025-05-12 14:45:35 +0000145 const resp = await fetch(`/api/project/${projectId}/saved/${store.mode === "deploy" ? "deploy" : "draft"}`, {
giod0026612025-05-08 13:00:36 +0000146 method: "GET",
147 });
148 const inst = await resp.json();
149 const { x = 0, y = 0, zoom = 1 } = inst.viewport;
150 store.setNodes(inst.nodes || []);
151 store.setEdges(inst.edges || []);
152 instance.setViewport({ x, y, zoom });
153 }, [projectId, instance, store]);
154 const clear = useCallback(() => {
155 store.setEdges([]);
gio6d8b71c2025-05-19 12:57:35 +0000156 store.setNodes(store.nodes.filter((n) => n.type === "network"));
157 }, [store]);
gio818da4e2025-05-12 14:45:35 +0000158 const edit = useCallback(async () => {
159 store.setMode("edit");
160 }, [store]);
gio74ab7852025-05-13 13:19:31 +0000161 // TODO(gio): refresh projects
giod0026612025-05-08 13:00:36 +0000162 const deleteProject = useCallback(async () => {
163 if (projectId == null) {
164 return;
165 }
gio33046722025-05-16 14:49:55 +0000166 if (!confirm("Are you sure you want to delete this project? This action cannot be undone.")) {
167 return;
168 }
giod0026612025-05-08 13:00:36 +0000169 const resp = await fetch(`/api/project/${projectId}`, {
170 method: "DELETE",
gioa71316d2025-05-24 09:41:36 +0400171 headers: {
172 "Content-Type": "application/json",
173 },
174 body: JSON.stringify({ state: JSON.stringify(instance.toObject()) }),
giod0026612025-05-08 13:00:36 +0000175 });
176 if (resp.ok) {
177 clear();
178 store.setProject(undefined);
gioe2b955a2025-05-15 15:41:05 +0000179 info("Project deleted");
giod0026612025-05-08 13:00:36 +0000180 } else {
gioe2b955a2025-05-15 15:41:05 +0000181 error("Failed to delete project", await resp.text());
giod0026612025-05-08 13:00:36 +0000182 }
gioa71316d2025-05-24 09:41:36 +0400183 }, [store, clear, projectId, info, error, instance]);
gio7d813702025-05-08 18:29:52 +0000184 const reload = useCallback(async () => {
185 if (projectId == null) {
186 return;
187 }
188 setReloading(true);
gioe2b955a2025-05-15 15:41:05 +0000189 const { dismiss } = info("Reloading services", "This may take a while...", Infinity);
gio7d813702025-05-08 18:29:52 +0000190 try {
191 const resp = await fetch(`/api/project/${projectId}/reload`, {
192 method: "POST",
193 headers: {
194 "Content-Type": "application/json",
195 },
196 });
197 if (resp.ok) {
gioe2b955a2025-05-15 15:41:05 +0000198 dismiss();
199 info("Reloaded services successfully");
gio7d813702025-05-08 18:29:52 +0000200 } else {
gioe2b955a2025-05-15 15:41:05 +0000201 dismiss();
202 error("Reload failed", await resp.text());
gio7d813702025-05-08 18:29:52 +0000203 }
204 } catch (e) {
gioe2b955a2025-05-15 15:41:05 +0000205 dismiss();
206 error("Reload failed", e instanceof Error ? e.message : undefined);
gio7d813702025-05-08 18:29:52 +0000207 } finally {
208 setReloading(false);
209 }
gioe2b955a2025-05-15 15:41:05 +0000210 }, [projectId, info, error]);
giobd37a2b2025-05-15 04:28:42 +0000211 const removeDeployment = useCallback(async () => {
212 if (projectId == null) {
213 return;
214 }
215 if (!confirm("Are you sure you want to remove this deployment? This action cannot be undone.")) {
216 return;
217 }
218 setReloading(true);
219 try {
220 const resp = await fetch(`/api/project/${projectId}/remove-deployment`, {
221 method: "POST",
222 headers: {
223 "Content-Type": "application/json",
224 },
225 });
226 if (resp.ok) {
gioe2b955a2025-05-15 15:41:05 +0000227 info("Deployment removed successfully");
giobd37a2b2025-05-15 04:28:42 +0000228 store.setMode("edit");
229 } else {
230 const errorData = await resp.json();
gioe2b955a2025-05-15 15:41:05 +0000231 error("Failed to remove deployment", errorData.error || "Unknown error");
giobd37a2b2025-05-15 04:28:42 +0000232 }
233 } catch (e) {
gioe2b955a2025-05-15 15:41:05 +0000234 error("Failed to remove deployment", e instanceof Error ? e.message : undefined);
giobd37a2b2025-05-15 04:28:42 +0000235 } finally {
giob1c5c452025-05-21 04:16:54 +0000236 store.refreshEnv();
giobd37a2b2025-05-15 04:28:42 +0000237 setReloading(false);
238 }
gioe2b955a2025-05-15 15:41:05 +0000239 }, [projectId, info, error, store]);
240 const [deployProps, setDeployProps] = useState<{ loading?: boolean; disabled?: boolean }>({
241 loading: false,
242 disabled: false,
243 });
244 const [reloadProps, setReloadProps] = useState<{ loading?: boolean; disabled?: boolean }>({
245 loading: false,
246 disabled: false,
247 });
giod0026612025-05-08 13:00:36 +0000248 useEffect(() => {
249 if (loading) {
gioe2b955a2025-05-15 15:41:05 +0000250 setDeployProps({ loading: true, disabled: true });
giod0026612025-05-08 13:00:36 +0000251 } else if (ok) {
gio7d813702025-05-08 18:29:52 +0000252 setDeployProps({ disabled: false });
giod0026612025-05-08 13:00:36 +0000253 } else {
gio7d813702025-05-08 18:29:52 +0000254 setDeployProps({ disabled: true });
giod0026612025-05-08 13:00:36 +0000255 }
gio7d813702025-05-08 18:29:52 +0000256
257 if (reloading) {
gioe2b955a2025-05-15 15:41:05 +0000258 setReloadProps({ loading: true, disabled: true });
gio7d813702025-05-08 18:29:52 +0000259 } else {
260 setReloadProps({ disabled: projectId === undefined });
261 }
262 }, [ok, loading, reloading, projectId]);
gio818da4e2025-05-12 14:45:35 +0000263 if (store.mode === "deploy") {
264 return (
265 <div className="flex flex-row gap-1 items-center">
266 <Button onClick={edit} {...reloadProps}>
267 Edit
268 </Button>
269 <DropdownMenu>
270 <DropdownMenuTrigger>
gio3d0bf032025-06-05 06:57:26 +0000271 <Button size="icon">
272 <Ellipsis />
273 </Button>
gio818da4e2025-05-12 14:45:35 +0000274 </DropdownMenuTrigger>
275 <DropdownMenuContent className="w-56">
276 <DropdownMenuGroup>
277 <DropdownMenuItem
278 onClick={reload}
279 className="cursor-pointer hover:bg-gray-200"
280 {...reloadProps}
281 >
gioe2b955a2025-05-15 15:41:05 +0000282 {reloadProps.loading ? (
283 <>
284 <LoaderCircle className="animate-spin" />
285 Reloading...
286 </>
287 ) : (
288 "Reload services"
289 )}
gio818da4e2025-05-12 14:45:35 +0000290 </DropdownMenuItem>
291 <DropdownMenuItem
giobd37a2b2025-05-15 04:28:42 +0000292 onClick={removeDeployment}
293 disabled={projectId === undefined}
294 className="cursor-pointer hover:bg-gray-200"
295 >
gioe2b955a2025-05-15 15:41:05 +0000296 Remove deployment
giobd37a2b2025-05-15 04:28:42 +0000297 </DropdownMenuItem>
298 <DropdownMenuItem
gio818da4e2025-05-12 14:45:35 +0000299 onClick={deleteProject}
300 disabled={projectId === undefined}
301 className="cursor-pointer hover:bg-gray-200"
302 >
gioe2b955a2025-05-15 15:41:05 +0000303 Delete project
gio818da4e2025-05-12 14:45:35 +0000304 </DropdownMenuItem>
305 </DropdownMenuGroup>
306 </DropdownMenuContent>
307 </DropdownMenu>
308 </div>
309 );
310 } else {
311 return (
312 <div className="flex flex-row gap-1 items-center">
313 <Button onClick={deploy} {...deployProps}>
gioe2b955a2025-05-15 15:41:05 +0000314 {deployProps.loading ? (
315 <>
316 <LoaderCircle className="animate-spin" />
317 Deploying...
318 </>
319 ) : (
320 "Deploy"
321 )}
gio818da4e2025-05-12 14:45:35 +0000322 </Button>
323 <Button onClick={save}>Save</Button>
324 <DropdownMenu>
325 <DropdownMenuTrigger>
gio3d0bf032025-06-05 06:57:26 +0000326 <Button size="icon">
327 <Ellipsis />
328 </Button>
gio818da4e2025-05-12 14:45:35 +0000329 </DropdownMenuTrigger>
330 <DropdownMenuContent className="w-56">
331 <DropdownMenuGroup>
332 <DropdownMenuItem
333 onClick={restoreSaved}
334 disabled={projectId === undefined}
335 className="cursor-pointer hover:bg-gray-200"
336 >
337 Restore
338 </DropdownMenuItem>
339 <DropdownMenuItem onClick={clear} className="cursor-pointer hover:bg-gray-200">
340 Clear
341 </DropdownMenuItem>
342 <DropdownMenuItem
343 onClick={deleteProject}
344 disabled={projectId === undefined}
345 className="cursor-pointer hover:bg-gray-200"
346 >
gioe2b955a2025-05-15 15:41:05 +0000347 Delete project
gio818da4e2025-05-12 14:45:35 +0000348 </DropdownMenuItem>
349 </DropdownMenuGroup>
350 </DropdownMenuContent>
351 </DropdownMenu>
352 </div>
353 );
354 }
giof8acc612025-04-26 08:20:55 +0400355}