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