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