blob: 71fc35866516591a60a5ac119e8597b63b953945 [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,
gio48fde052025-05-14 09:48:08 +000015 useEnv,
gio3d0bf032025-06-05 06:57:26 +000016 useGithubRepositories,
giod0026612025-05-08 13:00:36 +000017} from "@/lib/state";
18import { KeyboardEvent, FocusEvent, useCallback, useEffect, useMemo, useState } from "react";
gio5f2f1002025-03-20 18:38:48 +040019import { z } from "zod";
gio3d0bf032025-06-05 06:57:26 +000020import { useForm, EventType, DeepPartial } from "react-hook-form";
giod0026612025-05-08 13:00:36 +000021import { zodResolver } from "@hookform/resolvers/zod";
22import { Form, FormControl, FormField, FormItem, FormMessage } from "./ui/form";
giod0026612025-05-08 13:00:36 +000023import { Button } from "./ui/button";
gio33990c62025-05-06 07:51:24 +000024import { Handle, Position, useNodes } from "@xyflow/react";
gio5f2f1002025-03-20 18:38:48 +040025import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
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";
giofcefd7c2025-05-13 08:01:07 +000028import { Input } from "./ui/input";
gio3d0bf032025-06-05 06:57:26 +000029import { Switch } from "./ui/switch";
gio48fde052025-05-14 09:48:08 +000030import { Label } from "./ui/label";
gio3d0bf032025-06-05 06:57:26 +000031import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs";
32import { Code, Container, Network, Pencil, Variable } from "lucide-react";
33import { Icon } from "./icon";
34import { Badge } from "./ui/badge";
35import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from "./ui/accordion";
gio5f2f1002025-03-20 18:38:48 +040036
37export function NodeApp(node: ServiceNode) {
giod0026612025-05-08 13:00:36 +000038 const { id, selected } = node;
39 const isConnectablePorts = useMemo(() => nodeIsConnectable(node, "ports"), [node]);
40 const isConnectableRepository = useMemo(() => nodeIsConnectable(node, "repository"), [node]);
41 return (
42 <NodeRect id={id} selected={selected} type={node.type} state={node.data.state}>
43 <div style={{ padding: "10px 20px" }}>
44 {nodeLabel(node)}
45 <Handle
46 id="repository"
47 type={"target"}
48 position={Position.Left}
49 isConnectableStart={isConnectableRepository}
50 isConnectableEnd={isConnectableRepository}
51 isConnectable={isConnectableRepository}
52 />
53 <Handle
54 id="ports"
55 type={"source"}
56 position={Position.Top}
57 isConnectableStart={isConnectablePorts}
58 isConnectableEnd={isConnectablePorts}
59 isConnectable={isConnectablePorts}
60 />
61 <Handle
62 id="env_var"
63 type={"target"}
64 position={Position.Bottom}
65 isConnectableStart={true}
66 isConnectableEnd={true}
67 isConnectable={true}
68 />
69 </div>
70 </NodeRect>
71 );
gio5f2f1002025-03-20 18:38:48 +040072}
73
74const schema = z.object({
giod0026612025-05-08 13:00:36 +000075 name: z.string().min(1, "requried"),
76 type: z.enum(ServiceTypes),
gio5f2f1002025-03-20 18:38:48 +040077});
78
gio33990c62025-05-06 07:51:24 +000079const sourceSchema = z.object({
giod0026612025-05-08 13:00:36 +000080 id: z.string().min(1, "required"),
81 branch: z.string(),
82 rootDir: z.string(),
gio33990c62025-05-06 07:51:24 +000083});
84
gio48fde052025-05-14 09:48:08 +000085const devSchema = z.object({
86 enabled: z.boolean(),
87});
88
89const exposeSchema = z.object({
90 network: z.string().min(1, "reqired"),
91 subdomain: z.string().min(1, "required"),
92});
93
gio3d0bf032025-06-05 06:57:26 +000094export function NodeAppDetails({ node, disabled }: { node: ServiceNode; disabled?: boolean }) {
95 const { data } = node;
96 return (
97 <>
98 <Name node={node} disabled={disabled} />
99 <Tabs defaultValue="runtime">
100 <TabsList className="w-full flex flex-row justify-between">
101 <TabsTrigger value="runtime">
102 <TooltipProvider>
103 <Tooltip>
104 <TooltipTrigger>
105 <Container />
106 </TooltipTrigger>
107 <TooltipContent>Runtime</TooltipContent>
108 </Tooltip>
109 </TooltipProvider>
110 </TabsTrigger>
111 <TabsTrigger value="ports">
112 <TooltipProvider>
113 <Tooltip>
114 <TooltipTrigger className="flex flex-row gap-1 items-center">
115 <Network />
116 </TooltipTrigger>
117 <TooltipContent>
118 Ports{" "}
119 <Badge variant="secondary" className="rounded-full">
120 {data.ports?.length ?? 0}
121 </Badge>
122 </TooltipContent>
123 </Tooltip>
124 </TooltipProvider>
125 </TabsTrigger>
126 <TabsTrigger value="vars">
127 <TooltipProvider>
128 <Tooltip>
129 <TooltipTrigger className="flex flex-row gap-1 items-center">
130 <Variable />
131 </TooltipTrigger>
132 <TooltipContent>
133 Variables{" "}
134 <Badge variant="secondary" className="rounded-full">
135 {data.envVars?.length ?? 0}
136 </Badge>
137 </TooltipContent>
138 </Tooltip>
139 </TooltipProvider>
140 </TabsTrigger>
141 <TabsTrigger value="dev">
142 <TooltipProvider>
143 <Tooltip>
144 <TooltipTrigger className="flex flex-row gap-1 items-center">
145 <Code />
146 </TooltipTrigger>
147 <TooltipContent>Dev</TooltipContent>
148 </Tooltip>
149 </TooltipProvider>
150 </TabsTrigger>
151 </TabsList>
152 <TabsContent value="runtime">
153 <Runtime node={node} disabled={disabled} />
154 </TabsContent>
155 <TabsContent value="ports">
156 <Ports node={node} disabled={disabled} />
157 </TabsContent>
158 <TabsContent value="vars">
159 <EnvVars node={node} disabled={disabled} />
160 </TabsContent>
161 <TabsContent value="dev">
162 <Dev node={node} disabled={disabled} />
163 </TabsContent>
164 </Tabs>
165 </>
166 );
167}
168
169function Name({ node, disabled }: { node: ServiceNode; disabled?: boolean }): React.ReactNode {
170 const { id, data } = node;
giod0026612025-05-08 13:00:36 +0000171 const store = useStateStore();
gio3d0bf032025-06-05 06:57:26 +0000172 const [isEditing, setIsEditing] = useState(false);
173 useEffect(() => {
174 if (data.label === "" && !disabled) {
175 setIsEditing(true);
176 }
177 }, [data.label, disabled]);
178 return (
179 <div className="flex flex-row gap-1 items-center">
180 <Icon type="app" />
181 {isEditing ? (
182 <Input
183 placeholder="Name"
184 value={data.label}
185 onChange={(e) => store.updateNodeData(id, { label: e.target.value })}
186 onBlur={() => {
187 if (data.label !== "") {
188 setIsEditing(false);
189 }
190 }}
191 autoFocus={true}
192 />
193 ) : (
194 <h3
195 className="text-lg font-bold cursor-text select-none hover:outline-solid hover:outline-2 hover:outline-gray-200"
196 onClick={() => {
197 if (!disabled) {
198 setIsEditing(true);
199 }
200 }}
201 >
202 {data.label}
203 </h3>
204 )}
205 </div>
206 );
207}
208
209function Runtime({ node, disabled }: { node: ServiceNode; disabled?: boolean }): React.ReactNode {
210 const { id, data } = node;
211 const store = useStateStore();
giod0026612025-05-08 13:00:36 +0000212 const form = useForm<z.infer<typeof schema>>({
213 resolver: zodResolver(schema),
214 mode: "onChange",
215 defaultValues: {
216 name: data.label,
217 type: data.type,
218 },
219 });
giod0026612025-05-08 13:00:36 +0000220 useEffect(() => {
221 const sub = form.watch(
222 (
223 value: DeepPartial<z.infer<typeof schema>>,
224 { name, type }: { name?: keyof z.infer<typeof schema> | undefined; type?: EventType | undefined },
225 ) => {
giod0026612025-05-08 13:00:36 +0000226 if (type !== "change") {
227 return;
228 }
229 switch (name) {
230 case "name":
231 if (!value.name) {
232 break;
233 }
234 store.updateNodeData<"app">(id, {
235 label: value.name,
236 });
237 break;
238 case "type":
239 if (!value.type) {
240 break;
241 }
242 store.updateNodeData<"app">(id, {
243 type: value.type,
244 });
245 break;
246 }
247 },
248 );
249 return () => sub.unsubscribe();
250 }, [id, form, store]);
giod0026612025-05-08 13:00:36 +0000251 const [typeProps, setTypeProps] = useState({});
252 useEffect(() => {
253 if (data.activeField === "type") {
254 setTypeProps({
255 open: true,
256 onOpenChange: () => store.updateNodeData(id, { activeField: undefined }),
257 });
258 } else {
259 setTypeProps({});
260 }
261 }, [id, data, store, setTypeProps]);
gio3d0bf032025-06-05 06:57:26 +0000262 const setPreBuildCommands = useCallback(
263 (e: React.ChangeEvent<HTMLTextAreaElement>) => {
264 store.updateNodeData<"app">(id, {
265 preBuildCommands: e.currentTarget.value,
giod0026612025-05-08 13:00:36 +0000266 });
267 },
gio3d0bf032025-06-05 06:57:26 +0000268 [id, store],
giod0026612025-05-08 13:00:36 +0000269 );
gio3d0bf032025-06-05 06:57:26 +0000270 return (
271 <>
272 <SourceRepo node={node} disabled={disabled} />
273 <Form {...form}>
274 <form className="space-y-2">
275 <Label>Container Image</Label>
276 <FormField
277 control={form.control}
278 name="type"
279 render={({ field }) => (
280 <FormItem>
281 <Select
282 onValueChange={field.onChange}
283 value={field.value || ""}
284 {...typeProps}
285 disabled={disabled}
286 >
287 <FormControl>
288 <SelectTrigger>
289 <SelectValue />
290 </SelectTrigger>
291 </FormControl>
292 <SelectContent>
293 {ServiceTypes.map((t) => (
294 <SelectItem key={t} value={t}>
295 {t}
296 </SelectItem>
297 ))}
298 </SelectContent>
299 </Select>
300 <FormMessage />
301 </FormItem>
302 )}
303 />
304 </form>
305 </Form>
306 <Label>Pre-Build Commands</Label>
307 <Textarea
308 placeholder="new line separated list of commands to run before running the service"
309 value={data.preBuildCommands}
310 onChange={setPreBuildCommands}
311 disabled={disabled}
312 />
313 </>
giod0026612025-05-08 13:00:36 +0000314 );
gio3d0bf032025-06-05 06:57:26 +0000315}
316
317function Ports({ node, disabled }: { node: ServiceNode; disabled?: boolean }): React.ReactNode {
318 const { id, data } = node;
319 const store = useStateStore();
320 const [name, setName] = useState("");
321 const [value, setValue] = useState("");
322 const onSubmit = useCallback(() => {
323 const portId = uuidv4();
324 store.updateNodeData<"app">(id, {
325 ports: (data.ports || []).concat({
326 id: portId,
327 name: name.toUpperCase(),
328 value: Number(value),
329 }),
330 envVars: (data.envVars || []).concat({
331 id: uuidv4(),
332 source: null,
333 portId,
334 name: `DODO_PORT_${name.toUpperCase()}`,
335 }),
336 });
337 setName("");
338 setValue("");
339 }, [id, data, store, name, value, setName, setValue]);
giod0026612025-05-08 13:00:36 +0000340 const removePort = useCallback(
341 (portId: string) => {
342 // TODO(gio): this is ugly
343 const tcpRemoved = new Set<string>();
giod0026612025-05-08 13:00:36 +0000344 store.setEdges(
345 store.edges.filter((e) => {
346 if (e.source !== id || e.sourceHandle !== "ports") {
347 return true;
348 }
349 const tn = store.nodes.find((n) => n.id == e.target)!;
350 if (e.targetHandle === "https") {
351 const t = tn as GatewayHttpsNode;
352 if (t.data.https?.serviceId === id && t.data.https.portId === portId) {
353 return false;
354 }
355 }
356 if (e.targetHandle === "tcp") {
357 const t = tn as GatewayTCPNode;
358 if (tcpRemoved.has(t.id)) {
359 return true;
360 }
361 if (t.data.exposed.find((e) => e.serviceId === id && e.portId === portId)) {
362 tcpRemoved.add(t.id);
363 return false;
364 }
365 }
366 if (e.targetHandle === "env_var") {
367 if (
368 tn &&
369 (tn.data.envVars || []).find(
370 (ev) => ev.source === id && "portId" in ev && ev.portId === portId,
371 )
372 ) {
373 return false;
374 }
375 }
376 return true;
377 }),
378 );
379 store.nodes
380 .filter(
381 (n) =>
382 n.type === "gateway-https" &&
383 n.data.https &&
384 n.data.https.serviceId === id &&
385 n.data.https.portId === portId,
386 )
387 .forEach((n) => {
388 store.updateNodeData<"gateway-https">(n.id, {
389 https: undefined,
390 });
391 });
392 store.nodes
393 .filter((n) => n.type === "gateway-tcp")
394 .forEach((n) => {
395 const filtered = n.data.exposed.filter((e) => {
396 if (e.serviceId === id && e.portId === portId) {
397 return false;
398 } else {
399 return true;
400 }
401 });
402 if (filtered.length != n.data.exposed.length) {
403 store.updateNodeData<"gateway-tcp">(n.id, {
404 exposed: filtered,
405 });
406 }
407 });
408 store.nodes
409 .filter((n) => n.type === "app" && n.data.envVars)
410 .forEach((n) => {
411 store.updateNodeData<"app">(n.id, {
412 envVars: n.data.envVars.filter((ev) => {
413 if (ev.source === id && "portId" in ev && ev.portId === portId) {
414 return false;
415 }
416 return true;
417 }),
418 });
419 });
420 store.updateNodeData<"app">(id, {
421 ports: (data.ports || []).filter((p) => p.id !== portId),
422 envVars: (data.envVars || []).filter(
423 (ev) => !(ev.source === null && "portId" in ev && ev.portId === portId),
424 ),
425 });
426 },
427 [id, data, store],
428 );
gio3d0bf032025-06-05 06:57:26 +0000429 return (
430 <div className="flex flex-col gap-1">
431 <div className="grid grid-cols-[1fr_1fr_auto] gap-1">
432 {data &&
433 data.ports &&
434 data.ports.map((p) => (
435 <>
436 <div className="flex items-center px-3">{p.name.toUpperCase()}</div>
437 <div className="flex items-center px-3">{p.value}</div>
438 <div className="flex items-center">
439 <Button
440 variant="destructive"
441 className="w-full"
442 onClick={() => removePort(p.id)}
443 disabled={disabled}
444 >
445 Remove
446 </Button>
447 </div>
448 </>
449 ))}
450 <div>
451 <Input
452 placeholder="name"
453 className="uppercase w-0 min-w-full"
454 disabled={disabled}
455 value={name}
456 onChange={(e) => setName(e.target.value)}
457 />
458 </div>
459 <div>
460 <Input
461 placeholder="0"
462 className="w-0 min-w-full"
463 disabled={disabled}
464 value={value}
465 onChange={(e) => setValue(e.target.value)}
466 />
467 </div>
468 <div>
469 <Button type="submit" className="w-full" disabled={disabled} onClick={onSubmit}>
470 Add
471 </Button>
472 </div>
473 </div>
474 </div>
475 );
476}
477
478function EnvVars({ node, disabled }: { node: ServiceNode; disabled?: boolean }): React.ReactNode {
479 const { id, data } = node;
480 const store = useStateStore();
481 const editAlias = useCallback(
482 (e: BoundEnvVar) => {
483 return () => {
484 store.updateNodeData(id, {
485 ...data,
486 envVars: data.envVars!.map((o) => {
487 if (o.id !== e.id) {
488 return o;
489 } else
490 return {
491 ...o,
492 isEditting: true,
493 };
494 }),
495 });
496 };
497 },
498 [id, data, store],
499 );
500 const saveAlias = useCallback(
501 (e: BoundEnvVar, value: string, store: AppState) => {
502 store.updateNodeData(id, {
503 ...data,
504 envVars: data.envVars!.map((o) => {
505 if (o.id !== e.id) {
506 return o;
507 }
508 if (value) {
509 return {
510 ...o,
511 isEditting: false,
512 alias: value.toUpperCase(),
513 };
514 }
515 if ("alias" in o) {
516 const { alias: _, ...rest } = o;
517 return {
518 ...rest,
519 isEditting: false,
520 };
521 }
522 return {
523 ...o,
524 isEditting: false,
525 };
526 }),
giod0026612025-05-08 13:00:36 +0000527 });
528 },
gio3d0bf032025-06-05 06:57:26 +0000529 [id, data],
giod0026612025-05-08 13:00:36 +0000530 );
gio3d0bf032025-06-05 06:57:26 +0000531 const saveAliasOnEnter = useCallback(
532 (e: BoundEnvVar) => {
533 return (event: KeyboardEvent<HTMLInputElement>) => {
534 if (event.key === "Enter") {
535 event.preventDefault();
536 saveAlias(e, event.currentTarget.value, store);
giod0026612025-05-08 13:00:36 +0000537 }
gio3d0bf032025-06-05 06:57:26 +0000538 };
539 },
540 [store, saveAlias],
541 );
542 const saveAliasOnBlur = useCallback(
543 (e: BoundEnvVar) => {
544 return (event: FocusEvent<HTMLInputElement>) => {
545 saveAlias(e, event.currentTarget.value, store);
546 };
547 },
548 [store, saveAlias],
549 );
550 return (
551 <ul>
552 {data &&
553 data.envVars &&
554 data.envVars.map((v) => {
555 if ("name" in v) {
556 const value = "alias" in v ? v.alias : v.name;
557 if (v.isEditting) {
558 return (
559 <li key={v.id}>
560 <Input
561 type="text"
562 className="uppercase"
563 defaultValue={value}
564 onKeyUp={saveAliasOnEnter(v)}
565 onBlur={saveAliasOnBlur(v)}
566 autoFocus={true}
567 disabled={disabled}
568 />
569 </li>
570 );
571 }
572 return (
573 <li key={v.id} onClick={editAlias(v)}>
574 <TooltipProvider>
575 <Tooltip>
576 <TooltipTrigger className="w-full">
577 <div className="w-full flex flex-row items-center gap-1 cursor-text">
578 <Pencil className="w-4 h-4" />
579 <div className="uppercase">{value}</div>
580 </div>
581 </TooltipTrigger>
582 <TooltipContent>{v.name}</TooltipContent>
583 </Tooltip>
584 </TooltipProvider>
585 </li>
586 );
587 }
588 })}
589 </ul>
590 );
591}
592
593function Dev({ node, disabled }: { node: ServiceNode; disabled?: boolean }): React.ReactNode {
594 const { id, data } = node;
595 const env = useEnv();
596 const store = useStateStore();
gio48fde052025-05-14 09:48:08 +0000597 const devForm = useForm<z.infer<typeof devSchema>>({
598 resolver: zodResolver(devSchema),
599 mode: "onChange",
600 defaultValues: {
601 enabled: data.dev ? data.dev.enabled : false,
602 },
603 });
604 useEffect(() => {
605 const sub = devForm.watch((value, { name }) => {
606 if (name === "enabled") {
607 if (value.enabled) {
608 const csGateway: Omit<GatewayHttpsNode, "position"> = {
609 id: uuidv4(),
610 type: "gateway-https",
611 data: {
612 readonly: true,
613 https: {
614 serviceId: id,
615 portId: `${id}-code-server`,
616 },
617 network: data.dev?.expose?.network,
618 subdomain: data.dev?.expose?.subdomain,
619 label: "",
620 envVars: [],
621 ports: [],
622 },
623 };
624 const sshGateway: Omit<GatewayTCPNode, "position"> = {
625 id: uuidv4(),
626 type: "gateway-tcp",
627 data: {
628 readonly: true,
629 exposed: [
630 {
631 serviceId: id,
632 portId: `${id}-ssh`,
633 },
634 ],
635 network: data.dev?.expose?.network,
636 subdomain: data.dev?.expose?.subdomain,
637 label: "",
638 envVars: [],
639 ports: [],
640 },
641 };
642 store.addNode(csGateway);
643 store.addNode(sshGateway);
644 store.updateNodeData<"app">(id, {
645 dev: {
646 enabled: true,
647 expose: data.dev?.expose,
648 codeServerNodeId: csGateway.id,
649 sshNodeId: sshGateway.id,
650 },
651 ports: (data.ports || []).concat(
652 {
653 id: `${id}-code-server`,
654 name: "code-server",
655 value: 9090,
656 },
657 {
658 id: `${id}-ssh`,
659 name: "ssh",
660 value: 22,
661 },
662 ),
663 });
664 let edges = store.edges.concat([
665 {
666 id: uuidv4(),
667 source: id,
668 sourceHandle: "ports",
669 target: csGateway.id,
670 targetHandle: "https",
671 },
672 {
673 id: uuidv4(),
674 source: id,
675 sourceHandle: "ports",
676 target: sshGateway.id,
677 targetHandle: "tcp",
678 },
679 ]);
680 if (data.dev?.expose?.network !== undefined) {
681 edges = edges.concat([
682 {
683 id: uuidv4(),
684 source: csGateway.id,
685 sourceHandle: "subdomain",
686 target: data.dev.expose.network,
687 targetHandle: "subdomain",
688 },
689 {
690 id: uuidv4(),
691 source: sshGateway.id,
692 sourceHandle: "subdomain",
693 target: data.dev.expose.network,
694 targetHandle: "subdomain",
695 },
696 ]);
697 }
698 store.setEdges(edges);
699 } else {
700 const { dev } = data;
701 if (dev?.enabled) {
702 store.setNodes(
703 store.nodes.filter((n) => n.id !== dev.codeServerNodeId && n.id !== dev.sshNodeId),
704 );
705 store.setEdges(
706 store.edges.filter((e) => e.target !== dev.codeServerNodeId && e.target !== dev.sshNodeId),
707 );
708 }
709 store.updateNodeData<"app">(id, {
710 dev: {
711 enabled: false,
712 expose: dev?.expose,
713 },
714 ports: (data.ports || []).filter((p) => p.name !== "code-server" && p.name !== "ssh"),
715 });
716 }
717 }
718 });
719 return () => sub.unsubscribe();
720 }, [id, data, devForm, store]);
721 const exposeForm = useForm<z.infer<typeof exposeSchema>>({
722 resolver: zodResolver(exposeSchema),
723 mode: "onChange",
724 defaultValues: {
725 network: data.dev?.expose?.network,
726 subdomain: data.dev?.expose?.subdomain,
727 },
728 });
729 useEffect(() => {
730 const sub = exposeForm.watch(
731 (
732 value: DeepPartial<z.infer<typeof exposeSchema>>,
733 { name }: { name?: keyof z.infer<typeof exposeSchema> | undefined; type?: EventType | undefined },
734 ) => {
735 const { dev } = data;
736 if (!dev?.enabled) {
737 return;
738 }
739 if (name === "network") {
740 let edges = store.edges;
741 if (dev.enabled && dev.expose?.network !== undefined) {
742 edges = edges.filter((e) => {
743 if (
744 (e.source === dev.codeServerNodeId || e.source === dev.sshNodeId) &&
745 e.sourceHandle === "subdomain" &&
746 e.target === dev.expose?.network &&
747 e.targetHandle === "subdomain"
748 ) {
749 return false;
750 } else {
751 return true;
752 }
753 });
754 }
755 if (value.network !== undefined) {
756 edges = edges.concat(
757 {
758 id: uuidv4(),
759 source: dev.codeServerNodeId,
760 sourceHandle: "subdomain",
761 target: value.network,
762 targetHandle: "subdomain",
763 },
764 {
765 id: uuidv4(),
766 source: dev.sshNodeId,
767 sourceHandle: "subdomain",
768 target: value.network,
769 targetHandle: "subdomain",
770 },
771 );
772 }
773 store.setEdges(edges);
774 store.updateNodeData<"app">(id, {
775 dev: {
776 ...dev,
777 expose: {
778 network: value.network,
779 subdomain: dev.expose?.subdomain,
780 },
781 },
782 });
783 store.updateNodeData<"gateway-https">(dev.codeServerNodeId, { network: value.network });
784 store.updateNodeData<"gateway-tcp">(dev.sshNodeId, { network: value.network });
785 } else if (name === "subdomain") {
786 store.updateNodeData<"app">(id, {
787 dev: {
788 ...dev,
789 expose: {
790 network: dev.expose?.network,
791 subdomain: value.subdomain,
792 },
793 },
794 });
795 store.updateNodeData<"gateway-https">(dev.codeServerNodeId, { subdomain: value.subdomain });
796 store.updateNodeData<"gateway-tcp">(dev.sshNodeId, { subdomain: value.subdomain });
797 }
798 },
799 );
800 return () => sub.unsubscribe();
801 }, [id, data, exposeForm, store]);
giod0026612025-05-08 13:00:36 +0000802 return (
803 <>
gio48fde052025-05-14 09:48:08 +0000804 <Form {...devForm}>
805 <form className="space-y-2">
806 <FormField
807 control={devForm.control}
808 name="enabled"
809 render={({ field }) => (
810 <FormItem>
811 <div className="flex flex-row gap-1 items-center">
gio3d0bf032025-06-05 06:57:26 +0000812 <Switch
gio3ec94242025-05-16 12:46:57 +0000813 id="devEnabled"
814 onCheckedChange={field.onChange}
815 checked={field.value}
816 disabled={disabled}
817 />
gio3d0bf032025-06-05 06:57:26 +0000818 <Label htmlFor="devEnabled">Dev VM</Label>
gio48fde052025-05-14 09:48:08 +0000819 </div>
820 <FormMessage />
821 </FormItem>
822 )}
823 />
824 </form>
825 </Form>
gio29050d62025-05-16 04:49:26 +0000826 {data.dev && data.dev.enabled && (
827 <Form {...exposeForm}>
828 <form className="space-y-2">
gio3d0bf032025-06-05 06:57:26 +0000829 <Label>Network</Label>
gio29050d62025-05-16 04:49:26 +0000830 <FormField
831 control={exposeForm.control}
832 name="network"
833 render={({ field }) => (
834 <FormItem>
gio3ec94242025-05-16 12:46:57 +0000835 <Select
836 onValueChange={field.onChange}
gio3d0bf032025-06-05 06:57:26 +0000837 value={field.value || ""}
gio3ec94242025-05-16 12:46:57 +0000838 disabled={disabled}
839 >
gio29050d62025-05-16 04:49:26 +0000840 <FormControl>
841 <SelectTrigger>
gio3d0bf032025-06-05 06:57:26 +0000842 <SelectValue />
gio29050d62025-05-16 04:49:26 +0000843 </SelectTrigger>
844 </FormControl>
845 <SelectContent>
846 {env.networks.map((n) => (
847 <SelectItem
848 key={n.name}
849 value={n.domain}
850 >{`${n.name} - ${n.domain}`}</SelectItem>
851 ))}
852 </SelectContent>
853 </Select>
854 <FormMessage />
855 </FormItem>
856 )}
857 />
gio3d0bf032025-06-05 06:57:26 +0000858 <Label>Subdomain</Label>
gio29050d62025-05-16 04:49:26 +0000859 <FormField
860 control={exposeForm.control}
861 name="subdomain"
862 render={({ field }) => (
863 <FormItem>
gio48fde052025-05-14 09:48:08 +0000864 <FormControl>
gio3d0bf032025-06-05 06:57:26 +0000865 <Input {...field} disabled={disabled} />
gio48fde052025-05-14 09:48:08 +0000866 </FormControl>
gio29050d62025-05-16 04:49:26 +0000867 <FormMessage />
868 </FormItem>
869 )}
870 />
871 </form>
872 </Form>
873 )}
giod0026612025-05-08 13:00:36 +0000874 </>
875 );
876}
gio3d0bf032025-06-05 06:57:26 +0000877
878function SourceRepo({ node, disabled }: { node: ServiceNode; disabled?: boolean }): React.ReactNode {
879 const { id, data } = node;
880 const store = useStateStore();
881 const nodes = useNodes<AppNode>();
882 const repo = useMemo(() => {
883 return nodes
884 .filter((n): n is GithubNode => n.type === "github")
885 .find((n) => n.id === data.repository?.repoNodeId);
886 }, [nodes, data.repository?.repoNodeId]);
887 const repos = useGithubRepositories();
888 const sourceForm = useForm<z.infer<typeof sourceSchema>>({
889 resolver: zodResolver(sourceSchema),
890 mode: "onChange",
891 defaultValues: {
892 id: data?.repository?.id?.toString(),
893 branch: data.repository && "branch" in data.repository ? data.repository.branch : undefined,
894 rootDir: data.repository && "rootDir" in data.repository ? data.repository.rootDir : undefined,
895 },
896 });
897 useEffect(() => {
898 const sub = sourceForm.watch(
899 (
900 value: DeepPartial<z.infer<typeof sourceSchema>>,
901 { name }: { name?: keyof z.infer<typeof sourceSchema> | undefined; type?: EventType | undefined },
902 ) => {
903 if (name === "id") {
904 const newRepoId = value.id ? parseInt(value.id, 10) : undefined;
905 if (!newRepoId) return;
906
907 const oldGithubNodeId = data.repository?.repoNodeId;
908 const selectedRepo = repos.find((r) => r.id === newRepoId);
909
910 if (!selectedRepo) return;
911
912 // If a node for the selected repo already exists, connect to it.
913 const existingNodeForSelectedRepo = nodes
914 .filter((n): n is GithubNode => n.type === "github")
915 .find((n) => n.data.repository?.id === selectedRepo.id);
916
917 if (existingNodeForSelectedRepo) {
918 let { nodes, edges } = store;
919 if (oldGithubNodeId) {
920 edges = edges.filter(
921 (e) =>
922 !(
923 e.target === id &&
924 e.source === oldGithubNodeId &&
925 e.targetHandle === "repository"
926 ),
927 );
928 }
929 edges = edges.concat({
930 id: uuidv4(),
931 source: existingNodeForSelectedRepo.id,
932 sourceHandle: "repository",
933 target: id,
934 targetHandle: "repository",
935 });
936 nodes = nodes.map((n) => {
937 if (n.id !== id) {
938 return n;
939 } else {
940 const sn = n as ServiceNode;
941 return {
942 ...sn,
943 data: {
944 ...sn.data,
945 repository: {
946 ...sn.data.repository,
947 id: newRepoId,
948 repoNodeId: existingNodeForSelectedRepo.id,
949 },
950 },
951 };
952 }
953 });
954 if (oldGithubNodeId && oldGithubNodeId !== existingNodeForSelectedRepo.id) {
955 const isOldNodeStillUsed = edges.some(
956 (e) => e.source === oldGithubNodeId && e.sourceHandle === "repository",
957 );
958 if (!isOldNodeStillUsed) {
959 nodes = nodes.filter((n) => n.id !== oldGithubNodeId);
960 }
961 }
962 store.setNodes(nodes);
963 store.setEdges(edges);
964 return;
965 }
966
967 // No node for selected repo, decide whether to update old node or create a new one.
968 if (oldGithubNodeId) {
969 const isOldNodeShared =
970 store.edges.filter(
971 (e) =>
972 e.source === oldGithubNodeId && e.target !== id && e.sourceHandle === "repository",
973 ).length > 0;
974
975 if (!isOldNodeShared) {
976 // Update old node
977 store.updateNodeData<"github">(oldGithubNodeId, {
978 repository: {
979 id: selectedRepo.id,
980 sshURL: selectedRepo.ssh_url,
981 fullName: selectedRepo.full_name,
982 },
983 label: selectedRepo.full_name,
984 });
985 store.updateNodeData<"app">(id, {
986 repository: {
987 ...data.repository,
988 id: newRepoId,
989 },
990 });
991 } else {
992 // Create new node because old one is shared
993 const newGithubNodeId = uuidv4();
994 store.addNode({
995 id: newGithubNodeId,
996 type: "github",
997 data: {
998 repository: {
999 id: selectedRepo.id,
1000 sshURL: selectedRepo.ssh_url,
1001 fullName: selectedRepo.full_name,
1002 },
1003 label: selectedRepo.full_name,
1004 envVars: [],
1005 ports: [],
1006 },
1007 });
1008
1009 let edges = store.edges;
1010 // remove old edge
1011 edges = edges.filter(
1012 (e) =>
1013 !(
1014 e.target === id &&
1015 e.source === oldGithubNodeId &&
1016 e.targetHandle === "repository"
1017 ),
1018 );
1019 // add new edge
1020 edges = edges.concat({
1021 id: uuidv4(),
1022 source: newGithubNodeId,
1023 sourceHandle: "repository",
1024 target: id,
1025 targetHandle: "repository",
1026 });
1027 store.setEdges(edges);
1028 store.updateNodeData<"app">(id, {
1029 repository: {
1030 ...data.repository,
1031 id: newRepoId,
1032 repoNodeId: newGithubNodeId,
1033 },
1034 });
1035 }
1036 } else {
1037 // No old github node, so create a new one
1038 const newGithubNodeId = uuidv4();
1039 store.addNode({
1040 id: newGithubNodeId,
1041 type: "github",
1042 data: {
1043 repository: {
1044 id: selectedRepo.id,
1045 sshURL: selectedRepo.ssh_url,
1046 fullName: selectedRepo.full_name,
1047 },
1048 label: selectedRepo.full_name,
1049 envVars: [],
1050 ports: [],
1051 },
1052 });
1053 store.setEdges(
1054 store.edges.concat({
1055 id: uuidv4(),
1056 source: newGithubNodeId,
1057 sourceHandle: "repository",
1058 target: id,
1059 targetHandle: "repository",
1060 }),
1061 );
1062 store.updateNodeData<"app">(id, {
1063 repository: {
1064 ...data.repository,
1065 id: newRepoId,
1066 repoNodeId: newGithubNodeId,
1067 },
1068 });
1069 }
1070 } else if (name === "branch") {
1071 store.updateNodeData<"app">(id, {
1072 repository: {
1073 ...data?.repository,
1074 branch: value.branch,
1075 },
1076 });
1077 } else if (name === "rootDir") {
1078 store.updateNodeData<"app">(id, {
1079 repository: {
1080 ...data?.repository,
1081 rootDir: value.rootDir,
1082 },
1083 });
1084 }
1085 },
1086 );
1087 return () => sub.unsubscribe();
1088 }, [id, data, sourceForm, store, nodes, repos]);
1089 const [isExpanded, setIsExpanded] = useState(false);
1090 // useEffect(() => {
1091 // if (data.repository === undefined) {
1092 // setIsExpanded(true);
1093 // }
1094 // }, [data.repository, setIsExpanded]);
1095 console.log(data.repository, isExpanded, repo);
1096 return (
1097 <Accordion type="single" collapsible>
1098 <AccordionItem value="repository" className="border-none">
1099 <AccordionTrigger onClick={() => setIsExpanded(!isExpanded)}>
1100 Source {!isExpanded && repo !== undefined && repo.data.repository?.fullName}
1101 </AccordionTrigger>
1102 <AccordionContent className="px-1">
1103 <Form {...sourceForm}>
1104 <form className="space-y-2">
1105 <Label>Repository</Label>
1106 <FormField
1107 control={sourceForm.control}
1108 name="id"
1109 render={({ field }) => (
1110 <FormItem>
1111 <Select onValueChange={field.onChange} value={field.value} disabled={disabled}>
1112 <FormControl>
1113 <SelectTrigger>
1114 <SelectValue />
1115 </SelectTrigger>
1116 </FormControl>
1117 <SelectContent>
1118 {repos.map((r) => (
1119 <SelectItem
1120 key={r.id}
1121 value={r.id.toString()}
1122 >{`${r.full_name}`}</SelectItem>
1123 ))}
1124 </SelectContent>
1125 </Select>
1126 <FormMessage />
1127 </FormItem>
1128 )}
1129 />
1130 <Label>Branch</Label>
1131 <FormField
1132 control={sourceForm.control}
1133 name="branch"
1134 render={({ field }) => (
1135 <FormItem>
1136 <FormControl>
1137 <Input
1138 placeholder="master"
1139 className="lowercase"
1140 {...field}
1141 disabled={disabled}
1142 />
1143 </FormControl>
1144 <FormMessage />
1145 </FormItem>
1146 )}
1147 />
1148 <Label>Root Directory</Label>
1149 <FormField
1150 control={sourceForm.control}
1151 name="rootDir"
1152 render={({ field }) => (
1153 <FormItem>
1154 <FormControl>
1155 <Input placeholder="/" {...field} disabled={disabled} />
1156 </FormControl>
1157 <FormMessage />
1158 </FormItem>
1159 )}
1160 />
1161 </form>
1162 </Form>
1163 </AccordionContent>
1164 </AccordionItem>
1165 </Accordion>
1166 );
1167}