blob: 3027f27bfb8b35686f1db6bf29dd42aa3c6e4a43 [file] [log] [blame]
gio5f2f1002025-03-20 18:38:48 +04001import { v4 as uuidv4 } from "uuid";
2import { NodeRect } from './node-rect';
gio6cf8c272025-05-08 09:01:38 +00003import { useStateStore, ServiceNode, ServiceTypes, nodeLabel, BoundEnvVar, AppState, nodeIsConnectable, GatewayTCPNode, GatewayHttpsNode, AppNode } from '@/lib/state';
gio5f2f1002025-03-20 18:38:48 +04004import { KeyboardEvent, FocusEvent, useCallback, useEffect, useMemo, useState } from 'react';
5import { z } from "zod";
gio6cf8c272025-05-08 09:01:38 +00006import { DeepPartial, EventType, useForm, ControllerRenderProps, FieldPath } from 'react-hook-form';
gio5f2f1002025-03-20 18:38:48 +04007import { zodResolver } from '@hookform/resolvers/zod';
8import { Form, FormControl, FormField, FormItem, FormMessage } from './ui/form';
9import { Input } from './ui/input';
10import { Button } from './ui/button';
gio33990c62025-05-06 07:51:24 +000011import { Handle, Position, useNodes } from "@xyflow/react";
gio5f2f1002025-03-20 18:38:48 +040012import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
giob41ecae2025-04-24 08:46:50 +000013import { PencilIcon, XIcon } from "lucide-react";
gio5f2f1002025-03-20 18:38:48 +040014import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip";
gio91165612025-05-03 17:07:38 +000015import { Textarea } from "./ui/textarea";
gio5f2f1002025-03-20 18:38:48 +040016
17export function NodeApp(node: ServiceNode) {
18 const { id, selected } = node;
19 const isConnectablePorts = useMemo(() => nodeIsConnectable(node, "ports"), [node]);
20 const isConnectableRepository = useMemo(() => nodeIsConnectable(node, "repository"), [node]);
21 return (
gio1dc800a2025-04-24 17:15:43 +000022 <NodeRect id={id} selected={selected} type={node.type} state={node.data.state}>
gio5f2f1002025-03-20 18:38:48 +040023 <div style={{ padding: '10px 20px' }}>
24 {nodeLabel(node)}
25 <Handle
26 id="repository"
27 type={"target"}
28 position={Position.Left}
29 isConnectableStart={isConnectableRepository}
30 isConnectableEnd={isConnectableRepository}
31 isConnectable={isConnectableRepository}
32 />
33 <Handle
34 id="ports"
35 type={"source"}
36 position={Position.Top}
37 isConnectableStart={isConnectablePorts}
38 isConnectableEnd={isConnectablePorts}
39 isConnectable={isConnectablePorts}
40 />
41 <Handle
42 id="env_var"
43 type={"target"}
44 position={Position.Bottom}
45 isConnectableStart={true}
46 isConnectableEnd={true}
47 isConnectable={true}
48 />
49 </div>
50 </NodeRect>
51 );
52}
53
54const schema = z.object({
55 name: z.string().min(1, "requried"),
56 type: z.enum(ServiceTypes),
57});
58
59const portSchema = z.object({
60 name: z.string().min(1, "required"),
61 value: z.coerce.number().gt(0, "can not be negative"),
62});
63
gio33990c62025-05-06 07:51:24 +000064const sourceSchema = z.object({
65 id: z.string().min(1, "required"),
66 branch: z.string(),
67 rootDir: z.string(),
68});
69
gio5f2f1002025-03-20 18:38:48 +040070export function NodeAppDetails({ id, data }: ServiceNode) {
71 const store = useStateStore();
gio6cf8c272025-05-08 09:01:38 +000072 const nodes = useNodes<AppNode>();
gio5f2f1002025-03-20 18:38:48 +040073 const form = useForm<z.infer<typeof schema>>({
74 resolver: zodResolver(schema),
75 mode: "onChange",
76 defaultValues: {
77 name: data.label,
78 type: data.type,
79 }
80 });
81 const portForm = useForm<z.infer<typeof portSchema>>({
82 resolver: zodResolver(portSchema),
83 mode: "onSubmit",
84 defaultValues: {
85 name: "",
86 value: 0,
87 }
88 });
89 const onSubmit = useCallback((values: z.infer<typeof portSchema>) => {
giob41ecae2025-04-24 08:46:50 +000090 const portId = uuidv4();
gio5f2f1002025-03-20 18:38:48 +040091 store.updateNodeData<"app">(id, {
92 ports: (data.ports || []).concat({
giob41ecae2025-04-24 08:46:50 +000093 id: portId,
gio5f2f1002025-03-20 18:38:48 +040094 name: values.name,
95 value: values.value,
gio355883e2025-04-23 14:10:51 +000096 }),
97 envVars: (data.envVars || []).concat({
98 id: uuidv4(),
99 source: null,
giob41ecae2025-04-24 08:46:50 +0000100 portId,
gio355883e2025-04-23 14:10:51 +0000101 name: `DODO_PORT_${values.name.toUpperCase()}`,
102 }),
gio5f2f1002025-03-20 18:38:48 +0400103 });
104 portForm.reset();
gio6cf8c272025-05-08 09:01:38 +0000105 }, [id, data, portForm, store]);
gio5f2f1002025-03-20 18:38:48 +0400106 useEffect(() => {
107 const sub = form.watch((value: DeepPartial<z.infer<typeof schema>>, { name, type }: { name?: keyof z.infer<typeof schema> | undefined, type?: EventType | undefined }) => {
108 console.log({ name, type });
109 if (type !== "change") {
110 return;
111 }
112 switch (name) {
113 case "name":
114 if (!value.name) {
115 break;
116 }
117 store.updateNodeData<"app">(id, {
118 label: value.name,
119 });
120 break;
121 case "type":
122 if (!value.type) {
123 break;
124 }
125 store.updateNodeData<"app">(id, {
126 type: value.type,
127 })
128 break;
129 }
130 });
131 return () => sub.unsubscribe();
gio6cf8c272025-05-08 09:01:38 +0000132 }, [id, form, store]);
133 const focus = useCallback((field: ControllerRenderProps<z.infer<typeof schema>, FieldPath<z.infer<typeof schema>>>, name: string) => {
gio5f2f1002025-03-20 18:38:48 +0400134 return (e: HTMLElement | null) => {
135 field.ref(e);
136 if (e != null && name === data.activeField) {
137 console.log(e);
138 e.focus();
139 store.updateNodeData(id, {
140 activeField: undefined,
141 });
142 }
143 }
gio6cf8c272025-05-08 09:01:38 +0000144 }, [id, data, store]);
gio5f2f1002025-03-20 18:38:48 +0400145 const [typeProps, setTypeProps] = useState({});
146 useEffect(() => {
147 if (data.activeField === "type") {
148 setTypeProps({
149 open: true,
150 onOpenChange: () => store.updateNodeData(id, { activeField: undefined }),
151 });
152 } else {
153 setTypeProps({});
154 }
gio6cf8c272025-05-08 09:01:38 +0000155 }, [id, data, store, setTypeProps]);
gio5f2f1002025-03-20 18:38:48 +0400156 const editAlias = useCallback((e: BoundEnvVar) => {
157 return () => {
158 store.updateNodeData(id, {
159 ...data,
160 envVars: data.envVars!.map((o) => {
161 if (o.id !== e.id) {
162 return o;
163 } else return {
164 ...o,
165 isEditting: true,
166 }
167 }),
168 });
169 };
170 }, [id, data, store]);
gio6cf8c272025-05-08 09:01:38 +0000171 const saveAlias = useCallback((e: BoundEnvVar, value: string, store: AppState) => {
gio5f2f1002025-03-20 18:38:48 +0400172 store.updateNodeData(id, {
173 ...data,
174 envVars: data.envVars!.map((o) => {
175 if (o.id !== e.id) {
176 return o;
177 }
178 if (value) {
179 return {
180 ...o,
181 isEditting: false,
182 alias: value.toUpperCase(),
183 }
184 }
185 console.log(o);
186 if ("alias" in o) {
gio6cf8c272025-05-08 09:01:38 +0000187 const { alias: _, ...rest } = o;
gio5f2f1002025-03-20 18:38:48 +0400188 console.log(rest);
189 return {
190 ...rest,
191 isEditting: false,
192 };
193 }
194 return {
195 ...o,
196 isEditting: false,
197 };
198 }),
199 });
gio6cf8c272025-05-08 09:01:38 +0000200 }, [id, data]);
gio5f2f1002025-03-20 18:38:48 +0400201 const saveAliasOnEnter = useCallback((e: BoundEnvVar) => {
202 return (event: KeyboardEvent<HTMLInputElement>) => {
203 if (event.key === "Enter") {
204 event.preventDefault();
205 saveAlias(e, event.currentTarget.value, store);
206 }
207 }
gio6cf8c272025-05-08 09:01:38 +0000208 }, [store, saveAlias]);
gio5f2f1002025-03-20 18:38:48 +0400209 const saveAliasOnBlur = useCallback((e: BoundEnvVar) => {
210 return (event: FocusEvent<HTMLInputElement>) => {
211 saveAlias(e, event.currentTarget.value, store);
212 }
gio6cf8c272025-05-08 09:01:38 +0000213 }, [store, saveAlias]);
giob41ecae2025-04-24 08:46:50 +0000214 const removePort = useCallback((portId: string) => {
gioaba9a962025-04-25 14:19:40 +0000215 // TODO(gio): this is ugly
216 const tcpRemoved = new Set<string>();
217 console.log(store.edges);
giob41ecae2025-04-24 08:46:50 +0000218 store.setEdges(store.edges.filter((e) => {
219 if (e.source !== id || e.sourceHandle !== "ports") {
220 return true;
221 }
gioaba9a962025-04-25 14:19:40 +0000222 const tn = store.nodes.find((n) => n.id == e.target)!;
giob41ecae2025-04-24 08:46:50 +0000223 if (e.targetHandle === "https") {
gioaba9a962025-04-25 14:19:40 +0000224 const t = tn as GatewayHttpsNode;
225 if (t.data.https?.serviceId === id && t.data.https.portId === portId) {
226 return false;
227 }
228 }
229 if (e.targetHandle === "tcp") {
230 const t = tn as GatewayTCPNode;
231 if (tcpRemoved.has(t.id)) {
232 return true;
233 }
234 if (t.data.exposed.find((e) => e.serviceId === id && e.portId === portId)) {
gioaba9a962025-04-25 14:19:40 +0000235 tcpRemoved.add(t.id);
236 return false;
237 }
giob41ecae2025-04-24 08:46:50 +0000238 }
239 if (e.targetHandle === "env_var") {
giob41ecae2025-04-24 08:46:50 +0000240 if (tn && (tn.data.envVars || []).find((ev) => ev.source === id && "portId" in ev && ev.portId === portId)) {
241 return false;
242 }
243 }
244 return true;
245 }));
246 store.nodes.filter((n) => n.type === "gateway-https" && n.data.https && n.data.https.serviceId === id && n.data.https.portId === portId).forEach((n) => {
247 store.updateNodeData<"gateway-https">(n.id, {
248 https: undefined,
249 });
250 });
gioaba9a962025-04-25 14:19:40 +0000251 store.nodes.filter((n) => n.type === "gateway-tcp").forEach((n) => {
252 const filtered = n.data.exposed.filter((e) => {
253 if (e.serviceId === id && e.portId === portId) {
254 return false;
255 } else {
256 return true;
257 }
258 })
259 if (filtered.length != n.data.exposed.length) {
260 store.updateNodeData<"gateway-tcp">(n.id, {
261 exposed: filtered,
262 });
263 }
264 });
giob41ecae2025-04-24 08:46:50 +0000265 store.nodes.filter((n) => n.type === "app" && n.data.envVars).forEach((n) => {
266 store.updateNodeData<"app">(n.id, {
267 envVars: n.data.envVars.filter((ev) => {
268 if (ev.source === id && "portId" in ev && ev.portId === portId) {
269 return false;
270 }
271 return true;
272 })
273 });
gioaba9a962025-04-25 14:19:40 +0000274 });
giob41ecae2025-04-24 08:46:50 +0000275 store.updateNodeData<"app">(id, {
276 ports: (data.ports || []).filter((p) => p.id !== portId),
277 envVars: (data.envVars || []).filter((ev) => !(ev.source === null && "portId" in ev && ev.portId === portId)),
278 });
279 }, [id, data, store]);
gio91165612025-05-03 17:07:38 +0000280 const setPreBuildCommands = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
281 store.updateNodeData<"app">(id, {
282 preBuildCommands: e.currentTarget.value,
283 });
284 }, [id, store]);
gio33990c62025-05-06 07:51:24 +0000285
286 const sourceForm = useForm<z.infer<typeof sourceSchema>>({
287 resolver: zodResolver(sourceSchema),
288 mode: "onChange",
289 defaultValues: {
290 id: data?.repository?.id,
291 branch: data.repository && "branch" in data.repository ? data.repository.branch : undefined,
292 rootDir: data.repository && "rootDir" in data.repository ? data.repository.rootDir : undefined,
293 },
294 });
295 useEffect(() => {
296 const sub = sourceForm.watch((value: DeepPartial<z.infer<typeof sourceSchema>>, { name }: { name?: keyof z.infer<typeof sourceSchema> | undefined, type?: EventType | undefined }) => {
297 console.log(value);
298 if (name === "id") {
299 let edges = store.edges;
300 if (data?.repository?.id !== undefined) {
301 edges = edges.filter((e) => {
302 if (e.target === id && e.targetHandle === "repository" && e.source === data.repository.id) {
303 return false;
304 } else {
305 return true;
306 }
307 });
308 }
309 if (value.id !== undefined) {
310 edges = edges.concat({
311 id: uuidv4(),
312 source: value.id,
313 sourceHandle: "repository",
314 target: id,
315 targetHandle: "repository",
316 });
317 }
318 store.setEdges(edges);
319 store.updateNodeData<"app">(id, {
320 repository: {
321 id: value.id,
322 },
323 });
324 } else if (name === "branch") {
325 store.updateNodeData<"app">(id, {
326 repository: {
327 ...data?.repository,
328 branch: value.branch,
329 },
330 });
331 } else if (name === "rootDir") {
332 store.updateNodeData<"app">(id, {
333 repository: {
334 ...data?.repository,
335 rootDir: value.rootDir,
336 },
337 });
338 }
339 });
340 return () => sub.unsubscribe();
341 }, [id, data, sourceForm, store]);
342
gio5f2f1002025-03-20 18:38:48 +0400343 return (
344 <>
345 <Form {...form}>
346 <form>
347 <FormField
348 control={form.control}
349 name="name"
350 render={({ field }) => (
351 <FormItem>
352 <FormControl>
353 <Input placeholder="name" className="border border-black" {...field} ref={focus(field, "name")} />
354 </FormControl>
355 <FormMessage />
356 </FormItem>
357 )}
358 />
359 <FormField
360 control={form.control}
361 name="type"
362 render={({ field }) => (
363 <FormItem>
364 <Select onValueChange={field.onChange} defaultValue={field.value} {...typeProps}>
365 <FormControl>
366 <SelectTrigger>
367 <SelectValue placeholder="Runtime" />
368 </SelectTrigger>
369 </FormControl>
370 <SelectContent>
371 {ServiceTypes.map((t) => (
372 <SelectItem key={t} value={t}>{t}</SelectItem>
373 ))}
374 </SelectContent>
375 </Select>
376 <FormMessage />
377 </FormItem>
378 )}
379 />
380 </form>
381 </Form>
gio33990c62025-05-06 07:51:24 +0000382 Source
383 <Form {...sourceForm}>
384 <form className="space-y-2">
385 <FormField
386 control={sourceForm.control}
387 name="id"
388 render={({ field }) => (
389 <FormItem>
390 <Select onValueChange={field.onChange} defaultValue={field.value}>
391 <FormControl>
392 <SelectTrigger>
393 <SelectValue placeholder="Repository" />
394 </SelectTrigger>
395 </FormControl>
396 <SelectContent>
gio6cf8c272025-05-08 09:01:38 +0000397 {(nodes.filter((n) => n.type === "github" && n.data.repository?.id !== undefined) as GithubNode[]).map((n) => (
gio7f98e772025-05-07 11:00:14 +0000398 <SelectItem key={n.id} value={n.id}>{`${n.data.repository?.sshURL}`}</SelectItem>
gio33990c62025-05-06 07:51:24 +0000399 ))}
400 </SelectContent>
401 </Select>
402 <FormMessage />
403 </FormItem>
404 )}
405 />
406 <FormField
407 control={sourceForm.control}
408 name="branch"
409 render={({ field }) => (
410 <FormItem>
411 <FormControl>
412 <Input placeholder="master" className="border border-black" {...field} />
413 </FormControl>
414 <FormMessage />
415 </FormItem>
416 )}
417 />
418 <FormField
419 control={sourceForm.control}
420 name="rootDir"
421 render={({ field }) => (
422 <FormItem>
423 <FormControl>
424 <Input placeholder="/" className="border border-black" {...field} />
425 </FormControl>
426 <FormMessage />
427 </FormItem>
428 )}
429 />
430 </form>
431 </Form>
gio5f2f1002025-03-20 18:38:48 +0400432 Ports
433 <ul>
giob41ecae2025-04-24 08:46:50 +0000434 {data && data.ports && data.ports.map((p) => (<li key={p.id}><Button size={"icon"} variant={"ghost"} onClick={() => removePort(p.id)}><XIcon /></Button> {p.name} - {p.value}</li>))}
gio5f2f1002025-03-20 18:38:48 +0400435 </ul>
436 <Form {...portForm}>
437 <form className="flex flex-row space-x-1" onSubmit={portForm.handleSubmit(onSubmit)}>
438 <FormField
439 control={portForm.control}
440 name="name"
441 render={({ field }) => (
442 <FormItem>
443 <FormControl>
444 <Input placeholder="name" className="border border-black" {...field} />
445 </FormControl>
446 <FormMessage />
447 </FormItem>
448 )}
449 />
450 <FormField
451 control={portForm.control}
452 name="value"
453 render={({ field }) => (
454 <FormItem>
455 <FormControl>
456 <Input placeholder="value" className="border border-black" {...field} />
457 </FormControl>
458 <FormMessage />
459 </FormItem>
460 )}
461 />
462 <Button type="submit">Add Port</Button>
463 </form>
464 </Form>
465 Env Vars
466 <ul>
467 {data && data.envVars && data.envVars.map((v) => {
468 if ("name" in v) {
469 const value = "alias" in v ? v.alias : v.name;
470 if (v.isEditting) {
471 return (<li key={v.id}><Input type="text" className="border border-black" defaultValue={value} onKeyUp={saveAliasOnEnter(v)} onBlur={saveAliasOnBlur(v)} autoFocus={true} /></li>);
472 }
473 return (
474 <li key={v.id} onClick={editAlias(v)}>
475 <TooltipProvider>
476 <Tooltip>
477 <TooltipTrigger>
giob41ecae2025-04-24 08:46:50 +0000478 <Button size={"icon"} variant={"ghost"}><PencilIcon /></Button>
gio5f2f1002025-03-20 18:38:48 +0400479 {value}
480 </TooltipTrigger>
481 <TooltipContent>
482 {v.name}
483 </TooltipContent>
484 </Tooltip>
485 </TooltipProvider>
486 </li>
487 );
488 }
489 })}
490 </ul>
gio91165612025-05-03 17:07:38 +0000491 Pre-Build Commands
492 <Textarea placeholder="new line separated list of commands to run before running the service" value={data.preBuildCommands} onChange={setPreBuildCommands} />
gio5f2f1002025-03-20 18:38:48 +0400493 </>);
494}