blob: 15e2fa364b499e71d738b90be1ffc6e77239bd98 [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";
gio9f3d4f52025-07-04 08:42:34 +000024import { Gateway } from "@/Gateways";
gio2e7d2172025-07-04 09:24:53 +000025import { Port } from "config";
26import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
27
28const sourceSchema = z.object({
29 id: z.string().min(1, "required"),
30 branch: z.string(),
31 rootDir: z.string(),
32});
33
34const devSchema = z.object({
35 enabled: z.boolean(),
36});
37
38const exposeSchema = z.object({
39 network: z.string().min(1, "reqired"),
40 subdomain: z.string().min(1, "required"),
41});
42
43const agentSchema = z.object({
44 model: z.enum(["gemini", "claude"]),
45 apiKey: z.string().optional(),
46});
47
48const portExposeSchema = z
49 .object({
50 type: z.enum(["https", "tcp"]),
51 network: z.string().min(1, "Required"),
52 subdomain: z.string().optional(),
53 })
54 .refine(
55 (data) => {
56 if (data.type === "https" || data.type === "tcp") {
57 return !!data.subdomain && data.subdomain.length > 0;
58 }
59 return true;
60 },
61 {
62 message: "Subdomain is required",
63 path: ["subdomain"],
64 },
65 );
66
67type PortExposeFormValues = z.infer<typeof portExposeSchema>;
gio5f2f1002025-03-20 18:38:48 +040068
69export function NodeApp(node: ServiceNode) {
giod0026612025-05-08 13:00:36 +000070 const { id, selected } = node;
71 const isConnectablePorts = useMemo(() => nodeIsConnectable(node, "ports"), [node]);
72 const isConnectableRepository = useMemo(() => nodeIsConnectable(node, "repository"), [node]);
73 return (
gio69148322025-06-19 23:16:12 +040074 <NodeRect id={id} selected={selected} node={node} state={node.data.state}>
giod0026612025-05-08 13:00:36 +000075 <div style={{ padding: "10px 20px" }}>
76 {nodeLabel(node)}
77 <Handle
78 id="repository"
79 type={"target"}
80 position={Position.Left}
81 isConnectableStart={isConnectableRepository}
82 isConnectableEnd={isConnectableRepository}
83 isConnectable={isConnectableRepository}
84 />
85 <Handle
86 id="ports"
87 type={"source"}
88 position={Position.Top}
89 isConnectableStart={isConnectablePorts}
90 isConnectableEnd={isConnectablePorts}
91 isConnectable={isConnectablePorts}
92 />
93 <Handle
94 id="env_var"
95 type={"target"}
96 position={Position.Bottom}
97 isConnectableStart={true}
98 isConnectableEnd={true}
99 isConnectable={true}
100 />
101 </div>
102 </NodeRect>
103 );
gio5f2f1002025-03-20 18:38:48 +0400104}
105
106const schema = z.object({
giod0026612025-05-08 13:00:36 +0000107 name: z.string().min(1, "requried"),
108 type: z.enum(ServiceTypes),
gio5f2f1002025-03-20 18:38:48 +0400109});
110
gio2e7d2172025-07-04 09:24:53 +0000111function ExposeForm({
112 node,
113 port,
114 onDone,
115 disabled,
116}: {
117 node: ServiceNode;
118 port: Port;
119 onDone: () => void;
120 disabled?: boolean;
121}) {
122 const store = useStateStore();
123 const nodes = useNodes<AppNode>();
124 const env = useEnv();
125 const form = useForm<PortExposeFormValues>({
126 resolver: zodResolver(portExposeSchema),
127 mode: "onChange",
128 defaultValues: {
129 type: "https",
130 },
131 });
gio33990c62025-05-06 07:51:24 +0000132
gio2e7d2172025-07-04 09:24:53 +0000133 const onSubmit = (data: PortExposeFormValues) => {
134 const networkNode = nodes.find((n) => n.type === "network" && n.data.domain === data.network);
135 if (!networkNode) {
136 // TODO: should show an error to the user
137 return;
138 }
139 if (data.type === "https") {
140 const newNode: Omit<GatewayHttpsNode, "position"> = {
141 id: uuidv4(),
142 type: "gateway-https",
143 data: {
144 https: {
145 serviceId: node.id,
146 portId: port.id,
147 },
148 network: data.network,
149 subdomain: data.subdomain!,
150 label: "",
151 envVars: [],
152 ports: [],
153 },
154 };
155 store.addNode(newNode);
156 store.setEdges(
157 store.edges.concat(
158 {
159 id: uuidv4(),
160 source: node.id,
161 sourceHandle: "ports",
162 target: newNode.id,
163 targetHandle: "https",
164 },
165 {
166 id: uuidv4(),
167 source: newNode.id,
168 sourceHandle: "subdomain",
169 target: networkNode.id,
170 targetHandle: "subdomain",
171 },
172 ),
173 );
174 } else if (data.type === "tcp") {
175 const existingGateway = nodes.find(
176 (n): n is GatewayTCPNode =>
177 n.type === "gateway-tcp" && n.data.network === data.network && n.data.subdomain === data.subdomain,
178 );
179 if (existingGateway) {
180 store.updateNodeData<"gateway-tcp">(existingGateway.id, {
181 exposed: [...existingGateway.data.exposed, { serviceId: node.id, portId: port.id }],
182 });
183 let edges = store.edges.concat({
184 id: uuidv4(),
185 source: node.id,
186 sourceHandle: "ports",
187 target: existingGateway.id,
188 targetHandle: "tcp",
189 });
190 if (
191 !edges.find(
192 (e) =>
193 e.source === existingGateway.id &&
194 e.target === networkNode.id &&
195 e.sourceHandle === "subdomain" &&
196 e.targetHandle === "subdomain",
197 )
198 ) {
199 edges = edges.concat({
200 id: uuidv4(),
201 source: existingGateway.id,
202 sourceHandle: "subdomain",
203 target: networkNode.id,
204 targetHandle: "subdomain",
205 });
206 }
207 store.setEdges(edges);
208 } else {
209 const newNode: Omit<GatewayTCPNode, "position"> = {
210 id: uuidv4(),
211 type: "gateway-tcp",
212 data: {
213 exposed: [{ serviceId: node.id, portId: port.id }],
214 network: data.network,
215 subdomain: data.subdomain,
216 label: "",
217 envVars: [],
218 ports: [],
219 },
220 };
221 store.addNode(newNode);
222 store.setEdges(
223 store.edges.concat(
224 {
225 id: uuidv4(),
226 source: node.id,
227 sourceHandle: "ports",
228 target: newNode.id,
229 targetHandle: "tcp",
230 },
231 {
232 id: uuidv4(),
233 source: newNode.id,
234 sourceHandle: "subdomain",
235 target: networkNode.id,
236 targetHandle: "subdomain",
237 },
238 ),
239 );
240 }
241 }
242 onDone();
243 };
gio48fde052025-05-14 09:48:08 +0000244
gio2e7d2172025-07-04 09:24:53 +0000245 const type = form.watch("type");
gio48fde052025-05-14 09:48:08 +0000246
gio2e7d2172025-07-04 09:24:53 +0000247 return (
248 <Form {...form}>
249 <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-2 border-t mt-2 pt-2">
250 <FormField
251 control={form.control}
252 name="type"
253 render={({ field }) => (
254 <FormItem>
255 <FormLabel>Gateway Type</FormLabel>
256 <Select onValueChange={field.onChange} defaultValue={field.value} disabled={disabled}>
257 <FormControl>
258 <SelectTrigger>
259 <SelectValue placeholder="Select a type" />
260 </SelectTrigger>
261 </FormControl>
262 <SelectContent>
263 <SelectItem value="https">HTTPS</SelectItem>
264 <SelectItem value="tcp">TCP</SelectItem>
265 </SelectContent>
266 </Select>
267 <FormMessage />
268 </FormItem>
269 )}
270 />
271 <FormField
272 control={form.control}
273 name="network"
274 render={({ field }) => (
275 <FormItem>
276 <FormLabel>Network</FormLabel>
277 <Select onValueChange={field.onChange} defaultValue={field.value} disabled={disabled}>
278 <FormControl>
279 <SelectTrigger>
280 <SelectValue placeholder="Select a network" />
281 </SelectTrigger>
282 </FormControl>
283 <SelectContent>
284 {env.networks.map((n) => (
285 <SelectItem key={n.domain} value={n.domain}>
286 {n.name} - {n.domain}
287 </SelectItem>
288 ))}
289 </SelectContent>
290 </Select>
291 <FormMessage />
292 </FormItem>
293 )}
294 />
295 {(type === "https" || type === "tcp") && (
296 <FormField
297 control={form.control}
298 name="subdomain"
299 render={({ field }) => (
300 <FormItem>
301 <FormLabel>Subdomain</FormLabel>
302 <FormControl>
303 <Input placeholder="subdomain" {...field} disabled={disabled} />
304 </FormControl>
305 <FormMessage />
306 </FormItem>
307 )}
308 />
309 )}
310 <div className="flex justify-end gap-2">
311 <Button type="button" variant="ghost" onClick={onDone} disabled={disabled}>
312 Cancel
313 </Button>
314 <Button type="submit" disabled={disabled || !form.formState.isValid}>
315 Expose
316 </Button>
317 </div>
318 </form>
319 </Form>
320 );
321}
gio69148322025-06-19 23:16:12 +0400322
gioe7734b22025-06-13 10:12:04 +0000323export function NodeAppDetails({ node, disabled, showName = true, isOverview = false }: NodeDetailsProps<ServiceNode>) {
gio3d0bf032025-06-05 06:57:26 +0000324 const { data } = node;
325 return (
326 <>
gio3fb133d2025-06-13 07:20:24 +0000327 {showName ? <Name node={node} disabled={disabled} /> : null}
gio3d0bf032025-06-05 06:57:26 +0000328 <Tabs defaultValue="runtime">
329 <TabsList className="w-full flex flex-row justify-between">
330 <TabsTrigger value="runtime">
gioe7734b22025-06-13 10:12:04 +0000331 {isOverview ? (
332 <div className="flex flex-row gap-1 items-center">
333 <Container /> Runtime
334 </div>
335 ) : (
336 <TooltipProvider>
337 <Tooltip>
338 <TooltipTrigger>
339 <Container />
340 </TooltipTrigger>
341 <TooltipContent>Runtime</TooltipContent>
342 </Tooltip>
343 </TooltipProvider>
344 )}
gio3d0bf032025-06-05 06:57:26 +0000345 </TabsTrigger>
346 <TabsTrigger value="ports">
gioe7734b22025-06-13 10:12:04 +0000347 {isOverview ? (
348 <div className="flex flex-row gap-1 items-center">
349 <Network /> Ports
350 <Badge className="rounded-full">{data.ports?.length ?? 0}</Badge>
351 </div>
352 ) : (
353 <TooltipProvider>
354 <Tooltip>
355 <TooltipTrigger className="flex flex-row gap-1 items-center">
356 <Network />
357 </TooltipTrigger>
358 <TooltipContent>
359 Ports{" "}
360 <Badge variant="secondary" className="rounded-full">
361 {data.ports?.length ?? 0}
362 </Badge>
363 </TooltipContent>
364 </Tooltip>
365 </TooltipProvider>
366 )}
gio3d0bf032025-06-05 06:57:26 +0000367 </TabsTrigger>
368 <TabsTrigger value="vars">
gioe7734b22025-06-13 10:12:04 +0000369 {isOverview ? (
370 <div className="flex flex-row gap-1 items-center">
371 <Variable /> Variables
372 <Badge className="rounded-full">{data.envVars?.length ?? 0}</Badge>
373 </div>
374 ) : (
375 <TooltipProvider>
376 <Tooltip>
377 <TooltipTrigger className="flex flex-row gap-1 items-center">
378 <Variable />
379 </TooltipTrigger>
380 <TooltipContent>
381 Variables{" "}
382 <Badge variant="secondary" className="rounded-full">
383 {data.envVars?.length ?? 0}
384 </Badge>
385 </TooltipContent>
386 </Tooltip>
387 </TooltipProvider>
388 )}
gio3d0bf032025-06-05 06:57:26 +0000389 </TabsTrigger>
gio69148322025-06-19 23:16:12 +0400390 {node.data.type !== "sketch:latest" && (
391 <TabsTrigger value="dev">
392 {isOverview ? (
393 <div className="flex flex-row gap-1 items-center">
394 <Code /> Dev
395 </div>
396 ) : (
397 <TooltipProvider>
398 <Tooltip>
399 <TooltipTrigger className="flex flex-row gap-1 items-center">
400 <Code />
401 </TooltipTrigger>
402 <TooltipContent>Dev</TooltipContent>
403 </Tooltip>
404 </TooltipProvider>
405 )}
406 </TabsTrigger>
407 )}
gio3d0bf032025-06-05 06:57:26 +0000408 </TabsList>
409 <TabsContent value="runtime">
410 <Runtime node={node} disabled={disabled} />
411 </TabsContent>
412 <TabsContent value="ports">
gio2e7d2172025-07-04 09:24:53 +0000413 <Ports node={node} disabled={disabled} isOverview={isOverview} />
gio3d0bf032025-06-05 06:57:26 +0000414 </TabsContent>
415 <TabsContent value="vars">
416 <EnvVars node={node} disabled={disabled} />
417 </TabsContent>
gio69148322025-06-19 23:16:12 +0400418 {node.data.type !== "sketch:latest" && (
419 <TabsContent value="dev">
420 <Dev node={node} disabled={disabled} />
421 </TabsContent>
422 )}
gio3d0bf032025-06-05 06:57:26 +0000423 </Tabs>
424 </>
425 );
426}
427
gio3d0bf032025-06-05 06:57:26 +0000428function Runtime({ node, disabled }: { node: ServiceNode; disabled?: boolean }): React.ReactNode {
429 const { id, data } = node;
430 const store = useStateStore();
giod0026612025-05-08 13:00:36 +0000431 const form = useForm<z.infer<typeof schema>>({
432 resolver: zodResolver(schema),
433 mode: "onChange",
434 defaultValues: {
435 name: data.label,
436 type: data.type,
437 },
438 });
giod0026612025-05-08 13:00:36 +0000439 useEffect(() => {
440 const sub = form.watch(
441 (
442 value: DeepPartial<z.infer<typeof schema>>,
443 { name, type }: { name?: keyof z.infer<typeof schema> | undefined; type?: EventType | undefined },
444 ) => {
giod0026612025-05-08 13:00:36 +0000445 if (type !== "change") {
446 return;
447 }
448 switch (name) {
449 case "name":
450 if (!value.name) {
451 break;
452 }
453 store.updateNodeData<"app">(id, {
454 label: value.name,
455 });
456 break;
457 case "type":
458 if (!value.type) {
459 break;
460 }
461 store.updateNodeData<"app">(id, {
462 type: value.type,
463 });
464 break;
465 }
466 },
467 );
468 return () => sub.unsubscribe();
469 }, [id, form, store]);
giod0026612025-05-08 13:00:36 +0000470 const [typeProps, setTypeProps] = useState({});
471 useEffect(() => {
472 if (data.activeField === "type") {
473 setTypeProps({
474 open: true,
475 onOpenChange: () => store.updateNodeData(id, { activeField: undefined }),
476 });
477 } else {
478 setTypeProps({});
479 }
480 }, [id, data, store, setTypeProps]);
gio3d0bf032025-06-05 06:57:26 +0000481 const setPreBuildCommands = useCallback(
482 (e: React.ChangeEvent<HTMLTextAreaElement>) => {
483 store.updateNodeData<"app">(id, {
484 preBuildCommands: e.currentTarget.value,
giod0026612025-05-08 13:00:36 +0000485 });
486 },
gio3d0bf032025-06-05 06:57:26 +0000487 [id, store],
giod0026612025-05-08 13:00:36 +0000488 );
gio69148322025-06-19 23:16:12 +0400489 const agentForm = useForm<z.infer<typeof agentSchema>>({
490 resolver: zodResolver(agentSchema),
491 mode: "onChange",
492 defaultValues: {
gio69ff7592025-07-03 06:27:21 +0000493 apiKey: data.model?.apiKey,
494 model: data.model?.name,
gio69148322025-06-19 23:16:12 +0400495 },
496 });
497 useEffect(() => {
gio69ff7592025-07-03 06:27:21 +0000498 const sub = agentForm.watch((value, { name }: { name?: keyof z.infer<typeof agentSchema> | undefined }) => {
499 switch (name) {
500 case "model":
501 agentForm.setValue("apiKey", "", { shouldDirty: true });
502 store.updateNodeData<"app">(id, {
503 model: {
504 name: value.model,
505 apiKey: undefined,
506 },
507 });
508 break;
509 case "apiKey":
510 store.updateNodeData<"app">(id, {
511 model: {
512 name: data.model?.name,
513 apiKey: value.apiKey,
514 },
515 });
516 break;
517 }
gio69148322025-06-19 23:16:12 +0400518 });
519 return () => sub.unsubscribe();
gio69ff7592025-07-03 06:27:21 +0000520 }, [id, agentForm, store, data]);
gio3d0bf032025-06-05 06:57:26 +0000521 return (
522 <>
523 <SourceRepo node={node} disabled={disabled} />
gio69148322025-06-19 23:16:12 +0400524 {node.data.type !== "sketch:latest" && (
525 <Form {...form}>
526 <form className="space-y-2">
527 <Label>Container Image</Label>
528 <FormField
529 control={form.control}
530 name="type"
531 render={({ field }) => (
532 <FormItem>
533 <Select
534 onValueChange={field.onChange}
535 value={field.value || ""}
536 {...typeProps}
537 disabled={disabled}
538 >
539 <FormControl>
540 <SelectTrigger>
541 <SelectValue />
542 </SelectTrigger>
543 </FormControl>
544 <SelectContent>
545 {ServiceTypes.filter((t) => t !== "sketch:latest").map((t) => (
546 <SelectItem key={t} value={t}>
547 {t}
548 </SelectItem>
549 ))}
550 </SelectContent>
551 </Select>
552 <FormMessage />
553 </FormItem>
554 )}
555 />
556 </form>
557 </Form>
558 )}
559 {node.data.type === "sketch:latest" && (
560 <Form {...agentForm}>
561 <form className="space-y-2">
gio69148322025-06-19 23:16:12 +0400562 <FormField
563 control={agentForm.control}
gio69ff7592025-07-03 06:27:21 +0000564 name="model"
565 render={({ field }) => (
566 <FormItem>
567 <FormLabel>AI Model</FormLabel>
568 <Select
569 onValueChange={field.onChange}
570 defaultValue={field.value}
571 disabled={disabled}
572 >
573 <FormControl>
574 <SelectTrigger>
575 <SelectValue placeholder="Select a model" />
576 </SelectTrigger>
577 </FormControl>
578 <SelectContent>
579 <SelectItem value="gemini">Gemini</SelectItem>
580 <SelectItem value="claude">Claude</SelectItem>
581 </SelectContent>
582 </Select>
583 <FormMessage />
584 </FormItem>
585 )}
586 />
587 <Label>API Key</Label>
588 <FormField
589 control={agentForm.control}
590 name="apiKey"
gio69148322025-06-19 23:16:12 +0400591 render={({ field }) => (
592 <FormItem>
gio3d0bf032025-06-05 06:57:26 +0000593 <FormControl>
gio69148322025-06-19 23:16:12 +0400594 <Input
595 type="password"
gio69ff7592025-07-03 06:27:21 +0000596 placeholder="Override AI Model API key"
gio69148322025-06-19 23:16:12 +0400597 {...field}
598 value={field.value || ""}
599 disabled={disabled}
600 />
gio3d0bf032025-06-05 06:57:26 +0000601 </FormControl>
gio69148322025-06-19 23:16:12 +0400602 <FormMessage />
603 </FormItem>
604 )}
605 />
606 </form>
607 </Form>
608 )}
609 {node.data.type !== "sketch:latest" && (
610 <>
611 <Label>Pre-Build Commands</Label>
612 <Textarea
613 placeholder="new line separated list of commands to run before running the service"
614 value={data.preBuildCommands}
615 onChange={setPreBuildCommands}
616 disabled={disabled}
gio3d0bf032025-06-05 06:57:26 +0000617 />
gio69148322025-06-19 23:16:12 +0400618 </>
619 )}
gio3d0bf032025-06-05 06:57:26 +0000620 </>
giod0026612025-05-08 13:00:36 +0000621 );
gio3d0bf032025-06-05 06:57:26 +0000622}
623
gio2e7d2172025-07-04 09:24:53 +0000624function Ports({
625 node,
626 disabled,
627 isOverview,
628}: {
629 node: ServiceNode;
630 disabled?: boolean;
631 isOverview?: boolean;
632}): React.ReactNode {
gio3d0bf032025-06-05 06:57:26 +0000633 const { id, data } = node;
634 const store = useStateStore();
gio9f3d4f52025-07-04 08:42:34 +0000635 const nodes = useNodes<AppNode>();
636 const [portIngresses, setPortIngresses] = useState<Record<string, string[]>>({});
gio2e7d2172025-07-04 09:24:53 +0000637 const [exposingPortId, setExposingPortId] = useState<string | null>(null);
gio9f3d4f52025-07-04 08:42:34 +0000638
639 const httpsGateways = useMemo(
640 () => nodes.filter((n): n is GatewayHttpsNode => n.type === "gateway-https"),
641 [nodes],
642 );
643
644 useEffect(() => {
645 if (!data.ports) {
646 setPortIngresses({});
647 return;
648 }
649 const newIngresses: Record<string, string[]> = {};
650 for (const port of data.ports) {
651 newIngresses[port.id] = [];
652 }
653 for (const gateway of httpsGateways) {
654 const https = gateway.data.https;
655 if (https && https.serviceId === id && https.portId && gateway.data.network && gateway.data.subdomain) {
656 const url = `https://${gateway.data.subdomain}.${gateway.data.network}`;
657 if (newIngresses[https.portId]) {
658 newIngresses[https.portId].push(url);
659 } else {
660 newIngresses[https.portId] = [url];
661 }
662 }
663 }
664 setPortIngresses(newIngresses);
665 console.log(newIngresses);
666 }, [id, data.ports, httpsGateways]);
667
gio3d0bf032025-06-05 06:57:26 +0000668 const [name, setName] = useState("");
669 const [value, setValue] = useState("");
670 const onSubmit = useCallback(() => {
671 const portId = uuidv4();
672 store.updateNodeData<"app">(id, {
673 ports: (data.ports || []).concat({
674 id: portId,
675 name: name.toUpperCase(),
676 value: Number(value),
677 }),
gio73ac16c2025-07-03 14:38:04 +0000678 envVars: (data.envVars || []).concat(
679 {
680 id: uuidv4(),
681 source: null,
682 portId,
683 name: `DODO_PORT_${name.toUpperCase()}`,
684 },
685 {
686 id: uuidv4(),
687 source: null,
688 portId,
689 name: `DODO_PORT_${name.toUpperCase()}`,
690 alias: name.toUpperCase(),
691 },
692 ),
gio3d0bf032025-06-05 06:57:26 +0000693 });
694 setName("");
695 setValue("");
696 }, [id, data, store, name, value, setName, setValue]);
giod0026612025-05-08 13:00:36 +0000697 const removePort = useCallback(
698 (portId: string) => {
699 // TODO(gio): this is ugly
700 const tcpRemoved = new Set<string>();
giod0026612025-05-08 13:00:36 +0000701 store.setEdges(
702 store.edges.filter((e) => {
703 if (e.source !== id || e.sourceHandle !== "ports") {
704 return true;
705 }
706 const tn = store.nodes.find((n) => n.id == e.target)!;
707 if (e.targetHandle === "https") {
708 const t = tn as GatewayHttpsNode;
709 if (t.data.https?.serviceId === id && t.data.https.portId === portId) {
710 return false;
711 }
712 }
713 if (e.targetHandle === "tcp") {
714 const t = tn as GatewayTCPNode;
715 if (tcpRemoved.has(t.id)) {
716 return true;
717 }
718 if (t.data.exposed.find((e) => e.serviceId === id && e.portId === portId)) {
719 tcpRemoved.add(t.id);
720 return false;
721 }
722 }
723 if (e.targetHandle === "env_var") {
724 if (
725 tn &&
726 (tn.data.envVars || []).find(
727 (ev) => ev.source === id && "portId" in ev && ev.portId === portId,
728 )
729 ) {
730 return false;
731 }
732 }
733 return true;
734 }),
735 );
736 store.nodes
737 .filter(
738 (n) =>
739 n.type === "gateway-https" &&
740 n.data.https &&
741 n.data.https.serviceId === id &&
742 n.data.https.portId === portId,
743 )
744 .forEach((n) => {
745 store.updateNodeData<"gateway-https">(n.id, {
746 https: undefined,
747 });
748 });
749 store.nodes
750 .filter((n) => n.type === "gateway-tcp")
751 .forEach((n) => {
752 const filtered = n.data.exposed.filter((e) => {
753 if (e.serviceId === id && e.portId === portId) {
754 return false;
755 } else {
756 return true;
757 }
758 });
759 if (filtered.length != n.data.exposed.length) {
760 store.updateNodeData<"gateway-tcp">(n.id, {
761 exposed: filtered,
762 });
763 }
764 });
765 store.nodes
766 .filter((n) => n.type === "app" && n.data.envVars)
767 .forEach((n) => {
768 store.updateNodeData<"app">(n.id, {
769 envVars: n.data.envVars.filter((ev) => {
770 if (ev.source === id && "portId" in ev && ev.portId === portId) {
771 return false;
772 }
773 return true;
774 }),
775 });
776 });
777 store.updateNodeData<"app">(id, {
778 ports: (data.ports || []).filter((p) => p.id !== portId),
779 envVars: (data.envVars || []).filter(
780 (ev) => !(ev.source === null && "portId" in ev && ev.portId === portId),
781 ),
782 });
783 },
784 [id, data, store],
785 );
gio3d0bf032025-06-05 06:57:26 +0000786 return (
787 <div className="flex flex-col gap-1">
788 <div className="grid grid-cols-[1fr_1fr_auto] gap-1">
789 {data &&
790 data.ports &&
791 data.ports.map((p) => (
gio2e7d2172025-07-04 09:24:53 +0000792 <div key={p.id} className="contents">
gio9f3d4f52025-07-04 08:42:34 +0000793 <div className="contents">
794 <div className="flex items-center px-3">{p.name.toUpperCase()}</div>
795 <div className="flex items-center px-3">{p.value}</div>
gio2e7d2172025-07-04 09:24:53 +0000796 <div className="flex items-center gap-1">
797 {isOverview && (
798 <Button
799 variant="outline"
800 onClick={() => setExposingPortId(p.id)}
801 disabled={disabled}
802 >
803 Expose
804 </Button>
805 )}
gio9f3d4f52025-07-04 08:42:34 +0000806 <Button
807 variant="destructive"
808 className="w-full"
809 onClick={() => removePort(p.id)}
810 disabled={disabled}
811 >
812 Remove
813 </Button>
814 </div>
gio3d0bf032025-06-05 06:57:26 +0000815 </div>
gio9f3d4f52025-07-04 08:42:34 +0000816 {portIngresses[p.id]?.length > 0 && (
817 <div key={p.id} className="col-span-full pl-6">
818 {portIngresses[p.id].map((url) => (
gio2e7d2172025-07-04 09:24:53 +0000819 <Gateway key={url} g={{ type: "https", address: url, name: p.name }} />
gio9f3d4f52025-07-04 08:42:34 +0000820 ))}
821 </div>
822 )}
gio2e7d2172025-07-04 09:24:53 +0000823 {exposingPortId === p.id && (
824 <Dialog open={true} onOpenChange={() => setExposingPortId(null)}>
825 <DialogContent>
826 <DialogHeader>
827 <DialogTitle>
828 Expose Port {p.name}:{p.value}
829 </DialogTitle>
830 </DialogHeader>
831 <ExposeForm
832 node={node}
833 port={p}
834 onDone={() => setExposingPortId(null)}
835 disabled={disabled}
836 />
837 </DialogContent>
838 </Dialog>
839 )}
840 </div>
gio3d0bf032025-06-05 06:57:26 +0000841 ))}
842 <div>
843 <Input
844 placeholder="name"
845 className="uppercase w-0 min-w-full"
846 disabled={disabled}
847 value={name}
848 onChange={(e) => setName(e.target.value)}
849 />
850 </div>
851 <div>
852 <Input
853 placeholder="0"
854 className="w-0 min-w-full"
855 disabled={disabled}
856 value={value}
857 onChange={(e) => setValue(e.target.value)}
858 />
859 </div>
860 <div>
861 <Button type="submit" className="w-full" disabled={disabled} onClick={onSubmit}>
862 Add
863 </Button>
864 </div>
865 </div>
866 </div>
867 );
868}
869
870function EnvVars({ node, disabled }: { node: ServiceNode; disabled?: boolean }): React.ReactNode {
871 const { id, data } = node;
872 const store = useStateStore();
gio1dacf1c2025-07-03 16:39:04 +0000873 const [name, setName] = useState("");
874 const [value, setValue] = useState("");
875
876 const addEnvVar = useCallback(() => {
877 if (!name.trim() || !value.trim()) return;
878 store.updateNodeData<"app">(id, {
879 envVars: (data.envVars || []).concat({
880 id: uuidv4(),
881 source: null,
882 name: name.toUpperCase(),
883 value: value,
884 }),
885 });
886 setName("");
887 setValue("");
888 }, [id, data, store, name, value]);
889
890 const removeEnvVar = useCallback(
891 (varId: string) => {
892 store.updateNodeData<"app">(id, {
893 envVars: (data.envVars || []).filter((v) => v.id !== varId),
894 });
895 },
896 [id, data, store],
897 );
898
899 const editValueEnvVar = useCallback(
900 (varId: string) => {
901 if (disabled) return;
902 store.updateNodeData<"app">(id, {
903 envVars: (data.envVars || []).map((v) => (v.id === varId ? { ...v, isEditting: true } : v)),
904 });
905 },
906 [id, data, store, disabled],
907 );
908
909 const saveValueEnvVar = useCallback(
910 (varId: string, newName: string, newValue: string) => {
911 store.updateNodeData<"app">(id, {
912 envVars: (data.envVars || []).map((v) => {
913 if (v.id === varId) {
914 return { ...v, name: newName.toUpperCase(), value: newValue, isEditting: false };
915 }
916 return v;
917 }),
918 });
919 },
920 [id, data, store],
921 );
922
gio3d0bf032025-06-05 06:57:26 +0000923 const editAlias = useCallback(
924 (e: BoundEnvVar) => {
925 return () => {
gioff9b5522025-07-03 13:50:30 +0000926 if (disabled) {
927 return;
928 }
gio3d0bf032025-06-05 06:57:26 +0000929 store.updateNodeData(id, {
930 ...data,
931 envVars: data.envVars!.map((o) => {
932 if (o.id !== e.id) {
933 return o;
934 } else
935 return {
936 ...o,
937 isEditting: true,
938 };
939 }),
940 });
941 };
942 },
gioff9b5522025-07-03 13:50:30 +0000943 [id, data, store, disabled],
gio3d0bf032025-06-05 06:57:26 +0000944 );
gio1dacf1c2025-07-03 16:39:04 +0000945
gio3d0bf032025-06-05 06:57:26 +0000946 const saveAlias = useCallback(
947 (e: BoundEnvVar, value: string, store: AppState) => {
948 store.updateNodeData(id, {
949 ...data,
950 envVars: data.envVars!.map((o) => {
951 if (o.id !== e.id) {
952 return o;
953 }
954 if (value) {
gio1dacf1c2025-07-03 16:39:04 +0000955 if ("name" in o && value.toUpperCase() === o.name.toUpperCase()) {
956 return {
957 ...o,
958 isEditting: false,
959 alias: undefined,
960 };
961 } else {
962 return {
963 ...o,
964 isEditting: false,
965 alias: value.toUpperCase(),
966 };
967 }
gio3d0bf032025-06-05 06:57:26 +0000968 }
969 if ("alias" in o) {
970 const { alias: _, ...rest } = o;
971 return {
972 ...rest,
973 isEditting: false,
974 };
975 }
976 return {
977 ...o,
978 isEditting: false,
979 };
980 }),
giod0026612025-05-08 13:00:36 +0000981 });
982 },
gio3d0bf032025-06-05 06:57:26 +0000983 [id, data],
giod0026612025-05-08 13:00:36 +0000984 );
gio1dacf1c2025-07-03 16:39:04 +0000985
gio3d0bf032025-06-05 06:57:26 +0000986 const saveAliasOnEnter = useCallback(
987 (e: BoundEnvVar) => {
988 return (event: KeyboardEvent<HTMLInputElement>) => {
989 if (event.key === "Enter") {
gio3d0bf032025-06-05 06:57:26 +0000990 saveAlias(e, event.currentTarget.value, store);
gio1dacf1c2025-07-03 16:39:04 +0000991 } else if (event.key === "Escape") {
992 store.updateNodeData(id, {
993 ...data,
994 envVars: data.envVars!.map((o) => (o.id === e.id ? { ...o, isEditting: false } : o)),
995 });
giod0026612025-05-08 13:00:36 +0000996 }
gio3d0bf032025-06-05 06:57:26 +0000997 };
998 },
gio1dacf1c2025-07-03 16:39:04 +0000999 [store, saveAlias, id, data],
gio3d0bf032025-06-05 06:57:26 +00001000 );
gio1dacf1c2025-07-03 16:39:04 +00001001
gio3d0bf032025-06-05 06:57:26 +00001002 const saveAliasOnBlur = useCallback(
1003 (e: BoundEnvVar) => {
1004 return (event: FocusEvent<HTMLInputElement>) => {
1005 saveAlias(e, event.currentTarget.value, store);
1006 };
1007 },
1008 [store, saveAlias],
1009 );
gio1dacf1c2025-07-03 16:39:04 +00001010
gio3d0bf032025-06-05 06:57:26 +00001011 return (
gio1dacf1c2025-07-03 16:39:04 +00001012 <div className="flex flex-col gap-1">
1013 <div className="grid grid-cols-[auto_1fr_1fr_auto] gap-1">
1014 {data?.envVars?.map((v) => {
1015 if ("value" in v) {
1016 if (v.isEditting) {
1017 return (
1018 <div key={v.id} className="contents">
1019 <Input
1020 className="uppercase col-start-2"
1021 defaultValue={v.name}
1022 onKeyUp={(e) => {
1023 if (e.key === "Enter") {
1024 const nameInput = e.currentTarget;
1025 const valueInput = nameInput.parentElement?.querySelector(
1026 'input[placeholder="Value"]',
1027 ) as HTMLInputElement;
1028 if (valueInput) {
1029 saveValueEnvVar(v.id, nameInput.value, valueInput.value);
1030 }
1031 } else if (e.key === "Escape") {
1032 store.updateNodeData(id, {
1033 ...data,
1034 envVars: data.envVars!.map((o) =>
1035 o.id === v.id ? { ...o, isEditting: false } : o,
1036 ),
1037 });
1038 }
1039 }}
1040 autoFocus
1041 disabled={disabled}
1042 />
1043 <Input
1044 placeholder="Value"
1045 defaultValue={v.value}
1046 onKeyUp={(e) => {
1047 if (e.key === "Enter") {
1048 const valueInput = e.currentTarget;
1049 const nameInput = valueInput.parentElement?.querySelector(
1050 'input:not([placeholder="Value"])',
1051 ) as HTMLInputElement;
1052 if (nameInput) {
1053 saveValueEnvVar(v.id, nameInput.value, valueInput.value);
1054 }
1055 } else if (e.key === "Escape") {
1056 store.updateNodeData(id, {
1057 ...data,
1058 envVars: data.envVars!.map((o) =>
1059 o.id === v.id ? { ...o, isEditting: false } : o,
1060 ),
1061 });
1062 }
1063 }}
1064 disabled={disabled}
1065 />
1066 <Button
1067 variant="destructive"
1068 size="sm"
1069 onClick={() => removeEnvVar(v.id)}
1070 disabled={disabled}
1071 >
1072 Remove
1073 </Button>
1074 </div>
1075 );
1076 }
1077 return (
1078 <div
1079 key={v.id}
1080 className={`contents ${disabled ? "" : "cursor-text"}`}
1081 onClick={() => editValueEnvVar(v.id)}
1082 >
1083 <div>{!disabled && <Pencil className="w-4 h-4" />}</div>
1084 <div className={`${disabled ? "col-span-2" : ""} col-start-2`}>{v.name}</div>
1085 <div>{v.value}</div>
1086 <Button
1087 variant="destructive"
1088 size="sm"
1089 onClick={(e) => {
1090 e.stopPropagation();
1091 removeEnvVar(v.id);
1092 }}
1093 disabled={disabled}
1094 >
1095 Remove
1096 </Button>
1097 </div>
1098 );
1099 }
gio3d0bf032025-06-05 06:57:26 +00001100 if ("name" in v) {
1101 const value = "alias" in v ? v.alias : v.name;
1102 if (v.isEditting) {
1103 return (
gio1dacf1c2025-07-03 16:39:04 +00001104 <Input
1105 type="text"
1106 className="uppercase col-start-2 col-span-3"
1107 defaultValue={value}
1108 onKeyUp={saveAliasOnEnter(v)}
1109 onBlur={saveAliasOnBlur(v)}
1110 autoFocus={true}
1111 disabled={disabled}
1112 />
gio3d0bf032025-06-05 06:57:26 +00001113 );
1114 }
1115 return (
gio1dacf1c2025-07-03 16:39:04 +00001116 <div
1117 key={v.id}
1118 onClick={editAlias(v)}
1119 className={`contents ${disabled ? "" : "cursor-text"}`}
1120 >
1121 {!disabled && <Pencil className="w-4 h-4" />}
1122 <div className="col-start-2 col-span-3">
1123 <TooltipProvider>
1124 <Tooltip>
1125 <TooltipTrigger className="uppercase">{value}</TooltipTrigger>
1126 <TooltipContent>{v.name}</TooltipContent>
1127 </Tooltip>
1128 </TooltipProvider>
1129 </div>
1130 </div>
gio3d0bf032025-06-05 06:57:26 +00001131 );
1132 }
gio1dacf1c2025-07-03 16:39:04 +00001133 return null;
gio3d0bf032025-06-05 06:57:26 +00001134 })}
gio1dacf1c2025-07-03 16:39:04 +00001135 {!disabled && (
1136 <div className="contents">
1137 <Input
1138 placeholder="Name"
1139 className="uppercase col-start-2"
1140 value={name}
1141 onChange={(e) => setName(e.target.value)}
1142 disabled={disabled}
1143 />
1144 <Input
1145 placeholder="Value"
1146 value={value}
1147 onChange={(e) => setValue(e.target.value)}
1148 disabled={disabled}
1149 />
1150 <Button onClick={addEnvVar} disabled={disabled || !name.trim() || !value.trim()}>
1151 Add
1152 </Button>
1153 </div>
1154 )}
1155 </div>
1156 </div>
gio3d0bf032025-06-05 06:57:26 +00001157 );
1158}
1159
1160function Dev({ node, disabled }: { node: ServiceNode; disabled?: boolean }): React.ReactNode {
1161 const { id, data } = node;
1162 const env = useEnv();
1163 const store = useStateStore();
gio48fde052025-05-14 09:48:08 +00001164 const devForm = useForm<z.infer<typeof devSchema>>({
1165 resolver: zodResolver(devSchema),
1166 mode: "onChange",
1167 defaultValues: {
1168 enabled: data.dev ? data.dev.enabled : false,
1169 },
1170 });
1171 useEffect(() => {
1172 const sub = devForm.watch((value, { name }) => {
1173 if (name === "enabled") {
1174 if (value.enabled) {
1175 const csGateway: Omit<GatewayHttpsNode, "position"> = {
1176 id: uuidv4(),
1177 type: "gateway-https",
1178 data: {
1179 readonly: true,
1180 https: {
1181 serviceId: id,
1182 portId: `${id}-code-server`,
1183 },
1184 network: data.dev?.expose?.network,
1185 subdomain: data.dev?.expose?.subdomain,
1186 label: "",
1187 envVars: [],
1188 ports: [],
1189 },
1190 };
1191 const sshGateway: Omit<GatewayTCPNode, "position"> = {
1192 id: uuidv4(),
1193 type: "gateway-tcp",
1194 data: {
1195 readonly: true,
1196 exposed: [
1197 {
1198 serviceId: id,
1199 portId: `${id}-ssh`,
1200 },
1201 ],
1202 network: data.dev?.expose?.network,
1203 subdomain: data.dev?.expose?.subdomain,
1204 label: "",
1205 envVars: [],
1206 ports: [],
1207 },
1208 };
1209 store.addNode(csGateway);
1210 store.addNode(sshGateway);
1211 store.updateNodeData<"app">(id, {
1212 dev: {
1213 enabled: true,
1214 expose: data.dev?.expose,
1215 codeServerNodeId: csGateway.id,
1216 sshNodeId: sshGateway.id,
1217 },
1218 ports: (data.ports || []).concat(
1219 {
1220 id: `${id}-code-server`,
1221 name: "code-server",
1222 value: 9090,
1223 },
1224 {
1225 id: `${id}-ssh`,
1226 name: "ssh",
1227 value: 22,
1228 },
1229 ),
1230 });
1231 let edges = store.edges.concat([
1232 {
1233 id: uuidv4(),
1234 source: id,
1235 sourceHandle: "ports",
1236 target: csGateway.id,
1237 targetHandle: "https",
1238 },
1239 {
1240 id: uuidv4(),
1241 source: id,
1242 sourceHandle: "ports",
1243 target: sshGateway.id,
1244 targetHandle: "tcp",
1245 },
1246 ]);
1247 if (data.dev?.expose?.network !== undefined) {
1248 edges = edges.concat([
1249 {
1250 id: uuidv4(),
1251 source: csGateway.id,
1252 sourceHandle: "subdomain",
1253 target: data.dev.expose.network,
1254 targetHandle: "subdomain",
1255 },
1256 {
1257 id: uuidv4(),
1258 source: sshGateway.id,
1259 sourceHandle: "subdomain",
1260 target: data.dev.expose.network,
1261 targetHandle: "subdomain",
1262 },
1263 ]);
1264 }
1265 store.setEdges(edges);
1266 } else {
1267 const { dev } = data;
1268 if (dev?.enabled) {
1269 store.setNodes(
1270 store.nodes.filter((n) => n.id !== dev.codeServerNodeId && n.id !== dev.sshNodeId),
1271 );
1272 store.setEdges(
1273 store.edges.filter((e) => e.target !== dev.codeServerNodeId && e.target !== dev.sshNodeId),
1274 );
1275 }
1276 store.updateNodeData<"app">(id, {
1277 dev: {
1278 enabled: false,
1279 expose: dev?.expose,
1280 },
1281 ports: (data.ports || []).filter((p) => p.name !== "code-server" && p.name !== "ssh"),
1282 });
1283 }
1284 }
1285 });
1286 return () => sub.unsubscribe();
1287 }, [id, data, devForm, store]);
1288 const exposeForm = useForm<z.infer<typeof exposeSchema>>({
1289 resolver: zodResolver(exposeSchema),
1290 mode: "onChange",
1291 defaultValues: {
1292 network: data.dev?.expose?.network,
1293 subdomain: data.dev?.expose?.subdomain,
1294 },
1295 });
1296 useEffect(() => {
1297 const sub = exposeForm.watch(
1298 (
1299 value: DeepPartial<z.infer<typeof exposeSchema>>,
1300 { name }: { name?: keyof z.infer<typeof exposeSchema> | undefined; type?: EventType | undefined },
1301 ) => {
1302 const { dev } = data;
1303 if (!dev?.enabled) {
1304 return;
1305 }
1306 if (name === "network") {
1307 let edges = store.edges;
1308 if (dev.enabled && dev.expose?.network !== undefined) {
1309 edges = edges.filter((e) => {
1310 if (
1311 (e.source === dev.codeServerNodeId || e.source === dev.sshNodeId) &&
1312 e.sourceHandle === "subdomain" &&
1313 e.target === dev.expose?.network &&
1314 e.targetHandle === "subdomain"
1315 ) {
1316 return false;
1317 } else {
1318 return true;
1319 }
1320 });
1321 }
1322 if (value.network !== undefined) {
1323 edges = edges.concat(
1324 {
1325 id: uuidv4(),
1326 source: dev.codeServerNodeId,
1327 sourceHandle: "subdomain",
1328 target: value.network,
1329 targetHandle: "subdomain",
1330 },
1331 {
1332 id: uuidv4(),
1333 source: dev.sshNodeId,
1334 sourceHandle: "subdomain",
1335 target: value.network,
1336 targetHandle: "subdomain",
1337 },
1338 );
1339 }
1340 store.setEdges(edges);
1341 store.updateNodeData<"app">(id, {
1342 dev: {
1343 ...dev,
1344 expose: {
1345 network: value.network,
1346 subdomain: dev.expose?.subdomain,
1347 },
1348 },
1349 });
1350 store.updateNodeData<"gateway-https">(dev.codeServerNodeId, { network: value.network });
1351 store.updateNodeData<"gateway-tcp">(dev.sshNodeId, { network: value.network });
1352 } else if (name === "subdomain") {
1353 store.updateNodeData<"app">(id, {
1354 dev: {
1355 ...dev,
1356 expose: {
1357 network: dev.expose?.network,
1358 subdomain: value.subdomain,
1359 },
1360 },
1361 });
1362 store.updateNodeData<"gateway-https">(dev.codeServerNodeId, { subdomain: value.subdomain });
1363 store.updateNodeData<"gateway-tcp">(dev.sshNodeId, { subdomain: value.subdomain });
1364 }
1365 },
1366 );
1367 return () => sub.unsubscribe();
1368 }, [id, data, exposeForm, store]);
giod0026612025-05-08 13:00:36 +00001369 return (
1370 <>
gio48fde052025-05-14 09:48:08 +00001371 <Form {...devForm}>
1372 <form className="space-y-2">
1373 <FormField
1374 control={devForm.control}
1375 name="enabled"
1376 render={({ field }) => (
1377 <FormItem>
1378 <div className="flex flex-row gap-1 items-center">
gio3d0bf032025-06-05 06:57:26 +00001379 <Switch
gio3ec94242025-05-16 12:46:57 +00001380 id="devEnabled"
1381 onCheckedChange={field.onChange}
1382 checked={field.value}
1383 disabled={disabled}
1384 />
gio3d0bf032025-06-05 06:57:26 +00001385 <Label htmlFor="devEnabled">Dev VM</Label>
gio48fde052025-05-14 09:48:08 +00001386 </div>
1387 <FormMessage />
1388 </FormItem>
1389 )}
1390 />
1391 </form>
1392 </Form>
gio29050d62025-05-16 04:49:26 +00001393 {data.dev && data.dev.enabled && (
1394 <Form {...exposeForm}>
1395 <form className="space-y-2">
gio3d0bf032025-06-05 06:57:26 +00001396 <Label>Network</Label>
gio29050d62025-05-16 04:49:26 +00001397 <FormField
1398 control={exposeForm.control}
1399 name="network"
1400 render={({ field }) => (
1401 <FormItem>
gio3ec94242025-05-16 12:46:57 +00001402 <Select
1403 onValueChange={field.onChange}
gio3d0bf032025-06-05 06:57:26 +00001404 value={field.value || ""}
gio3ec94242025-05-16 12:46:57 +00001405 disabled={disabled}
1406 >
gio29050d62025-05-16 04:49:26 +00001407 <FormControl>
1408 <SelectTrigger>
gio3d0bf032025-06-05 06:57:26 +00001409 <SelectValue />
gio29050d62025-05-16 04:49:26 +00001410 </SelectTrigger>
1411 </FormControl>
1412 <SelectContent>
1413 {env.networks.map((n) => (
1414 <SelectItem
1415 key={n.name}
1416 value={n.domain}
1417 >{`${n.name} - ${n.domain}`}</SelectItem>
1418 ))}
1419 </SelectContent>
1420 </Select>
1421 <FormMessage />
1422 </FormItem>
1423 )}
1424 />
gio3d0bf032025-06-05 06:57:26 +00001425 <Label>Subdomain</Label>
gio29050d62025-05-16 04:49:26 +00001426 <FormField
1427 control={exposeForm.control}
1428 name="subdomain"
1429 render={({ field }) => (
1430 <FormItem>
gio48fde052025-05-14 09:48:08 +00001431 <FormControl>
gio3d0bf032025-06-05 06:57:26 +00001432 <Input {...field} disabled={disabled} />
gio48fde052025-05-14 09:48:08 +00001433 </FormControl>
gio29050d62025-05-16 04:49:26 +00001434 <FormMessage />
1435 </FormItem>
1436 )}
1437 />
1438 </form>
1439 </Form>
1440 )}
giod0026612025-05-08 13:00:36 +00001441 </>
1442 );
1443}
gio3d0bf032025-06-05 06:57:26 +00001444
1445function SourceRepo({ node, disabled }: { node: ServiceNode; disabled?: boolean }): React.ReactNode {
1446 const { id, data } = node;
1447 const store = useStateStore();
1448 const nodes = useNodes<AppNode>();
1449 const repo = useMemo(() => {
1450 return nodes
1451 .filter((n): n is GithubNode => n.type === "github")
1452 .find((n) => n.id === data.repository?.repoNodeId);
1453 }, [nodes, data.repository?.repoNodeId]);
1454 const repos = useGithubRepositories();
1455 const sourceForm = useForm<z.infer<typeof sourceSchema>>({
1456 resolver: zodResolver(sourceSchema),
1457 mode: "onChange",
1458 defaultValues: {
1459 id: data?.repository?.id?.toString(),
1460 branch: data.repository && "branch" in data.repository ? data.repository.branch : undefined,
1461 rootDir: data.repository && "rootDir" in data.repository ? data.repository.rootDir : undefined,
1462 },
1463 });
1464 useEffect(() => {
1465 const sub = sourceForm.watch(
1466 (
1467 value: DeepPartial<z.infer<typeof sourceSchema>>,
1468 { name }: { name?: keyof z.infer<typeof sourceSchema> | undefined; type?: EventType | undefined },
1469 ) => {
1470 if (name === "id") {
1471 const newRepoId = value.id ? parseInt(value.id, 10) : undefined;
1472 if (!newRepoId) return;
1473
1474 const oldGithubNodeId = data.repository?.repoNodeId;
1475 const selectedRepo = repos.find((r) => r.id === newRepoId);
1476
1477 if (!selectedRepo) return;
1478
1479 // If a node for the selected repo already exists, connect to it.
1480 const existingNodeForSelectedRepo = nodes
1481 .filter((n): n is GithubNode => n.type === "github")
1482 .find((n) => n.data.repository?.id === selectedRepo.id);
1483
1484 if (existingNodeForSelectedRepo) {
1485 let { nodes, edges } = store;
1486 if (oldGithubNodeId) {
1487 edges = edges.filter(
1488 (e) =>
1489 !(
1490 e.target === id &&
1491 e.source === oldGithubNodeId &&
1492 e.targetHandle === "repository"
1493 ),
1494 );
1495 }
1496 edges = edges.concat({
1497 id: uuidv4(),
1498 source: existingNodeForSelectedRepo.id,
1499 sourceHandle: "repository",
1500 target: id,
1501 targetHandle: "repository",
1502 });
1503 nodes = nodes.map((n) => {
1504 if (n.id !== id) {
1505 return n;
1506 } else {
1507 const sn = n as ServiceNode;
1508 return {
1509 ...sn,
1510 data: {
1511 ...sn.data,
1512 repository: {
1513 ...sn.data.repository,
1514 id: newRepoId,
1515 repoNodeId: existingNodeForSelectedRepo.id,
1516 },
1517 },
1518 };
1519 }
1520 });
1521 if (oldGithubNodeId && oldGithubNodeId !== existingNodeForSelectedRepo.id) {
1522 const isOldNodeStillUsed = edges.some(
1523 (e) => e.source === oldGithubNodeId && e.sourceHandle === "repository",
1524 );
1525 if (!isOldNodeStillUsed) {
1526 nodes = nodes.filter((n) => n.id !== oldGithubNodeId);
1527 }
1528 }
1529 store.setNodes(nodes);
1530 store.setEdges(edges);
1531 return;
1532 }
1533
1534 // No node for selected repo, decide whether to update old node or create a new one.
1535 if (oldGithubNodeId) {
1536 const isOldNodeShared =
1537 store.edges.filter(
1538 (e) =>
1539 e.source === oldGithubNodeId && e.target !== id && e.sourceHandle === "repository",
1540 ).length > 0;
1541
1542 if (!isOldNodeShared) {
1543 // Update old node
1544 store.updateNodeData<"github">(oldGithubNodeId, {
1545 repository: {
1546 id: selectedRepo.id,
1547 sshURL: selectedRepo.ssh_url,
1548 fullName: selectedRepo.full_name,
1549 },
1550 label: selectedRepo.full_name,
1551 });
1552 store.updateNodeData<"app">(id, {
1553 repository: {
1554 ...data.repository,
1555 id: newRepoId,
1556 },
1557 });
1558 } else {
1559 // Create new node because old one is shared
1560 const newGithubNodeId = uuidv4();
1561 store.addNode({
1562 id: newGithubNodeId,
1563 type: "github",
1564 data: {
1565 repository: {
1566 id: selectedRepo.id,
1567 sshURL: selectedRepo.ssh_url,
1568 fullName: selectedRepo.full_name,
1569 },
1570 label: selectedRepo.full_name,
1571 envVars: [],
1572 ports: [],
1573 },
1574 });
1575
1576 let edges = store.edges;
1577 // remove old edge
1578 edges = edges.filter(
1579 (e) =>
1580 !(
1581 e.target === id &&
1582 e.source === oldGithubNodeId &&
1583 e.targetHandle === "repository"
1584 ),
1585 );
1586 // add new edge
1587 edges = edges.concat({
1588 id: uuidv4(),
1589 source: newGithubNodeId,
1590 sourceHandle: "repository",
1591 target: id,
1592 targetHandle: "repository",
1593 });
1594 store.setEdges(edges);
1595 store.updateNodeData<"app">(id, {
1596 repository: {
1597 ...data.repository,
1598 id: newRepoId,
1599 repoNodeId: newGithubNodeId,
1600 },
1601 });
1602 }
1603 } else {
1604 // No old github node, so create a new one
1605 const newGithubNodeId = uuidv4();
1606 store.addNode({
1607 id: newGithubNodeId,
1608 type: "github",
1609 data: {
1610 repository: {
1611 id: selectedRepo.id,
1612 sshURL: selectedRepo.ssh_url,
1613 fullName: selectedRepo.full_name,
1614 },
1615 label: selectedRepo.full_name,
1616 envVars: [],
1617 ports: [],
1618 },
1619 });
1620 store.setEdges(
1621 store.edges.concat({
1622 id: uuidv4(),
1623 source: newGithubNodeId,
1624 sourceHandle: "repository",
1625 target: id,
1626 targetHandle: "repository",
1627 }),
1628 );
1629 store.updateNodeData<"app">(id, {
1630 repository: {
1631 ...data.repository,
1632 id: newRepoId,
1633 repoNodeId: newGithubNodeId,
1634 },
1635 });
1636 }
1637 } else if (name === "branch") {
1638 store.updateNodeData<"app">(id, {
1639 repository: {
1640 ...data?.repository,
1641 branch: value.branch,
1642 },
1643 });
1644 } else if (name === "rootDir") {
1645 store.updateNodeData<"app">(id, {
1646 repository: {
1647 ...data?.repository,
1648 rootDir: value.rootDir,
1649 },
1650 });
1651 }
1652 },
1653 );
1654 return () => sub.unsubscribe();
1655 }, [id, data, sourceForm, store, nodes, repos]);
1656 const [isExpanded, setIsExpanded] = useState(false);
1657 // useEffect(() => {
1658 // if (data.repository === undefined) {
1659 // setIsExpanded(true);
1660 // }
1661 // }, [data.repository, setIsExpanded]);
1662 console.log(data.repository, isExpanded, repo);
1663 return (
1664 <Accordion type="single" collapsible>
1665 <AccordionItem value="repository" className="border-none">
1666 <AccordionTrigger onClick={() => setIsExpanded(!isExpanded)}>
1667 Source {!isExpanded && repo !== undefined && repo.data.repository?.fullName}
1668 </AccordionTrigger>
1669 <AccordionContent className="px-1">
1670 <Form {...sourceForm}>
1671 <form className="space-y-2">
1672 <Label>Repository</Label>
1673 <FormField
1674 control={sourceForm.control}
1675 name="id"
1676 render={({ field }) => (
1677 <FormItem>
1678 <Select onValueChange={field.onChange} value={field.value} disabled={disabled}>
1679 <FormControl>
1680 <SelectTrigger>
1681 <SelectValue />
1682 </SelectTrigger>
1683 </FormControl>
1684 <SelectContent>
1685 {repos.map((r) => (
1686 <SelectItem
1687 key={r.id}
1688 value={r.id.toString()}
1689 >{`${r.full_name}`}</SelectItem>
1690 ))}
1691 </SelectContent>
1692 </Select>
1693 <FormMessage />
1694 </FormItem>
1695 )}
1696 />
1697 <Label>Branch</Label>
1698 <FormField
1699 control={sourceForm.control}
1700 name="branch"
1701 render={({ field }) => (
1702 <FormItem>
1703 <FormControl>
1704 <Input
1705 placeholder="master"
1706 className="lowercase"
1707 {...field}
1708 disabled={disabled}
1709 />
1710 </FormControl>
1711 <FormMessage />
1712 </FormItem>
1713 )}
1714 />
1715 <Label>Root Directory</Label>
1716 <FormField
1717 control={sourceForm.control}
1718 name="rootDir"
1719 render={({ field }) => (
1720 <FormItem>
1721 <FormControl>
1722 <Input placeholder="/" {...field} disabled={disabled} />
1723 </FormControl>
1724 <FormMessage />
1725 </FormItem>
1726 )}
1727 />
1728 </form>
1729 </Form>
1730 </AccordionContent>
1731 </AccordionItem>
1732 </Accordion>
1733 );
1734}