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