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