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