blob: f283ec8deb7be1b7b911c5b177a538314f025537 [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
gio1037ee22025-06-26 09:25:43 +000029interface ActionsProps {
30 isOverview?: boolean;
31}
32
33export function Actions({ isOverview = false }: ActionsProps) {
giod0026612025-05-08 13:00:36 +000034 const { toast } = useToast();
35 const store = useStateStore();
36 const projectId = useProjectId();
37 const nodes = useNodes<AppNode>();
38 const env = useEnv();
39 const messages = useMessages();
40 const instance = useReactFlow();
41 const [ok, setOk] = useState(false);
42 const [loading, setLoading] = useState(false);
gio7d813702025-05-08 18:29:52 +000043 const [reloading, setReloading] = useState(false);
gio8e74dc02025-06-13 10:19:26 +000044 const [showImportModal, setShowImportModal] = useState(false);
gio1037ee22025-06-26 09:25:43 +000045 const [showResourcesModal, setShowResourcesModal] = useState(false);
gioe2b955a2025-05-15 15:41:05 +000046 const info = useCallback(
47 (title: string, description?: string, duration?: number) => {
48 return toast({
49 title,
50 description,
51 duration: duration ?? 2000,
52 });
53 },
54 [toast],
55 );
56 const error = useCallback(
57 (title: string, description?: string, duration?: number) => {
58 return toast({
59 variant: "destructive",
60 title,
61 description,
62 duration: duration ?? 5000,
63 });
64 },
65 [toast],
66 );
giod0026612025-05-08 13:00:36 +000067 useEffect(() => {
68 setOk(!messages.some((m) => m.type === "FATAL"));
69 }, [messages, setOk]);
70 const monitor = useCallback(async () => {
71 const m = async function () {
72 const resp = await fetch(`/api/project/${projectId}/status`, {
73 method: "GET",
74 headers: {
75 "Content-Type": "application/json",
76 },
77 });
78 if (resp.status !== 200) {
79 return;
80 }
81 const data: { type: string; name: string; status: string }[] = await resp.json();
giod0026612025-05-08 13:00:36 +000082 for (const n of nodes) {
83 if (n.type === "network") {
84 continue;
85 }
gio8fad76a2025-05-22 14:01:23 +000086 const d = data.find((d) => n.type === toNodeType(d.type) && nodeLabelFull(n) === d.name);
giod0026612025-05-08 13:00:36 +000087 if (d !== undefined) {
88 store.updateNodeData(n.id, {
89 state: d?.status,
90 });
91 }
92 }
93 if (data.find((d) => d.status !== "success" && d.status != "failure") !== undefined) {
94 setTimeout(m, 1000);
95 }
96 };
97 setTimeout(m, 100);
98 }, [projectId, nodes, store]);
99 const deploy = useCallback(async () => {
100 if (projectId == null) {
101 return;
102 }
103 setLoading(true);
104 try {
gio7d813702025-05-08 18:29:52 +0000105 const config = generateDodoConfig(projectId, nodes, env);
giod0026612025-05-08 13:00:36 +0000106 if (config == null) {
107 throw new Error("MUST NOT REACH!");
108 }
109 const resp = await fetch(`/api/project/${projectId}/deploy`, {
110 method: "POST",
111 headers: {
112 "Content-Type": "application/json",
113 },
114 body: JSON.stringify({
giobd37a2b2025-05-15 04:28:42 +0000115 state: instance.toObject(),
giod0026612025-05-08 13:00:36 +0000116 config,
117 }),
118 });
119 if (resp.ok) {
gioe2b955a2025-05-15 15:41:05 +0000120 store.setMode("deploy");
121 info("Deployment succeeded");
giod0026612025-05-08 13:00:36 +0000122 monitor();
123 } else {
gio8fad76a2025-05-22 14:01:23 +0000124 error("Deployment failed");
giod0026612025-05-08 13:00:36 +0000125 }
gio62237142025-05-19 10:39:40 +0000126 store.refreshEnv();
gio8fad76a2025-05-22 14:01:23 +0000127 } catch {
128 error("Deployment failed");
giod0026612025-05-08 13:00:36 +0000129 } finally {
130 setLoading(false);
131 }
gioe2b955a2025-05-15 15:41:05 +0000132 }, [projectId, instance, nodes, env, setLoading, info, error, monitor, store]);
giod0026612025-05-08 13:00:36 +0000133 const save = useCallback(async () => {
134 if (projectId == null) {
135 return;
136 }
137 const resp = await fetch(`/api/project/${projectId}/saved`, {
138 method: "POST",
139 headers: {
140 "Content-Type": "application/json",
141 },
gio10ff1342025-07-05 10:22:15 +0000142 body: JSON.stringify({
143 type: "graph",
144 graph: instance.toObject(),
145 }),
giod0026612025-05-08 13:00:36 +0000146 });
147 if (resp.ok) {
gioe2b955a2025-05-15 15:41:05 +0000148 info("Save succeeded");
giod0026612025-05-08 13:00:36 +0000149 } else {
gioe2b955a2025-05-15 15:41:05 +0000150 error("Save failed", await resp.text());
giod0026612025-05-08 13:00:36 +0000151 }
gioe2b955a2025-05-15 15:41:05 +0000152 }, [projectId, instance, info, error]);
giod0026612025-05-08 13:00:36 +0000153 const restoreSaved = useCallback(async () => {
154 if (projectId == null) {
155 return;
156 }
gio818da4e2025-05-12 14:45:35 +0000157 const resp = await fetch(`/api/project/${projectId}/saved/${store.mode === "deploy" ? "deploy" : "draft"}`, {
giod0026612025-05-08 13:00:36 +0000158 method: "GET",
159 });
160 const inst = await resp.json();
gioc31bf142025-06-16 07:48:20 +0000161 const { x = 0, y = 0, zoom = 1 } = inst.state.viewport;
162 store.setNodes(inst.state.nodes || []);
163 store.setEdges(inst.state.edges || []);
giod0026612025-05-08 13:00:36 +0000164 instance.setViewport({ x, y, zoom });
165 }, [projectId, instance, store]);
166 const clear = useCallback(() => {
167 store.setEdges([]);
gio6d8b71c2025-05-19 12:57:35 +0000168 store.setNodes(store.nodes.filter((n) => n.type === "network"));
169 }, [store]);
gio818da4e2025-05-12 14:45:35 +0000170 const edit = useCallback(async () => {
171 store.setMode("edit");
172 }, [store]);
gio74ab7852025-05-13 13:19:31 +0000173 // TODO(gio): refresh projects
giod0026612025-05-08 13:00:36 +0000174 const deleteProject = useCallback(async () => {
175 if (projectId == null) {
176 return;
177 }
gio33046722025-05-16 14:49:55 +0000178 if (!confirm("Are you sure you want to delete this project? This action cannot be undone.")) {
179 return;
180 }
giod0026612025-05-08 13:00:36 +0000181 const resp = await fetch(`/api/project/${projectId}`, {
182 method: "DELETE",
gioa71316d2025-05-24 09:41:36 +0400183 headers: {
184 "Content-Type": "application/json",
185 },
186 body: JSON.stringify({ state: JSON.stringify(instance.toObject()) }),
giod0026612025-05-08 13:00:36 +0000187 });
188 if (resp.ok) {
189 clear();
190 store.setProject(undefined);
gioe2b955a2025-05-15 15:41:05 +0000191 info("Project deleted");
giod0026612025-05-08 13:00:36 +0000192 } else {
gioe2b955a2025-05-15 15:41:05 +0000193 error("Failed to delete project", await resp.text());
giod0026612025-05-08 13:00:36 +0000194 }
gioa71316d2025-05-24 09:41:36 +0400195 }, [store, clear, projectId, info, error, instance]);
gio7d813702025-05-08 18:29:52 +0000196 const reload = useCallback(async () => {
197 if (projectId == null) {
198 return;
199 }
200 setReloading(true);
gioe2b955a2025-05-15 15:41:05 +0000201 const { dismiss } = info("Reloading services", "This may take a while...", Infinity);
gio7d813702025-05-08 18:29:52 +0000202 try {
203 const resp = await fetch(`/api/project/${projectId}/reload`, {
204 method: "POST",
205 headers: {
206 "Content-Type": "application/json",
207 },
208 });
209 if (resp.ok) {
gioe2b955a2025-05-15 15:41:05 +0000210 dismiss();
211 info("Reloaded services successfully");
gio7d813702025-05-08 18:29:52 +0000212 } else {
gioe2b955a2025-05-15 15:41:05 +0000213 dismiss();
214 error("Reload failed", await resp.text());
gio7d813702025-05-08 18:29:52 +0000215 }
216 } catch (e) {
gioe2b955a2025-05-15 15:41:05 +0000217 dismiss();
218 error("Reload failed", e instanceof Error ? e.message : undefined);
gio7d813702025-05-08 18:29:52 +0000219 } finally {
220 setReloading(false);
221 }
gioe2b955a2025-05-15 15:41:05 +0000222 }, [projectId, info, error]);
giobd37a2b2025-05-15 04:28:42 +0000223 const removeDeployment = useCallback(async () => {
224 if (projectId == null) {
225 return;
226 }
227 if (!confirm("Are you sure you want to remove this deployment? This action cannot be undone.")) {
228 return;
229 }
230 setReloading(true);
231 try {
232 const resp = await fetch(`/api/project/${projectId}/remove-deployment`, {
233 method: "POST",
234 headers: {
235 "Content-Type": "application/json",
236 },
237 });
238 if (resp.ok) {
gioe2b955a2025-05-15 15:41:05 +0000239 info("Deployment removed successfully");
giobd37a2b2025-05-15 04:28:42 +0000240 store.setMode("edit");
241 } else {
242 const errorData = await resp.json();
gioe2b955a2025-05-15 15:41:05 +0000243 error("Failed to remove deployment", errorData.error || "Unknown error");
giobd37a2b2025-05-15 04:28:42 +0000244 }
245 } catch (e) {
gioe2b955a2025-05-15 15:41:05 +0000246 error("Failed to remove deployment", e instanceof Error ? e.message : undefined);
giobd37a2b2025-05-15 04:28:42 +0000247 } finally {
giob1c5c452025-05-21 04:16:54 +0000248 store.refreshEnv();
giobd37a2b2025-05-15 04:28:42 +0000249 setReloading(false);
250 }
gioe2b955a2025-05-15 15:41:05 +0000251 }, [projectId, info, error, store]);
252 const [deployProps, setDeployProps] = useState<{ loading?: boolean; disabled?: boolean }>({
253 loading: false,
254 disabled: false,
255 });
256 const [reloadProps, setReloadProps] = useState<{ loading?: boolean; disabled?: boolean }>({
257 loading: false,
258 disabled: false,
259 });
giod0026612025-05-08 13:00:36 +0000260 useEffect(() => {
261 if (loading) {
gioe2b955a2025-05-15 15:41:05 +0000262 setDeployProps({ loading: true, disabled: true });
giod0026612025-05-08 13:00:36 +0000263 } else if (ok) {
gio7d813702025-05-08 18:29:52 +0000264 setDeployProps({ disabled: false });
giod0026612025-05-08 13:00:36 +0000265 } else {
gio7d813702025-05-08 18:29:52 +0000266 setDeployProps({ disabled: true });
giod0026612025-05-08 13:00:36 +0000267 }
gio7d813702025-05-08 18:29:52 +0000268
269 if (reloading) {
gioe2b955a2025-05-15 15:41:05 +0000270 setReloadProps({ loading: true, disabled: true });
gio7d813702025-05-08 18:29:52 +0000271 } else {
272 setReloadProps({ disabled: projectId === undefined });
273 }
274 }, [ok, loading, reloading, projectId]);
gio818da4e2025-05-12 14:45:35 +0000275 if (store.mode === "deploy") {
276 return (
277 <div className="flex flex-row gap-1 items-center">
278 <Button onClick={edit} {...reloadProps}>
279 Edit
280 </Button>
281 <DropdownMenu>
282 <DropdownMenuTrigger>
gio3d0bf032025-06-05 06:57:26 +0000283 <Button size="icon">
284 <Ellipsis />
285 </Button>
gio818da4e2025-05-12 14:45:35 +0000286 </DropdownMenuTrigger>
287 <DropdownMenuContent className="w-56">
288 <DropdownMenuGroup>
289 <DropdownMenuItem
290 onClick={reload}
291 className="cursor-pointer hover:bg-gray-200"
292 {...reloadProps}
293 >
gioe2b955a2025-05-15 15:41:05 +0000294 {reloadProps.loading ? (
295 <>
296 <LoaderCircle className="animate-spin" />
297 Reloading...
298 </>
299 ) : (
300 "Reload services"
301 )}
gio818da4e2025-05-12 14:45:35 +0000302 </DropdownMenuItem>
303 <DropdownMenuItem
giobd37a2b2025-05-15 04:28:42 +0000304 onClick={removeDeployment}
305 disabled={projectId === undefined}
306 className="cursor-pointer hover:bg-gray-200"
307 >
gioe2b955a2025-05-15 15:41:05 +0000308 Remove deployment
giobd37a2b2025-05-15 04:28:42 +0000309 </DropdownMenuItem>
310 <DropdownMenuItem
gio818da4e2025-05-12 14:45:35 +0000311 onClick={deleteProject}
312 disabled={projectId === undefined}
313 className="cursor-pointer hover:bg-gray-200"
314 >
gioe2b955a2025-05-15 15:41:05 +0000315 Delete project
gio818da4e2025-05-12 14:45:35 +0000316 </DropdownMenuItem>
317 </DropdownMenuGroup>
318 </DropdownMenuContent>
319 </DropdownMenu>
320 </div>
321 );
322 } else {
323 return (
gio8e74dc02025-06-13 10:19:26 +0000324 <>
325 <div className="flex flex-row gap-1 items-center">
gio1037ee22025-06-26 09:25:43 +0000326 {isOverview && (
327 <Button onClick={() => setShowResourcesModal(true)}>
328 <Plus className="w-4 h-4 mr-1" />
329 Add
330 </Button>
331 )}
gio8e74dc02025-06-13 10:19:26 +0000332 <Button onClick={deploy} {...deployProps}>
333 {deployProps.loading ? (
334 <>
335 <LoaderCircle className="animate-spin" />
336 Deploying...
337 </>
338 ) : (
339 "Deploy"
340 )}
341 </Button>
342 <Button onClick={save}>Save</Button>
343 <Button onClick={() => setShowImportModal(true)}>Import</Button>
344 <DropdownMenu>
345 <DropdownMenuTrigger>
346 <Button size="icon">
347 <Ellipsis />
348 </Button>
349 </DropdownMenuTrigger>
350 <DropdownMenuContent className="w-56">
351 <DropdownMenuGroup>
352 <DropdownMenuItem
353 onClick={restoreSaved}
354 disabled={projectId === undefined}
355 className="cursor-pointer hover:bg-gray-200"
356 >
357 Restore
358 </DropdownMenuItem>
359 <DropdownMenuItem onClick={clear} className="cursor-pointer hover:bg-gray-200">
360 Clear
361 </DropdownMenuItem>
362 <DropdownMenuItem
363 onClick={deleteProject}
364 disabled={projectId === undefined}
365 className="cursor-pointer hover:bg-gray-200"
366 >
367 Delete project
368 </DropdownMenuItem>
369 </DropdownMenuGroup>
370 </DropdownMenuContent>
371 </DropdownMenu>
372 </div>
373 <ImportModal open={showImportModal} onOpenChange={setShowImportModal} />
gio1037ee22025-06-26 09:25:43 +0000374 <Dialog open={showResourcesModal} onOpenChange={setShowResourcesModal}>
375 <DialogContent className="sm:max-w-md">
376 <DialogHeader>
377 <DialogTitle>Add Resources</DialogTitle>
378 </DialogHeader>
379 <div className="py-4">
380 <Resources onResourceAdded={() => setShowResourcesModal(false)} />
381 </div>
382 </DialogContent>
383 </Dialog>
gio8e74dc02025-06-13 10:19:26 +0000384 </>
gio818da4e2025-05-12 14:45:35 +0000385 );
386 }
giof8acc612025-04-26 08:20:55 +0400387}