blob: 28d43dcb7340658a6d59b08f6fd90dd9a78a8ce7 [file] [log] [blame]
gio56e9f472025-07-07 03:33:38 +00001import { nodeLabelFull, useMessages, useProjectId, useStateStore } from "@/lib/state";
gio5f2f1002025-03-20 18:38:48 +04002import { Button } from "./ui/button";
3import { useCallback, useEffect, useState } from "react";
gio56e9f472025-07-07 03:33:38 +00004import { 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>();
giod0026612025-05-08 13:00:36 +000034 const messages = useMessages();
35 const instance = useReactFlow();
36 const [ok, setOk] = useState(false);
37 const [loading, setLoading] = useState(false);
gio7d813702025-05-08 18:29:52 +000038 const [reloading, setReloading] = useState(false);
gio8e74dc02025-06-13 10:19:26 +000039 const [showImportModal, setShowImportModal] = useState(false);
gio1037ee22025-06-26 09:25:43 +000040 const [showResourcesModal, setShowResourcesModal] = useState(false);
gioe2b955a2025-05-15 15:41:05 +000041 const info = useCallback(
42 (title: string, description?: string, duration?: number) => {
43 return toast({
44 title,
45 description,
46 duration: duration ?? 2000,
47 });
48 },
49 [toast],
50 );
51 const error = useCallback(
52 (title: string, description?: string, duration?: number) => {
53 return toast({
54 variant: "destructive",
55 title,
56 description,
57 duration: duration ?? 5000,
58 });
59 },
60 [toast],
61 );
giod0026612025-05-08 13:00:36 +000062 useEffect(() => {
63 setOk(!messages.some((m) => m.type === "FATAL"));
64 }, [messages, setOk]);
65 const monitor = useCallback(async () => {
66 const m = async function () {
67 const resp = await fetch(`/api/project/${projectId}/status`, {
68 method: "GET",
69 headers: {
70 "Content-Type": "application/json",
71 },
72 });
73 if (resp.status !== 200) {
74 return;
75 }
76 const data: { type: string; name: string; status: string }[] = await resp.json();
giod0026612025-05-08 13:00:36 +000077 for (const n of nodes) {
78 if (n.type === "network") {
79 continue;
80 }
gio8fad76a2025-05-22 14:01:23 +000081 const d = data.find((d) => n.type === toNodeType(d.type) && nodeLabelFull(n) === d.name);
giod0026612025-05-08 13:00:36 +000082 if (d !== undefined) {
83 store.updateNodeData(n.id, {
84 state: d?.status,
85 });
86 }
87 }
88 if (data.find((d) => d.status !== "success" && d.status != "failure") !== undefined) {
89 setTimeout(m, 1000);
90 }
91 };
92 setTimeout(m, 100);
93 }, [projectId, nodes, store]);
94 const deploy = useCallback(async () => {
95 if (projectId == null) {
96 return;
97 }
98 setLoading(true);
99 try {
giod0026612025-05-08 13:00:36 +0000100 const resp = await fetch(`/api/project/${projectId}/deploy`, {
101 method: "POST",
102 headers: {
103 "Content-Type": "application/json",
104 },
105 body: JSON.stringify({
gio56e9f472025-07-07 03:33:38 +0000106 type: "graph",
107 graph: instance.toObject(),
giod0026612025-05-08 13:00:36 +0000108 }),
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 }
gio56e9f472025-07-07 03:33:38 +0000123 }, [projectId, instance, 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 },
gio10ff1342025-07-05 10:22:15 +0000133 body: JSON.stringify({
134 type: "graph",
135 graph: instance.toObject(),
136 }),
giod0026612025-05-08 13:00:36 +0000137 });
138 if (resp.ok) {
gioe2b955a2025-05-15 15:41:05 +0000139 info("Save succeeded");
giod0026612025-05-08 13:00:36 +0000140 } else {
gioe2b955a2025-05-15 15:41:05 +0000141 error("Save failed", await resp.text());
giod0026612025-05-08 13:00:36 +0000142 }
gioe2b955a2025-05-15 15:41:05 +0000143 }, [projectId, instance, info, error]);
giod0026612025-05-08 13:00:36 +0000144 const restoreSaved = useCallback(async () => {
145 if (projectId == null) {
146 return;
147 }
gio818da4e2025-05-12 14:45:35 +0000148 const resp = await fetch(`/api/project/${projectId}/saved/${store.mode === "deploy" ? "deploy" : "draft"}`, {
giod0026612025-05-08 13:00:36 +0000149 method: "GET",
150 });
151 const inst = await resp.json();
gioc31bf142025-06-16 07:48:20 +0000152 const { x = 0, y = 0, zoom = 1 } = inst.state.viewport;
153 store.setNodes(inst.state.nodes || []);
154 store.setEdges(inst.state.edges || []);
giod0026612025-05-08 13:00:36 +0000155 instance.setViewport({ x, y, zoom });
156 }, [projectId, instance, store]);
157 const clear = useCallback(() => {
158 store.setEdges([]);
gio6d8b71c2025-05-19 12:57:35 +0000159 store.setNodes(store.nodes.filter((n) => n.type === "network"));
160 }, [store]);
gio818da4e2025-05-12 14:45:35 +0000161 const edit = useCallback(async () => {
162 store.setMode("edit");
163 }, [store]);
gio74ab7852025-05-13 13:19:31 +0000164 // TODO(gio): refresh projects
giod0026612025-05-08 13:00:36 +0000165 const deleteProject = useCallback(async () => {
166 if (projectId == null) {
167 return;
168 }
gio33046722025-05-16 14:49:55 +0000169 if (!confirm("Are you sure you want to delete this project? This action cannot be undone.")) {
170 return;
171 }
giod0026612025-05-08 13:00:36 +0000172 const resp = await fetch(`/api/project/${projectId}`, {
173 method: "DELETE",
gioa71316d2025-05-24 09:41:36 +0400174 headers: {
175 "Content-Type": "application/json",
176 },
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 }
gio56e9f472025-07-07 03:33:38 +0000185 }, [store, clear, projectId, info, error]);
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">
gio678746b2025-07-06 14:45:27 +0000268 <Button
269 onClick={() => store.setBuildMode(store.buildMode === "overview" ? "canvas" : "overview")}
270 {...reloadProps}
271 >
272 {store.buildMode === "overview" ? "Canvas" : "Overview"}
273 </Button>
gio818da4e2025-05-12 14:45:35 +0000274 <Button onClick={edit} {...reloadProps}>
275 Edit
276 </Button>
277 <DropdownMenu>
278 <DropdownMenuTrigger>
gio3d0bf032025-06-05 06:57:26 +0000279 <Button size="icon">
280 <Ellipsis />
281 </Button>
gio818da4e2025-05-12 14:45:35 +0000282 </DropdownMenuTrigger>
283 <DropdownMenuContent className="w-56">
284 <DropdownMenuGroup>
285 <DropdownMenuItem
286 onClick={reload}
287 className="cursor-pointer hover:bg-gray-200"
288 {...reloadProps}
289 >
gioe2b955a2025-05-15 15:41:05 +0000290 {reloadProps.loading ? (
291 <>
292 <LoaderCircle className="animate-spin" />
293 Reloading...
294 </>
295 ) : (
296 "Reload services"
297 )}
gio818da4e2025-05-12 14:45:35 +0000298 </DropdownMenuItem>
299 <DropdownMenuItem
giobd37a2b2025-05-15 04:28:42 +0000300 onClick={removeDeployment}
301 disabled={projectId === undefined}
302 className="cursor-pointer hover:bg-gray-200"
303 >
gioe2b955a2025-05-15 15:41:05 +0000304 Remove deployment
giobd37a2b2025-05-15 04:28:42 +0000305 </DropdownMenuItem>
306 <DropdownMenuItem
gio818da4e2025-05-12 14:45:35 +0000307 onClick={deleteProject}
308 disabled={projectId === undefined}
309 className="cursor-pointer hover:bg-gray-200"
310 >
gioe2b955a2025-05-15 15:41:05 +0000311 Delete project
gio818da4e2025-05-12 14:45:35 +0000312 </DropdownMenuItem>
313 </DropdownMenuGroup>
314 </DropdownMenuContent>
315 </DropdownMenu>
316 </div>
317 );
318 } else {
319 return (
gio8e74dc02025-06-13 10:19:26 +0000320 <>
321 <div className="flex flex-row gap-1 items-center">
gio678746b2025-07-06 14:45:27 +0000322 <Button onClick={() => store.setBuildMode(store.buildMode === "overview" ? "canvas" : "overview")}>
323 {store.buildMode === "overview" ? "Canvas" : "Overview"}
324 </Button>
325 <Button onClick={() => setShowResourcesModal(true)}>
326 <Plus className="w-4 h-4 mr-1" />
327 Add
328 </Button>
gio8e74dc02025-06-13 10:19:26 +0000329 <Button onClick={deploy} {...deployProps}>
330 {deployProps.loading ? (
331 <>
332 <LoaderCircle className="animate-spin" />
333 Deploying...
334 </>
335 ) : (
336 "Deploy"
337 )}
338 </Button>
339 <Button onClick={save}>Save</Button>
340 <Button onClick={() => setShowImportModal(true)}>Import</Button>
341 <DropdownMenu>
342 <DropdownMenuTrigger>
343 <Button size="icon">
344 <Ellipsis />
345 </Button>
346 </DropdownMenuTrigger>
347 <DropdownMenuContent className="w-56">
348 <DropdownMenuGroup>
349 <DropdownMenuItem
350 onClick={restoreSaved}
351 disabled={projectId === undefined}
352 className="cursor-pointer hover:bg-gray-200"
353 >
354 Restore
355 </DropdownMenuItem>
356 <DropdownMenuItem onClick={clear} className="cursor-pointer hover:bg-gray-200">
357 Clear
358 </DropdownMenuItem>
359 <DropdownMenuItem
360 onClick={deleteProject}
361 disabled={projectId === undefined}
362 className="cursor-pointer hover:bg-gray-200"
363 >
364 Delete project
365 </DropdownMenuItem>
366 </DropdownMenuGroup>
367 </DropdownMenuContent>
368 </DropdownMenu>
369 </div>
370 <ImportModal open={showImportModal} onOpenChange={setShowImportModal} />
gio1037ee22025-06-26 09:25:43 +0000371 <Dialog open={showResourcesModal} onOpenChange={setShowResourcesModal}>
372 <DialogContent className="sm:max-w-md">
373 <DialogHeader>
374 <DialogTitle>Add Resources</DialogTitle>
375 </DialogHeader>
376 <div className="py-4">
377 <Resources onResourceAdded={() => setShowResourcesModal(false)} />
378 </div>
379 </DialogContent>
380 </Dialog>
gio8e74dc02025-06-13 10:19:26 +0000381 </>
gio818da4e2025-05-12 14:45:35 +0000382 );
383 }
giof8acc612025-04-26 08:20:55 +0400384}