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