blob: 1442b9a45f3a7a1992c1544f2507602360e4f3ba [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";
gio3d0bf032025-06-05 06:57:26 +000014import { Ellipsis, LoaderCircle } from "lucide-react";
gio8e74dc02025-06-13 10:19:26 +000015import { ImportModal } from "./import-modal";
gio5f2f1002025-03-20 18:38:48 +040016
gioda708652025-04-30 14:57:38 +040017function toNodeType(t: string): string {
giod0026612025-05-08 13:00:36 +000018 if (t === "ingress") {
19 return "gateway-https";
20 } else if (t === "service") {
21 return "app";
22 } else {
23 return t;
24 }
gioda708652025-04-30 14:57:38 +040025}
26
gio5f2f1002025-03-20 18:38:48 +040027export function Actions() {
giod0026612025-05-08 13:00:36 +000028 const { toast } = useToast();
29 const store = useStateStore();
30 const projectId = useProjectId();
31 const nodes = useNodes<AppNode>();
32 const env = useEnv();
33 const messages = useMessages();
34 const instance = useReactFlow();
35 const [ok, setOk] = useState(false);
36 const [loading, setLoading] = useState(false);
gio7d813702025-05-08 18:29:52 +000037 const [reloading, setReloading] = useState(false);
gio8e74dc02025-06-13 10:19:26 +000038 const [showImportModal, setShowImportModal] = useState(false);
gioe2b955a2025-05-15 15:41:05 +000039 const info = useCallback(
40 (title: string, description?: string, duration?: number) => {
41 return toast({
42 title,
43 description,
44 duration: duration ?? 2000,
45 });
46 },
47 [toast],
48 );
49 const error = useCallback(
50 (title: string, description?: string, duration?: number) => {
51 return toast({
52 variant: "destructive",
53 title,
54 description,
55 duration: duration ?? 5000,
56 });
57 },
58 [toast],
59 );
giod0026612025-05-08 13:00:36 +000060 useEffect(() => {
61 setOk(!messages.some((m) => m.type === "FATAL"));
62 }, [messages, setOk]);
63 const monitor = useCallback(async () => {
64 const m = async function () {
65 const resp = await fetch(`/api/project/${projectId}/status`, {
66 method: "GET",
67 headers: {
68 "Content-Type": "application/json",
69 },
70 });
71 if (resp.status !== 200) {
72 return;
73 }
74 const data: { type: string; name: string; status: string }[] = await resp.json();
giod0026612025-05-08 13:00:36 +000075 for (const n of nodes) {
76 if (n.type === "network") {
77 continue;
78 }
gio8fad76a2025-05-22 14:01:23 +000079 const d = data.find((d) => n.type === toNodeType(d.type) && nodeLabelFull(n) === d.name);
giod0026612025-05-08 13:00:36 +000080 if (d !== undefined) {
81 store.updateNodeData(n.id, {
82 state: d?.status,
83 });
84 }
85 }
86 if (data.find((d) => d.status !== "success" && d.status != "failure") !== undefined) {
87 setTimeout(m, 1000);
88 }
89 };
90 setTimeout(m, 100);
91 }, [projectId, nodes, store]);
92 const deploy = useCallback(async () => {
93 if (projectId == null) {
94 return;
95 }
96 setLoading(true);
97 try {
gio7d813702025-05-08 18:29:52 +000098 const config = generateDodoConfig(projectId, nodes, env);
giod0026612025-05-08 13:00:36 +000099 if (config == null) {
100 throw new Error("MUST NOT REACH!");
101 }
102 const resp = await fetch(`/api/project/${projectId}/deploy`, {
103 method: "POST",
104 headers: {
105 "Content-Type": "application/json",
106 },
107 body: JSON.stringify({
giobd37a2b2025-05-15 04:28:42 +0000108 state: instance.toObject(),
giod0026612025-05-08 13:00:36 +0000109 config,
110 }),
111 });
112 if (resp.ok) {
gioe2b955a2025-05-15 15:41:05 +0000113 store.setMode("deploy");
114 info("Deployment succeeded");
giod0026612025-05-08 13:00:36 +0000115 monitor();
116 } else {
gio8fad76a2025-05-22 14:01:23 +0000117 error("Deployment failed");
giod0026612025-05-08 13:00:36 +0000118 }
gio62237142025-05-19 10:39:40 +0000119 store.refreshEnv();
gio8fad76a2025-05-22 14:01:23 +0000120 } catch {
121 error("Deployment failed");
giod0026612025-05-08 13:00:36 +0000122 } finally {
123 setLoading(false);
124 }
gioe2b955a2025-05-15 15:41:05 +0000125 }, [projectId, instance, nodes, env, setLoading, info, error, monitor, store]);
giod0026612025-05-08 13:00:36 +0000126 const save = useCallback(async () => {
127 if (projectId == null) {
128 return;
129 }
130 const resp = await fetch(`/api/project/${projectId}/saved`, {
131 method: "POST",
132 headers: {
133 "Content-Type": "application/json",
134 },
135 body: JSON.stringify(instance.toObject()),
136 });
137 if (resp.ok) {
gioe2b955a2025-05-15 15:41:05 +0000138 info("Save succeeded");
giod0026612025-05-08 13:00:36 +0000139 } else {
gioe2b955a2025-05-15 15:41:05 +0000140 error("Save failed", await resp.text());
giod0026612025-05-08 13:00:36 +0000141 }
gioe2b955a2025-05-15 15:41:05 +0000142 }, [projectId, instance, info, error]);
giod0026612025-05-08 13:00:36 +0000143 const restoreSaved = useCallback(async () => {
144 if (projectId == null) {
145 return;
146 }
gio818da4e2025-05-12 14:45:35 +0000147 const resp = await fetch(`/api/project/${projectId}/saved/${store.mode === "deploy" ? "deploy" : "draft"}`, {
giod0026612025-05-08 13:00:36 +0000148 method: "GET",
149 });
150 const inst = await resp.json();
gioc31bf142025-06-16 07:48:20 +0000151 const { x = 0, y = 0, zoom = 1 } = inst.state.viewport;
152 store.setNodes(inst.state.nodes || []);
153 store.setEdges(inst.state.edges || []);
giod0026612025-05-08 13:00:36 +0000154 instance.setViewport({ x, y, zoom });
155 }, [projectId, instance, store]);
156 const clear = useCallback(() => {
157 store.setEdges([]);
gio6d8b71c2025-05-19 12:57:35 +0000158 store.setNodes(store.nodes.filter((n) => n.type === "network"));
159 }, [store]);
gio818da4e2025-05-12 14:45:35 +0000160 const edit = useCallback(async () => {
161 store.setMode("edit");
162 }, [store]);
gio74ab7852025-05-13 13:19:31 +0000163 // TODO(gio): refresh projects
giod0026612025-05-08 13:00:36 +0000164 const deleteProject = useCallback(async () => {
165 if (projectId == null) {
166 return;
167 }
gio33046722025-05-16 14:49:55 +0000168 if (!confirm("Are you sure you want to delete this project? This action cannot be undone.")) {
169 return;
170 }
giod0026612025-05-08 13:00:36 +0000171 const resp = await fetch(`/api/project/${projectId}`, {
172 method: "DELETE",
gioa71316d2025-05-24 09:41:36 +0400173 headers: {
174 "Content-Type": "application/json",
175 },
176 body: JSON.stringify({ state: JSON.stringify(instance.toObject()) }),
giod0026612025-05-08 13:00:36 +0000177 });
178 if (resp.ok) {
179 clear();
180 store.setProject(undefined);
gioe2b955a2025-05-15 15:41:05 +0000181 info("Project deleted");
giod0026612025-05-08 13:00:36 +0000182 } else {
gioe2b955a2025-05-15 15:41:05 +0000183 error("Failed to delete project", await resp.text());
giod0026612025-05-08 13:00:36 +0000184 }
gioa71316d2025-05-24 09:41:36 +0400185 }, [store, clear, projectId, info, error, instance]);
gio7d813702025-05-08 18:29:52 +0000186 const reload = useCallback(async () => {
187 if (projectId == null) {
188 return;
189 }
190 setReloading(true);
gioe2b955a2025-05-15 15:41:05 +0000191 const { dismiss } = info("Reloading services", "This may take a while...", Infinity);
gio7d813702025-05-08 18:29:52 +0000192 try {
193 const resp = await fetch(`/api/project/${projectId}/reload`, {
194 method: "POST",
195 headers: {
196 "Content-Type": "application/json",
197 },
198 });
199 if (resp.ok) {
gioe2b955a2025-05-15 15:41:05 +0000200 dismiss();
201 info("Reloaded services successfully");
gio7d813702025-05-08 18:29:52 +0000202 } else {
gioe2b955a2025-05-15 15:41:05 +0000203 dismiss();
204 error("Reload failed", await resp.text());
gio7d813702025-05-08 18:29:52 +0000205 }
206 } catch (e) {
gioe2b955a2025-05-15 15:41:05 +0000207 dismiss();
208 error("Reload failed", e instanceof Error ? e.message : undefined);
gio7d813702025-05-08 18:29:52 +0000209 } finally {
210 setReloading(false);
211 }
gioe2b955a2025-05-15 15:41:05 +0000212 }, [projectId, info, error]);
giobd37a2b2025-05-15 04:28:42 +0000213 const removeDeployment = useCallback(async () => {
214 if (projectId == null) {
215 return;
216 }
217 if (!confirm("Are you sure you want to remove this deployment? This action cannot be undone.")) {
218 return;
219 }
220 setReloading(true);
221 try {
222 const resp = await fetch(`/api/project/${projectId}/remove-deployment`, {
223 method: "POST",
224 headers: {
225 "Content-Type": "application/json",
226 },
227 });
228 if (resp.ok) {
gioe2b955a2025-05-15 15:41:05 +0000229 info("Deployment removed successfully");
giobd37a2b2025-05-15 04:28:42 +0000230 store.setMode("edit");
231 } else {
232 const errorData = await resp.json();
gioe2b955a2025-05-15 15:41:05 +0000233 error("Failed to remove deployment", errorData.error || "Unknown error");
giobd37a2b2025-05-15 04:28:42 +0000234 }
235 } catch (e) {
gioe2b955a2025-05-15 15:41:05 +0000236 error("Failed to remove deployment", e instanceof Error ? e.message : undefined);
giobd37a2b2025-05-15 04:28:42 +0000237 } finally {
giob1c5c452025-05-21 04:16:54 +0000238 store.refreshEnv();
giobd37a2b2025-05-15 04:28:42 +0000239 setReloading(false);
240 }
gioe2b955a2025-05-15 15:41:05 +0000241 }, [projectId, info, error, store]);
242 const [deployProps, setDeployProps] = useState<{ loading?: boolean; disabled?: boolean }>({
243 loading: false,
244 disabled: false,
245 });
246 const [reloadProps, setReloadProps] = useState<{ loading?: boolean; disabled?: boolean }>({
247 loading: false,
248 disabled: false,
249 });
giod0026612025-05-08 13:00:36 +0000250 useEffect(() => {
251 if (loading) {
gioe2b955a2025-05-15 15:41:05 +0000252 setDeployProps({ loading: true, disabled: true });
giod0026612025-05-08 13:00:36 +0000253 } else if (ok) {
gio7d813702025-05-08 18:29:52 +0000254 setDeployProps({ disabled: false });
giod0026612025-05-08 13:00:36 +0000255 } else {
gio7d813702025-05-08 18:29:52 +0000256 setDeployProps({ disabled: true });
giod0026612025-05-08 13:00:36 +0000257 }
gio7d813702025-05-08 18:29:52 +0000258
259 if (reloading) {
gioe2b955a2025-05-15 15:41:05 +0000260 setReloadProps({ loading: true, disabled: true });
gio7d813702025-05-08 18:29:52 +0000261 } else {
262 setReloadProps({ disabled: projectId === undefined });
263 }
264 }, [ok, loading, reloading, projectId]);
gio818da4e2025-05-12 14:45:35 +0000265 if (store.mode === "deploy") {
266 return (
267 <div className="flex flex-row gap-1 items-center">
268 <Button onClick={edit} {...reloadProps}>
269 Edit
270 </Button>
271 <DropdownMenu>
272 <DropdownMenuTrigger>
gio3d0bf032025-06-05 06:57:26 +0000273 <Button size="icon">
274 <Ellipsis />
275 </Button>
gio818da4e2025-05-12 14:45:35 +0000276 </DropdownMenuTrigger>
277 <DropdownMenuContent className="w-56">
278 <DropdownMenuGroup>
279 <DropdownMenuItem
280 onClick={reload}
281 className="cursor-pointer hover:bg-gray-200"
282 {...reloadProps}
283 >
gioe2b955a2025-05-15 15:41:05 +0000284 {reloadProps.loading ? (
285 <>
286 <LoaderCircle className="animate-spin" />
287 Reloading...
288 </>
289 ) : (
290 "Reload services"
291 )}
gio818da4e2025-05-12 14:45:35 +0000292 </DropdownMenuItem>
293 <DropdownMenuItem
giobd37a2b2025-05-15 04:28:42 +0000294 onClick={removeDeployment}
295 disabled={projectId === undefined}
296 className="cursor-pointer hover:bg-gray-200"
297 >
gioe2b955a2025-05-15 15:41:05 +0000298 Remove deployment
giobd37a2b2025-05-15 04:28:42 +0000299 </DropdownMenuItem>
300 <DropdownMenuItem
gio818da4e2025-05-12 14:45:35 +0000301 onClick={deleteProject}
302 disabled={projectId === undefined}
303 className="cursor-pointer hover:bg-gray-200"
304 >
gioe2b955a2025-05-15 15:41:05 +0000305 Delete project
gio818da4e2025-05-12 14:45:35 +0000306 </DropdownMenuItem>
307 </DropdownMenuGroup>
308 </DropdownMenuContent>
309 </DropdownMenu>
310 </div>
311 );
312 } else {
313 return (
gio8e74dc02025-06-13 10:19:26 +0000314 <>
315 <div className="flex flex-row gap-1 items-center">
316 <Button onClick={deploy} {...deployProps}>
317 {deployProps.loading ? (
318 <>
319 <LoaderCircle className="animate-spin" />
320 Deploying...
321 </>
322 ) : (
323 "Deploy"
324 )}
325 </Button>
326 <Button onClick={save}>Save</Button>
327 <Button onClick={() => setShowImportModal(true)}>Import</Button>
328 <DropdownMenu>
329 <DropdownMenuTrigger>
330 <Button size="icon">
331 <Ellipsis />
332 </Button>
333 </DropdownMenuTrigger>
334 <DropdownMenuContent className="w-56">
335 <DropdownMenuGroup>
336 <DropdownMenuItem
337 onClick={restoreSaved}
338 disabled={projectId === undefined}
339 className="cursor-pointer hover:bg-gray-200"
340 >
341 Restore
342 </DropdownMenuItem>
343 <DropdownMenuItem onClick={clear} className="cursor-pointer hover:bg-gray-200">
344 Clear
345 </DropdownMenuItem>
346 <DropdownMenuItem
347 onClick={deleteProject}
348 disabled={projectId === undefined}
349 className="cursor-pointer hover:bg-gray-200"
350 >
351 Delete project
352 </DropdownMenuItem>
353 </DropdownMenuGroup>
354 </DropdownMenuContent>
355 </DropdownMenu>
356 </div>
357 <ImportModal open={showImportModal} onOpenChange={setShowImportModal} />
358 </>
gio818da4e2025-05-12 14:45:35 +0000359 );
360 }
giof8acc612025-04-26 08:20:55 +0400361}