blob: ec0d28a62a1c6a9fb13b1a18fd210abdf57868ce [file] [log] [blame]
gio1dc800a2025-04-24 17:15:43 +00001import { AppNode, nodeLabel, 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";
14import { Menu } 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);
giod0026612025-05-08 13:00:36 +000037 useEffect(() => {
38 setOk(!messages.some((m) => m.type === "FATAL"));
39 }, [messages, setOk]);
40 const monitor = useCallback(async () => {
41 const m = async function () {
42 const resp = await fetch(`/api/project/${projectId}/status`, {
43 method: "GET",
44 headers: {
45 "Content-Type": "application/json",
46 },
47 });
48 if (resp.status !== 200) {
49 return;
50 }
51 const data: { type: string; name: string; status: string }[] = await resp.json();
52 console.log(data);
53 for (const n of nodes) {
54 if (n.type === "network") {
55 continue;
56 }
57 const d = data.find((d) => n.type === toNodeType(d.type) && nodeLabel(n) === d.name);
58 if (d !== undefined) {
59 store.updateNodeData(n.id, {
60 state: d?.status,
61 });
62 }
63 }
64 if (data.find((d) => d.status !== "success" && d.status != "failure") !== undefined) {
65 setTimeout(m, 1000);
66 }
67 };
68 setTimeout(m, 100);
69 }, [projectId, nodes, store]);
70 const deploy = useCallback(async () => {
71 if (projectId == null) {
72 return;
73 }
74 setLoading(true);
gio818da4e2025-05-12 14:45:35 +000075 store.setMode("deploy");
giod0026612025-05-08 13:00:36 +000076 try {
gio7d813702025-05-08 18:29:52 +000077 const config = generateDodoConfig(projectId, nodes, env);
giod0026612025-05-08 13:00:36 +000078 if (config == null) {
79 throw new Error("MUST NOT REACH!");
80 }
81 const resp = await fetch(`/api/project/${projectId}/deploy`, {
82 method: "POST",
83 headers: {
84 "Content-Type": "application/json",
85 },
86 body: JSON.stringify({
giobd37a2b2025-05-15 04:28:42 +000087 state: instance.toObject(),
giod0026612025-05-08 13:00:36 +000088 config,
89 }),
90 });
91 if (resp.ok) {
92 toast({
93 title: "Deployment succeeded",
94 });
95 monitor();
96 } else {
97 toast({
98 variant: "destructive",
99 title: "Deployment failed",
100 description: await resp.text(),
101 });
102 }
103 } catch (e) {
gio818da4e2025-05-12 14:45:35 +0000104 store.setMode("edit");
giod0026612025-05-08 13:00:36 +0000105 console.log(e);
106 toast({
107 variant: "destructive",
108 title: "Deployment failed",
109 });
110 } finally {
111 setLoading(false);
112 }
gio818da4e2025-05-12 14:45:35 +0000113 }, [projectId, instance, nodes, env, setLoading, toast, monitor, store]);
giod0026612025-05-08 13:00:36 +0000114 const save = useCallback(async () => {
115 if (projectId == null) {
116 return;
117 }
118 const resp = await fetch(`/api/project/${projectId}/saved`, {
119 method: "POST",
120 headers: {
121 "Content-Type": "application/json",
122 },
123 body: JSON.stringify(instance.toObject()),
124 });
125 if (resp.ok) {
126 toast({
127 title: "Save succeeded",
128 });
129 } else {
130 toast({
131 variant: "destructive",
132 title: "Save failed",
133 description: await resp.text(),
134 });
135 }
136 }, [projectId, instance, toast]);
137 const restoreSaved = useCallback(async () => {
138 if (projectId == null) {
139 return;
140 }
gio818da4e2025-05-12 14:45:35 +0000141 const resp = await fetch(`/api/project/${projectId}/saved/${store.mode === "deploy" ? "deploy" : "draft"}`, {
giod0026612025-05-08 13:00:36 +0000142 method: "GET",
143 });
144 const inst = await resp.json();
145 const { x = 0, y = 0, zoom = 1 } = inst.viewport;
146 store.setNodes(inst.nodes || []);
147 store.setEdges(inst.edges || []);
148 instance.setViewport({ x, y, zoom });
149 }, [projectId, instance, store]);
150 const clear = useCallback(() => {
151 store.setEdges([]);
152 store.setNodes([]);
153 instance.setViewport({ x: 0, y: 0, zoom: 1 });
154 }, [store, instance]);
gio818da4e2025-05-12 14:45:35 +0000155 const edit = useCallback(async () => {
156 store.setMode("edit");
157 }, [store]);
gio74ab7852025-05-13 13:19:31 +0000158 // TODO(gio): refresh projects
giod0026612025-05-08 13:00:36 +0000159 const deleteProject = useCallback(async () => {
160 if (projectId == null) {
161 return;
162 }
163 const resp = await fetch(`/api/project/${projectId}`, {
164 method: "DELETE",
165 });
166 if (resp.ok) {
167 clear();
168 store.setProject(undefined);
169 toast({
gio818da4e2025-05-12 14:45:35 +0000170 title: "Project deleted",
giod0026612025-05-08 13:00:36 +0000171 });
172 } else {
173 toast({
174 variant: "destructive",
gio818da4e2025-05-12 14:45:35 +0000175 title: "Failed to delete project",
giod0026612025-05-08 13:00:36 +0000176 description: await resp.text(),
177 });
178 }
179 }, [store, clear, projectId, toast]);
gio7d813702025-05-08 18:29:52 +0000180 const reload = useCallback(async () => {
181 if (projectId == null) {
182 return;
183 }
184 setReloading(true);
185 try {
186 const resp = await fetch(`/api/project/${projectId}/reload`, {
187 method: "POST",
188 headers: {
189 "Content-Type": "application/json",
190 },
191 });
192 if (resp.ok) {
193 toast({
194 title: "Reload triggered successfully",
195 });
196 } else {
197 toast({
198 variant: "destructive",
199 title: "Reload failed",
200 description: await resp.text(),
201 });
202 }
203 } catch (e) {
204 console.log(e);
205 toast({
206 variant: "destructive",
207 title: "Reload failed",
208 });
209 } finally {
210 setReloading(false);
211 }
212 }, [projectId, toast]);
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) {
229 toast({
230 title: "Deployment removed successfully",
231 });
232 store.setMode("edit");
233 } else {
234 const errorData = await resp.json();
235 toast({
236 variant: "destructive",
237 title: "Failed to remove deployment",
238 description: errorData.error || "Unknown error",
239 });
240 }
241 } catch (e) {
242 console.log(e);
243 toast({
244 variant: "destructive",
245 title: "Failed to remove deployment",
246 });
247 } finally {
248 setReloading(false);
249 }
250 }, [projectId, toast, store]);
gio7d813702025-05-08 18:29:52 +0000251 const [deployProps, setDeployProps] = useState({});
252 const [reloadProps, setReloadProps] = useState({});
giod0026612025-05-08 13:00:36 +0000253 useEffect(() => {
254 if (loading) {
gio7d813702025-05-08 18:29:52 +0000255 setDeployProps({ loading: true });
giod0026612025-05-08 13:00:36 +0000256 } else if (ok) {
gio7d813702025-05-08 18:29:52 +0000257 setDeployProps({ disabled: false });
giod0026612025-05-08 13:00:36 +0000258 } else {
gio7d813702025-05-08 18:29:52 +0000259 setDeployProps({ disabled: true });
giod0026612025-05-08 13:00:36 +0000260 }
gio7d813702025-05-08 18:29:52 +0000261
262 if (reloading) {
263 setReloadProps({ loading: true });
264 } else {
265 setReloadProps({ disabled: projectId === undefined });
266 }
267 }, [ok, loading, reloading, projectId]);
gio818da4e2025-05-12 14:45:35 +0000268 if (store.mode === "deploy") {
269 return (
270 <div className="flex flex-row gap-1 items-center">
271 <Button onClick={edit} {...reloadProps}>
272 Edit
273 </Button>
274 <DropdownMenu>
275 <DropdownMenuTrigger>
276 <Menu className="rounded-md bg-gray-200 opacity-50" />
277 </DropdownMenuTrigger>
278 <DropdownMenuContent className="w-56">
279 <DropdownMenuGroup>
280 <DropdownMenuItem
281 onClick={reload}
282 className="cursor-pointer hover:bg-gray-200"
283 {...reloadProps}
284 >
285 Reload Services
286 </DropdownMenuItem>
287 <DropdownMenuItem
giobd37a2b2025-05-15 04:28:42 +0000288 onClick={removeDeployment}
289 disabled={projectId === undefined}
290 className="cursor-pointer hover:bg-gray-200"
291 >
292 Remove Deployment
293 </DropdownMenuItem>
294 <DropdownMenuItem
gio818da4e2025-05-12 14:45:35 +0000295 onClick={deleteProject}
296 disabled={projectId === undefined}
297 className="cursor-pointer hover:bg-gray-200"
298 >
299 Delete Project
300 </DropdownMenuItem>
301 </DropdownMenuGroup>
302 </DropdownMenuContent>
303 </DropdownMenu>
304 </div>
305 );
306 } else {
307 return (
308 <div className="flex flex-row gap-1 items-center">
309 <Button onClick={deploy} {...deployProps}>
310 Deploy
311 </Button>
312 <Button onClick={save}>Save</Button>
313 <DropdownMenu>
314 <DropdownMenuTrigger>
315 <Menu />
316 </DropdownMenuTrigger>
317 <DropdownMenuContent className="w-56">
318 <DropdownMenuGroup>
319 <DropdownMenuItem
320 onClick={restoreSaved}
321 disabled={projectId === undefined}
322 className="cursor-pointer hover:bg-gray-200"
323 >
324 Restore
325 </DropdownMenuItem>
326 <DropdownMenuItem onClick={clear} className="cursor-pointer hover:bg-gray-200">
327 Clear
328 </DropdownMenuItem>
329 <DropdownMenuItem
330 onClick={deleteProject}
331 disabled={projectId === undefined}
332 className="cursor-pointer hover:bg-gray-200"
333 >
334 Delete Project
335 </DropdownMenuItem>
336 </DropdownMenuGroup>
337 </DropdownMenuContent>
338 </DropdownMenu>
339 </div>
340 );
341 }
giof8acc612025-04-26 08:20:55 +0400342}