blob: cfbbbde9c37e12467024d32956180c850a5f1eef [file] [log] [blame]
gio5f2f1002025-03-20 18:38:48 +04001import { v4 as uuidv4 } from "uuid";
giod0026612025-05-08 13:00:36 +00002import { NodeRect } from "./node-rect";
3import {
4 useStateStore,
5 ServiceNode,
6 ServiceTypes,
7 nodeLabel,
8 BoundEnvVar,
9 AppState,
10 nodeIsConnectable,
11 GatewayTCPNode,
12 GatewayHttpsNode,
13 AppNode,
14} from "@/lib/state";
15import { KeyboardEvent, FocusEvent, useCallback, useEffect, useMemo, useState } from "react";
gio5f2f1002025-03-20 18:38:48 +040016import { z } from "zod";
giod0026612025-05-08 13:00:36 +000017import { DeepPartial, EventType, useForm, ControllerRenderProps, FieldPath } from "react-hook-form";
18import { zodResolver } from "@hookform/resolvers/zod";
19import { Form, FormControl, FormField, FormItem, FormMessage } from "./ui/form";
20import { Input } from "./ui/input";
21import { Button } from "./ui/button";
gio33990c62025-05-06 07:51:24 +000022import { Handle, Position, useNodes } from "@xyflow/react";
gio5f2f1002025-03-20 18:38:48 +040023import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
giob41ecae2025-04-24 08:46:50 +000024import { PencilIcon, XIcon } from "lucide-react";
gio5f2f1002025-03-20 18:38:48 +040025import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip";
gio91165612025-05-03 17:07:38 +000026import { Textarea } from "./ui/textarea";
gio5f2f1002025-03-20 18:38:48 +040027
28export function NodeApp(node: ServiceNode) {
giod0026612025-05-08 13:00:36 +000029 const { id, selected } = node;
30 const isConnectablePorts = useMemo(() => nodeIsConnectable(node, "ports"), [node]);
31 const isConnectableRepository = useMemo(() => nodeIsConnectable(node, "repository"), [node]);
32 return (
33 <NodeRect id={id} selected={selected} type={node.type} state={node.data.state}>
34 <div style={{ padding: "10px 20px" }}>
35 {nodeLabel(node)}
36 <Handle
37 id="repository"
38 type={"target"}
39 position={Position.Left}
40 isConnectableStart={isConnectableRepository}
41 isConnectableEnd={isConnectableRepository}
42 isConnectable={isConnectableRepository}
43 />
44 <Handle
45 id="ports"
46 type={"source"}
47 position={Position.Top}
48 isConnectableStart={isConnectablePorts}
49 isConnectableEnd={isConnectablePorts}
50 isConnectable={isConnectablePorts}
51 />
52 <Handle
53 id="env_var"
54 type={"target"}
55 position={Position.Bottom}
56 isConnectableStart={true}
57 isConnectableEnd={true}
58 isConnectable={true}
59 />
60 </div>
61 </NodeRect>
62 );
gio5f2f1002025-03-20 18:38:48 +040063}
64
65const schema = z.object({
giod0026612025-05-08 13:00:36 +000066 name: z.string().min(1, "requried"),
67 type: z.enum(ServiceTypes),
gio5f2f1002025-03-20 18:38:48 +040068});
69
70const portSchema = z.object({
giod0026612025-05-08 13:00:36 +000071 name: z.string().min(1, "required"),
72 value: z.coerce.number().gt(0, "can not be negative"),
gio5f2f1002025-03-20 18:38:48 +040073});
74
gio33990c62025-05-06 07:51:24 +000075const sourceSchema = z.object({
giod0026612025-05-08 13:00:36 +000076 id: z.string().min(1, "required"),
77 branch: z.string(),
78 rootDir: z.string(),
gio33990c62025-05-06 07:51:24 +000079});
80
gio5f2f1002025-03-20 18:38:48 +040081export function NodeAppDetails({ id, data }: ServiceNode) {
giod0026612025-05-08 13:00:36 +000082 const store = useStateStore();
83 const nodes = useNodes<AppNode>();
84 const form = useForm<z.infer<typeof schema>>({
85 resolver: zodResolver(schema),
86 mode: "onChange",
87 defaultValues: {
88 name: data.label,
89 type: data.type,
90 },
91 });
92 const portForm = useForm<z.infer<typeof portSchema>>({
93 resolver: zodResolver(portSchema),
94 mode: "onSubmit",
95 defaultValues: {
96 name: "",
97 value: 0,
98 },
99 });
100 const onSubmit = useCallback(
101 (values: z.infer<typeof portSchema>) => {
102 const portId = uuidv4();
103 store.updateNodeData<"app">(id, {
104 ports: (data.ports || []).concat({
105 id: portId,
106 name: values.name,
107 value: values.value,
108 }),
109 envVars: (data.envVars || []).concat({
110 id: uuidv4(),
111 source: null,
112 portId,
113 name: `DODO_PORT_${values.name.toUpperCase()}`,
114 }),
115 });
116 portForm.reset();
117 },
118 [id, data, portForm, store],
119 );
120 useEffect(() => {
121 const sub = form.watch(
122 (
123 value: DeepPartial<z.infer<typeof schema>>,
124 { name, type }: { name?: keyof z.infer<typeof schema> | undefined; type?: EventType | undefined },
125 ) => {
126 console.log({ name, type });
127 if (type !== "change") {
128 return;
129 }
130 switch (name) {
131 case "name":
132 if (!value.name) {
133 break;
134 }
135 store.updateNodeData<"app">(id, {
136 label: value.name,
137 });
138 break;
139 case "type":
140 if (!value.type) {
141 break;
142 }
143 store.updateNodeData<"app">(id, {
144 type: value.type,
145 });
146 break;
147 }
148 },
149 );
150 return () => sub.unsubscribe();
151 }, [id, form, store]);
152 const focus = useCallback(
153 (field: ControllerRenderProps<z.infer<typeof schema>, FieldPath<z.infer<typeof schema>>>, name: string) => {
154 return (e: HTMLElement | null) => {
155 field.ref(e);
156 if (e != null && name === data.activeField) {
157 console.log(e);
158 e.focus();
159 store.updateNodeData(id, {
160 activeField: undefined,
161 });
162 }
163 };
164 },
165 [id, data, store],
166 );
167 const [typeProps, setTypeProps] = useState({});
168 useEffect(() => {
169 if (data.activeField === "type") {
170 setTypeProps({
171 open: true,
172 onOpenChange: () => store.updateNodeData(id, { activeField: undefined }),
173 });
174 } else {
175 setTypeProps({});
176 }
177 }, [id, data, store, setTypeProps]);
178 const editAlias = useCallback(
179 (e: BoundEnvVar) => {
180 return () => {
181 store.updateNodeData(id, {
182 ...data,
183 envVars: data.envVars!.map((o) => {
184 if (o.id !== e.id) {
185 return o;
186 } else
187 return {
188 ...o,
189 isEditting: true,
190 };
191 }),
192 });
193 };
194 },
195 [id, data, store],
196 );
197 const saveAlias = useCallback(
198 (e: BoundEnvVar, value: string, store: AppState) => {
199 store.updateNodeData(id, {
200 ...data,
201 envVars: data.envVars!.map((o) => {
202 if (o.id !== e.id) {
203 return o;
204 }
205 if (value) {
206 return {
207 ...o,
208 isEditting: false,
209 alias: value.toUpperCase(),
210 };
211 }
212 console.log(o);
213 if ("alias" in o) {
214 const { alias: _, ...rest } = o;
215 console.log(rest);
216 return {
217 ...rest,
218 isEditting: false,
219 };
220 }
221 return {
222 ...o,
223 isEditting: false,
224 };
225 }),
226 });
227 },
228 [id, data],
229 );
230 const saveAliasOnEnter = useCallback(
231 (e: BoundEnvVar) => {
232 return (event: KeyboardEvent<HTMLInputElement>) => {
233 if (event.key === "Enter") {
234 event.preventDefault();
235 saveAlias(e, event.currentTarget.value, store);
236 }
237 };
238 },
239 [store, saveAlias],
240 );
241 const saveAliasOnBlur = useCallback(
242 (e: BoundEnvVar) => {
243 return (event: FocusEvent<HTMLInputElement>) => {
244 saveAlias(e, event.currentTarget.value, store);
245 };
246 },
247 [store, saveAlias],
248 );
249 const removePort = useCallback(
250 (portId: string) => {
251 // TODO(gio): this is ugly
252 const tcpRemoved = new Set<string>();
253 console.log(store.edges);
254 store.setEdges(
255 store.edges.filter((e) => {
256 if (e.source !== id || e.sourceHandle !== "ports") {
257 return true;
258 }
259 const tn = store.nodes.find((n) => n.id == e.target)!;
260 if (e.targetHandle === "https") {
261 const t = tn as GatewayHttpsNode;
262 if (t.data.https?.serviceId === id && t.data.https.portId === portId) {
263 return false;
264 }
265 }
266 if (e.targetHandle === "tcp") {
267 const t = tn as GatewayTCPNode;
268 if (tcpRemoved.has(t.id)) {
269 return true;
270 }
271 if (t.data.exposed.find((e) => e.serviceId === id && e.portId === portId)) {
272 tcpRemoved.add(t.id);
273 return false;
274 }
275 }
276 if (e.targetHandle === "env_var") {
277 if (
278 tn &&
279 (tn.data.envVars || []).find(
280 (ev) => ev.source === id && "portId" in ev && ev.portId === portId,
281 )
282 ) {
283 return false;
284 }
285 }
286 return true;
287 }),
288 );
289 store.nodes
290 .filter(
291 (n) =>
292 n.type === "gateway-https" &&
293 n.data.https &&
294 n.data.https.serviceId === id &&
295 n.data.https.portId === portId,
296 )
297 .forEach((n) => {
298 store.updateNodeData<"gateway-https">(n.id, {
299 https: undefined,
300 });
301 });
302 store.nodes
303 .filter((n) => n.type === "gateway-tcp")
304 .forEach((n) => {
305 const filtered = n.data.exposed.filter((e) => {
306 if (e.serviceId === id && e.portId === portId) {
307 return false;
308 } else {
309 return true;
310 }
311 });
312 if (filtered.length != n.data.exposed.length) {
313 store.updateNodeData<"gateway-tcp">(n.id, {
314 exposed: filtered,
315 });
316 }
317 });
318 store.nodes
319 .filter((n) => n.type === "app" && n.data.envVars)
320 .forEach((n) => {
321 store.updateNodeData<"app">(n.id, {
322 envVars: n.data.envVars.filter((ev) => {
323 if (ev.source === id && "portId" in ev && ev.portId === portId) {
324 return false;
325 }
326 return true;
327 }),
328 });
329 });
330 store.updateNodeData<"app">(id, {
331 ports: (data.ports || []).filter((p) => p.id !== portId),
332 envVars: (data.envVars || []).filter(
333 (ev) => !(ev.source === null && "portId" in ev && ev.portId === portId),
334 ),
335 });
336 },
337 [id, data, store],
338 );
339 const setPreBuildCommands = useCallback(
340 (e: React.ChangeEvent<HTMLTextAreaElement>) => {
341 store.updateNodeData<"app">(id, {
342 preBuildCommands: e.currentTarget.value,
343 });
344 },
345 [id, store],
346 );
gio33990c62025-05-06 07:51:24 +0000347
giod0026612025-05-08 13:00:36 +0000348 const sourceForm = useForm<z.infer<typeof sourceSchema>>({
349 resolver: zodResolver(sourceSchema),
350 mode: "onChange",
351 defaultValues: {
352 id: data?.repository?.id,
353 branch: data.repository && "branch" in data.repository ? data.repository.branch : undefined,
354 rootDir: data.repository && "rootDir" in data.repository ? data.repository.rootDir : undefined,
355 },
356 });
357 useEffect(() => {
358 const sub = sourceForm.watch(
359 (
360 value: DeepPartial<z.infer<typeof sourceSchema>>,
361 { name }: { name?: keyof z.infer<typeof sourceSchema> | undefined; type?: EventType | undefined },
362 ) => {
363 console.log(value);
364 if (name === "id") {
365 let edges = store.edges;
366 if (data?.repository?.id !== undefined) {
367 edges = edges.filter((e) => {
368 if (e.target === id && e.targetHandle === "repository" && e.source === data.repository.id) {
369 return false;
370 } else {
371 return true;
372 }
373 });
374 }
375 if (value.id !== undefined) {
376 edges = edges.concat({
377 id: uuidv4(),
378 source: value.id,
379 sourceHandle: "repository",
380 target: id,
381 targetHandle: "repository",
382 });
383 }
384 store.setEdges(edges);
385 store.updateNodeData<"app">(id, {
386 repository: {
387 id: value.id,
388 },
389 });
390 } else if (name === "branch") {
391 store.updateNodeData<"app">(id, {
392 repository: {
393 ...data?.repository,
394 branch: value.branch,
395 },
396 });
397 } else if (name === "rootDir") {
398 store.updateNodeData<"app">(id, {
399 repository: {
400 ...data?.repository,
401 rootDir: value.rootDir,
402 },
403 });
404 }
405 },
406 );
407 return () => sub.unsubscribe();
408 }, [id, data, sourceForm, store]);
gio33990c62025-05-06 07:51:24 +0000409
giod0026612025-05-08 13:00:36 +0000410 return (
411 <>
412 <Form {...form}>
413 <form>
414 <FormField
415 control={form.control}
416 name="name"
417 render={({ field }) => (
418 <FormItem>
419 <FormControl>
420 <Input
421 placeholder="name"
422 className="border border-black"
423 {...field}
424 ref={focus(field, "name")}
425 />
426 </FormControl>
427 <FormMessage />
428 </FormItem>
429 )}
430 />
431 <FormField
432 control={form.control}
433 name="type"
434 render={({ field }) => (
435 <FormItem>
436 <Select onValueChange={field.onChange} defaultValue={field.value} {...typeProps}>
437 <FormControl>
438 <SelectTrigger>
439 <SelectValue placeholder="Runtime" />
440 </SelectTrigger>
441 </FormControl>
442 <SelectContent>
443 {ServiceTypes.map((t) => (
444 <SelectItem key={t} value={t}>
445 {t}
446 </SelectItem>
447 ))}
448 </SelectContent>
449 </Select>
450 <FormMessage />
451 </FormItem>
452 )}
453 />
454 </form>
455 </Form>
456 Source
457 <Form {...sourceForm}>
458 <form className="space-y-2">
459 <FormField
460 control={sourceForm.control}
461 name="id"
462 render={({ field }) => (
463 <FormItem>
464 <Select onValueChange={field.onChange} defaultValue={field.value}>
465 <FormControl>
466 <SelectTrigger>
467 <SelectValue placeholder="Repository" />
468 </SelectTrigger>
469 </FormControl>
470 <SelectContent>
471 {(
472 nodes.filter(
473 (n) => n.type === "github" && n.data.repository?.id !== undefined,
474 ) as GithubNode[]
475 ).map((n) => (
476 <SelectItem
477 key={n.id}
478 value={n.id}
479 >{`${n.data.repository?.sshURL}`}</SelectItem>
480 ))}
481 </SelectContent>
482 </Select>
483 <FormMessage />
484 </FormItem>
485 )}
486 />
487 <FormField
488 control={sourceForm.control}
489 name="branch"
490 render={({ field }) => (
491 <FormItem>
492 <FormControl>
493 <Input placeholder="master" className="border border-black" {...field} />
494 </FormControl>
495 <FormMessage />
496 </FormItem>
497 )}
498 />
499 <FormField
500 control={sourceForm.control}
501 name="rootDir"
502 render={({ field }) => (
503 <FormItem>
504 <FormControl>
505 <Input placeholder="/" className="border border-black" {...field} />
506 </FormControl>
507 <FormMessage />
508 </FormItem>
509 )}
510 />
511 </form>
512 </Form>
513 Ports
514 <ul>
515 {data &&
516 data.ports &&
517 data.ports.map((p) => (
518 <li key={p.id}>
519 <Button size={"icon"} variant={"ghost"} onClick={() => removePort(p.id)}>
520 <XIcon />
521 </Button>{" "}
522 {p.name} - {p.value}
523 </li>
524 ))}
525 </ul>
526 <Form {...portForm}>
527 <form className="flex flex-row space-x-1" onSubmit={portForm.handleSubmit(onSubmit)}>
528 <FormField
529 control={portForm.control}
530 name="name"
531 render={({ field }) => (
532 <FormItem>
533 <FormControl>
534 <Input placeholder="name" className="border border-black" {...field} />
535 </FormControl>
536 <FormMessage />
537 </FormItem>
538 )}
539 />
540 <FormField
541 control={portForm.control}
542 name="value"
543 render={({ field }) => (
544 <FormItem>
545 <FormControl>
546 <Input placeholder="value" className="border border-black" {...field} />
547 </FormControl>
548 <FormMessage />
549 </FormItem>
550 )}
551 />
552 <Button type="submit">Add Port</Button>
553 </form>
554 </Form>
555 Env Vars
556 <ul>
557 {data &&
558 data.envVars &&
559 data.envVars.map((v) => {
560 if ("name" in v) {
561 const value = "alias" in v ? v.alias : v.name;
562 if (v.isEditting) {
563 return (
564 <li key={v.id}>
565 <Input
566 type="text"
567 className="border border-black"
568 defaultValue={value}
569 onKeyUp={saveAliasOnEnter(v)}
570 onBlur={saveAliasOnBlur(v)}
571 autoFocus={true}
572 />
573 </li>
574 );
575 }
576 return (
577 <li key={v.id} onClick={editAlias(v)}>
578 <TooltipProvider>
579 <Tooltip>
580 <TooltipTrigger>
581 <Button size={"icon"} variant={"ghost"}>
582 <PencilIcon />
583 </Button>
584 {value}
585 </TooltipTrigger>
586 <TooltipContent>{v.name}</TooltipContent>
587 </Tooltip>
588 </TooltipProvider>
589 </li>
590 );
591 }
592 })}
593 </ul>
594 Pre-Build Commands
595 <Textarea
596 placeholder="new line separated list of commands to run before running the service"
597 value={data.preBuildCommands}
598 onChange={setPreBuildCommands}
599 />
600 </>
601 );
602}