blob: a6eaaf9069b79accf40b58ad1760b4eb377fa8c3 [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";
gio8323a052025-08-04 06:12:26 +00003import {
4 useStateStore,
5 nodeLabel,
6 AppState,
7 nodeIsConnectable,
8 useEnv,
9 useGithubRepositories,
10 useMode,
11} from "@/lib/state";
gio43e0aad2025-08-01 16:17:27 +040012import {
13 ServiceNode,
14 ServiceTypes,
15 GatewayHttpsNode,
16 GatewayTCPNode,
17 BoundEnvVar,
18 AppNode,
19 GithubNode,
20 Machines,
21 Machine,
22 MachinesSchema,
23} from "config";
24import { KeyboardEvent, FocusEvent, useCallback, useEffect, useMemo, useState, useRef } from "react";
gio5f2f1002025-03-20 18:38:48 +040025import { z } from "zod";
gio3d0bf032025-06-05 06:57:26 +000026import { useForm, EventType, DeepPartial } from "react-hook-form";
giod0026612025-05-08 13:00:36 +000027import { zodResolver } from "@hookform/resolvers/zod";
gio69ff7592025-07-03 06:27:21 +000028import { Form, FormControl, FormField, FormItem, FormMessage, FormLabel } from "./ui/form";
giod0026612025-05-08 13:00:36 +000029import { Button } from "./ui/button";
gio33990c62025-05-06 07:51:24 +000030import { Handle, Position, useNodes } from "@xyflow/react";
gio5f2f1002025-03-20 18:38:48 +040031import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
gio5f2f1002025-03-20 18:38:48 +040032import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip";
gio91165612025-05-03 17:07:38 +000033import { Textarea } from "./ui/textarea";
giofcefd7c2025-05-13 08:01:07 +000034import { Input } from "./ui/input";
gio3d0bf032025-06-05 06:57:26 +000035import { Switch } from "./ui/switch";
gio48fde052025-05-14 09:48:08 +000036import { Label } from "./ui/label";
gio3d0bf032025-06-05 06:57:26 +000037import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs";
gio8323a052025-08-04 06:12:26 +000038import { Check, Code, Container, Copy, Network, Pencil, Variable } from "lucide-react";
gio3d0bf032025-06-05 06:57:26 +000039import { Badge } from "./ui/badge";
40import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from "./ui/accordion";
gio3fb133d2025-06-13 07:20:24 +000041import { Name } from "./node-name";
42import { NodeDetailsProps } from "@/lib/types";
gio9f3d4f52025-07-04 08:42:34 +000043import { Gateway } from "@/Gateways";
gio2e7d2172025-07-04 09:24:53 +000044import { Port } from "config";
45import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
gio43e0aad2025-08-01 16:17:27 +040046import { RadioGroup, RadioGroupItem } from "./ui/radio-group";
47import { useToast } from "@/hooks/use-toast";
48import { LoaderCircle } from "lucide-react";
gio2e7d2172025-07-04 09:24:53 +000049
50const sourceSchema = z.object({
51 id: z.string().min(1, "required"),
52 branch: z.string(),
53 rootDir: z.string(),
54});
55
56const devSchema = z.object({
57 enabled: z.boolean(),
gio43e0aad2025-08-01 16:17:27 +040058 mode: z.enum(["VM", "PROXY"]).optional(),
gio2e7d2172025-07-04 09:24:53 +000059});
60
61const exposeSchema = z.object({
62 network: z.string().min(1, "reqired"),
63 subdomain: z.string().min(1, "required"),
64});
65
66const agentSchema = z.object({
67 model: z.enum(["gemini", "claude"]),
68 apiKey: z.string().optional(),
69});
70
gio43e0aad2025-08-01 16:17:27 +040071const proxySchema = z.object({
72 address: z.string().min(1, "required"),
73});
74
gio2e7d2172025-07-04 09:24:53 +000075const portExposeSchema = z
76 .object({
77 type: z.enum(["https", "tcp"]),
78 network: z.string().min(1, "Required"),
79 subdomain: z.string().optional(),
80 })
81 .refine(
82 (data) => {
83 if (data.type === "https" || data.type === "tcp") {
84 return !!data.subdomain && data.subdomain.length > 0;
85 }
86 return true;
87 },
88 {
89 message: "Subdomain is required",
90 path: ["subdomain"],
91 },
92 );
93
94type PortExposeFormValues = z.infer<typeof portExposeSchema>;
gio5f2f1002025-03-20 18:38:48 +040095
96export function NodeApp(node: ServiceNode) {
giod0026612025-05-08 13:00:36 +000097 const { id, selected } = node;
98 const isConnectablePorts = useMemo(() => nodeIsConnectable(node, "ports"), [node]);
99 const isConnectableRepository = useMemo(() => nodeIsConnectable(node, "repository"), [node]);
100 return (
gio69148322025-06-19 23:16:12 +0400101 <NodeRect id={id} selected={selected} node={node} state={node.data.state}>
giod0026612025-05-08 13:00:36 +0000102 <div style={{ padding: "10px 20px" }}>
103 {nodeLabel(node)}
104 <Handle
105 id="repository"
106 type={"target"}
107 position={Position.Left}
108 isConnectableStart={isConnectableRepository}
109 isConnectableEnd={isConnectableRepository}
110 isConnectable={isConnectableRepository}
111 />
112 <Handle
113 id="ports"
114 type={"source"}
115 position={Position.Top}
116 isConnectableStart={isConnectablePorts}
117 isConnectableEnd={isConnectablePorts}
118 isConnectable={isConnectablePorts}
119 />
120 <Handle
121 id="env_var"
122 type={"target"}
123 position={Position.Bottom}
124 isConnectableStart={true}
125 isConnectableEnd={true}
126 isConnectable={true}
127 />
128 </div>
129 </NodeRect>
130 );
gio5f2f1002025-03-20 18:38:48 +0400131}
132
133const schema = z.object({
giod0026612025-05-08 13:00:36 +0000134 name: z.string().min(1, "requried"),
135 type: z.enum(ServiceTypes),
gio5f2f1002025-03-20 18:38:48 +0400136});
137
gio2e7d2172025-07-04 09:24:53 +0000138function ExposeForm({
139 node,
140 port,
141 onDone,
142 disabled,
143}: {
144 node: ServiceNode;
145 port: Port;
146 onDone: () => void;
147 disabled?: boolean;
148}) {
149 const store = useStateStore();
150 const nodes = useNodes<AppNode>();
151 const env = useEnv();
152 const form = useForm<PortExposeFormValues>({
153 resolver: zodResolver(portExposeSchema),
154 mode: "onChange",
155 defaultValues: {
156 type: "https",
157 },
158 });
gio33990c62025-05-06 07:51:24 +0000159
gio2e7d2172025-07-04 09:24:53 +0000160 const onSubmit = (data: PortExposeFormValues) => {
161 const networkNode = nodes.find((n) => n.type === "network" && n.data.domain === data.network);
162 if (!networkNode) {
163 // TODO: should show an error to the user
164 return;
165 }
166 if (data.type === "https") {
167 const newNode: Omit<GatewayHttpsNode, "position"> = {
168 id: uuidv4(),
169 type: "gateway-https",
170 data: {
171 https: {
172 serviceId: node.id,
173 portId: port.id,
174 },
175 network: data.network,
176 subdomain: data.subdomain!,
177 label: "",
178 envVars: [],
179 ports: [],
180 },
181 };
182 store.addNode(newNode);
183 store.setEdges(
184 store.edges.concat(
185 {
186 id: uuidv4(),
187 source: node.id,
188 sourceHandle: "ports",
189 target: newNode.id,
190 targetHandle: "https",
191 },
192 {
193 id: uuidv4(),
194 source: newNode.id,
195 sourceHandle: "subdomain",
196 target: networkNode.id,
197 targetHandle: "subdomain",
198 },
199 ),
200 );
201 } else if (data.type === "tcp") {
202 const existingGateway = nodes.find(
203 (n): n is GatewayTCPNode =>
204 n.type === "gateway-tcp" && n.data.network === data.network && n.data.subdomain === data.subdomain,
205 );
206 if (existingGateway) {
207 store.updateNodeData<"gateway-tcp">(existingGateway.id, {
208 exposed: [...existingGateway.data.exposed, { serviceId: node.id, portId: port.id }],
209 });
210 let edges = store.edges.concat({
211 id: uuidv4(),
212 source: node.id,
213 sourceHandle: "ports",
214 target: existingGateway.id,
215 targetHandle: "tcp",
216 });
217 if (
218 !edges.find(
219 (e) =>
220 e.source === existingGateway.id &&
221 e.target === networkNode.id &&
222 e.sourceHandle === "subdomain" &&
223 e.targetHandle === "subdomain",
224 )
225 ) {
226 edges = edges.concat({
227 id: uuidv4(),
228 source: existingGateway.id,
229 sourceHandle: "subdomain",
230 target: networkNode.id,
231 targetHandle: "subdomain",
232 });
233 }
234 store.setEdges(edges);
235 } else {
236 const newNode: Omit<GatewayTCPNode, "position"> = {
237 id: uuidv4(),
238 type: "gateway-tcp",
239 data: {
240 exposed: [{ serviceId: node.id, portId: port.id }],
241 network: data.network,
242 subdomain: data.subdomain,
243 label: "",
244 envVars: [],
245 ports: [],
246 },
247 };
248 store.addNode(newNode);
249 store.setEdges(
250 store.edges.concat(
251 {
252 id: uuidv4(),
253 source: node.id,
254 sourceHandle: "ports",
255 target: newNode.id,
256 targetHandle: "tcp",
257 },
258 {
259 id: uuidv4(),
260 source: newNode.id,
261 sourceHandle: "subdomain",
262 target: networkNode.id,
263 targetHandle: "subdomain",
264 },
265 ),
266 );
267 }
268 }
269 onDone();
270 };
gio48fde052025-05-14 09:48:08 +0000271
gio2e7d2172025-07-04 09:24:53 +0000272 const type = form.watch("type");
gio48fde052025-05-14 09:48:08 +0000273
gio2e7d2172025-07-04 09:24:53 +0000274 return (
275 <Form {...form}>
276 <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-2 border-t mt-2 pt-2">
277 <FormField
278 control={form.control}
279 name="type"
280 render={({ field }) => (
281 <FormItem>
282 <FormLabel>Gateway Type</FormLabel>
283 <Select onValueChange={field.onChange} defaultValue={field.value} disabled={disabled}>
284 <FormControl>
285 <SelectTrigger>
286 <SelectValue placeholder="Select a type" />
287 </SelectTrigger>
288 </FormControl>
289 <SelectContent>
290 <SelectItem value="https">HTTPS</SelectItem>
291 <SelectItem value="tcp">TCP</SelectItem>
292 </SelectContent>
293 </Select>
294 <FormMessage />
295 </FormItem>
296 )}
297 />
298 <FormField
299 control={form.control}
300 name="network"
301 render={({ field }) => (
302 <FormItem>
303 <FormLabel>Network</FormLabel>
304 <Select onValueChange={field.onChange} defaultValue={field.value} disabled={disabled}>
305 <FormControl>
306 <SelectTrigger>
307 <SelectValue placeholder="Select a network" />
308 </SelectTrigger>
309 </FormControl>
310 <SelectContent>
311 {env.networks.map((n) => (
312 <SelectItem key={n.domain} value={n.domain}>
313 {n.name} - {n.domain}
314 </SelectItem>
315 ))}
316 </SelectContent>
317 </Select>
318 <FormMessage />
319 </FormItem>
320 )}
321 />
322 {(type === "https" || type === "tcp") && (
323 <FormField
324 control={form.control}
325 name="subdomain"
326 render={({ field }) => (
327 <FormItem>
328 <FormLabel>Subdomain</FormLabel>
329 <FormControl>
330 <Input placeholder="subdomain" {...field} disabled={disabled} />
331 </FormControl>
332 <FormMessage />
333 </FormItem>
334 )}
335 />
336 )}
337 <div className="flex justify-end gap-2">
338 <Button type="button" variant="ghost" onClick={onDone} disabled={disabled}>
339 Cancel
340 </Button>
341 <Button type="submit" disabled={disabled || !form.formState.isValid}>
342 Expose
343 </Button>
344 </div>
345 </form>
346 </Form>
347 );
348}
gio69148322025-06-19 23:16:12 +0400349
gioe7734b22025-06-13 10:12:04 +0000350export function NodeAppDetails({ node, disabled, showName = true, isOverview = false }: NodeDetailsProps<ServiceNode>) {
gio3d0bf032025-06-05 06:57:26 +0000351 const { data } = node;
gio43e0aad2025-08-01 16:17:27 +0400352 const defaultTab = useMemo(() => {
353 if (data.dev?.enabled) {
354 return "dev";
355 }
356 return "runtime";
357 }, [data]);
gio3d0bf032025-06-05 06:57:26 +0000358 return (
359 <>
gio3fb133d2025-06-13 07:20:24 +0000360 {showName ? <Name node={node} disabled={disabled} /> : null}
gio43e0aad2025-08-01 16:17:27 +0400361 <Tabs defaultValue={defaultTab}>
gio3d0bf032025-06-05 06:57:26 +0000362 <TabsList className="w-full flex flex-row justify-between">
363 <TabsTrigger value="runtime">
gioe7734b22025-06-13 10:12:04 +0000364 {isOverview ? (
365 <div className="flex flex-row gap-1 items-center">
366 <Container /> Runtime
367 </div>
368 ) : (
369 <TooltipProvider>
370 <Tooltip>
371 <TooltipTrigger>
372 <Container />
373 </TooltipTrigger>
374 <TooltipContent>Runtime</TooltipContent>
375 </Tooltip>
376 </TooltipProvider>
377 )}
gio3d0bf032025-06-05 06:57:26 +0000378 </TabsTrigger>
379 <TabsTrigger value="ports">
gioe7734b22025-06-13 10:12:04 +0000380 {isOverview ? (
381 <div className="flex flex-row gap-1 items-center">
382 <Network /> Ports
383 <Badge className="rounded-full">{data.ports?.length ?? 0}</Badge>
384 </div>
385 ) : (
386 <TooltipProvider>
387 <Tooltip>
388 <TooltipTrigger className="flex flex-row gap-1 items-center">
389 <Network />
390 </TooltipTrigger>
391 <TooltipContent>
392 Ports{" "}
393 <Badge variant="secondary" className="rounded-full">
394 {data.ports?.length ?? 0}
395 </Badge>
396 </TooltipContent>
397 </Tooltip>
398 </TooltipProvider>
399 )}
gio3d0bf032025-06-05 06:57:26 +0000400 </TabsTrigger>
401 <TabsTrigger value="vars">
gioe7734b22025-06-13 10:12:04 +0000402 {isOverview ? (
403 <div className="flex flex-row gap-1 items-center">
404 <Variable /> Variables
405 <Badge className="rounded-full">{data.envVars?.length ?? 0}</Badge>
406 </div>
407 ) : (
408 <TooltipProvider>
409 <Tooltip>
410 <TooltipTrigger className="flex flex-row gap-1 items-center">
411 <Variable />
412 </TooltipTrigger>
413 <TooltipContent>
414 Variables{" "}
415 <Badge variant="secondary" className="rounded-full">
416 {data.envVars?.length ?? 0}
417 </Badge>
418 </TooltipContent>
419 </Tooltip>
420 </TooltipProvider>
421 )}
gio3d0bf032025-06-05 06:57:26 +0000422 </TabsTrigger>
gio69148322025-06-19 23:16:12 +0400423 {node.data.type !== "sketch:latest" && (
424 <TabsTrigger value="dev">
425 {isOverview ? (
426 <div className="flex flex-row gap-1 items-center">
427 <Code /> Dev
428 </div>
429 ) : (
430 <TooltipProvider>
431 <Tooltip>
432 <TooltipTrigger className="flex flex-row gap-1 items-center">
433 <Code />
434 </TooltipTrigger>
435 <TooltipContent>Dev</TooltipContent>
436 </Tooltip>
437 </TooltipProvider>
438 )}
439 </TabsTrigger>
440 )}
gio3d0bf032025-06-05 06:57:26 +0000441 </TabsList>
442 <TabsContent value="runtime">
443 <Runtime node={node} disabled={disabled} />
444 </TabsContent>
445 <TabsContent value="ports">
gio2e7d2172025-07-04 09:24:53 +0000446 <Ports node={node} disabled={disabled} isOverview={isOverview} />
gio3d0bf032025-06-05 06:57:26 +0000447 </TabsContent>
448 <TabsContent value="vars">
449 <EnvVars node={node} disabled={disabled} />
450 </TabsContent>
gio69148322025-06-19 23:16:12 +0400451 {node.data.type !== "sketch:latest" && (
452 <TabsContent value="dev">
453 <Dev node={node} disabled={disabled} />
454 </TabsContent>
455 )}
gio3d0bf032025-06-05 06:57:26 +0000456 </Tabs>
457 </>
458 );
459}
460
gio3d0bf032025-06-05 06:57:26 +0000461function Runtime({ node, disabled }: { node: ServiceNode; disabled?: boolean }): React.ReactNode {
462 const { id, data } = node;
463 const store = useStateStore();
giod0026612025-05-08 13:00:36 +0000464 const form = useForm<z.infer<typeof schema>>({
465 resolver: zodResolver(schema),
466 mode: "onChange",
467 defaultValues: {
468 name: data.label,
469 type: data.type,
470 },
471 });
giod0026612025-05-08 13:00:36 +0000472 useEffect(() => {
473 const sub = form.watch(
474 (
475 value: DeepPartial<z.infer<typeof schema>>,
476 { name, type }: { name?: keyof z.infer<typeof schema> | undefined; type?: EventType | undefined },
477 ) => {
giod0026612025-05-08 13:00:36 +0000478 if (type !== "change") {
479 return;
480 }
481 switch (name) {
482 case "name":
483 if (!value.name) {
484 break;
485 }
486 store.updateNodeData<"app">(id, {
487 label: value.name,
488 });
489 break;
490 case "type":
491 if (!value.type) {
492 break;
493 }
494 store.updateNodeData<"app">(id, {
495 type: value.type,
496 });
497 break;
498 }
499 },
500 );
501 return () => sub.unsubscribe();
502 }, [id, form, store]);
giod0026612025-05-08 13:00:36 +0000503 const [typeProps, setTypeProps] = useState({});
504 useEffect(() => {
505 if (data.activeField === "type") {
506 setTypeProps({
507 open: true,
508 onOpenChange: () => store.updateNodeData(id, { activeField: undefined }),
509 });
510 } else {
511 setTypeProps({});
512 }
513 }, [id, data, store, setTypeProps]);
gio3d0bf032025-06-05 06:57:26 +0000514 const setPreBuildCommands = useCallback(
515 (e: React.ChangeEvent<HTMLTextAreaElement>) => {
516 store.updateNodeData<"app">(id, {
517 preBuildCommands: e.currentTarget.value,
giod0026612025-05-08 13:00:36 +0000518 });
519 },
gio3d0bf032025-06-05 06:57:26 +0000520 [id, store],
giod0026612025-05-08 13:00:36 +0000521 );
gio69148322025-06-19 23:16:12 +0400522 const agentForm = useForm<z.infer<typeof agentSchema>>({
523 resolver: zodResolver(agentSchema),
524 mode: "onChange",
525 defaultValues: {
gio69ff7592025-07-03 06:27:21 +0000526 apiKey: data.model?.apiKey,
527 model: data.model?.name,
gio69148322025-06-19 23:16:12 +0400528 },
529 });
530 useEffect(() => {
gio69ff7592025-07-03 06:27:21 +0000531 const sub = agentForm.watch((value, { name }: { name?: keyof z.infer<typeof agentSchema> | undefined }) => {
532 switch (name) {
533 case "model":
534 agentForm.setValue("apiKey", "", { shouldDirty: true });
535 store.updateNodeData<"app">(id, {
536 model: {
537 name: value.model,
538 apiKey: undefined,
539 },
540 });
541 break;
542 case "apiKey":
543 store.updateNodeData<"app">(id, {
544 model: {
545 name: data.model?.name,
546 apiKey: value.apiKey,
547 },
548 });
549 break;
550 }
gio69148322025-06-19 23:16:12 +0400551 });
552 return () => sub.unsubscribe();
gio69ff7592025-07-03 06:27:21 +0000553 }, [id, agentForm, store, data]);
gio3d0bf032025-06-05 06:57:26 +0000554 return (
555 <>
556 <SourceRepo node={node} disabled={disabled} />
gio69148322025-06-19 23:16:12 +0400557 {node.data.type !== "sketch:latest" && (
558 <Form {...form}>
559 <form className="space-y-2">
560 <Label>Container Image</Label>
561 <FormField
562 control={form.control}
563 name="type"
564 render={({ field }) => (
565 <FormItem>
566 <Select
567 onValueChange={field.onChange}
568 value={field.value || ""}
569 {...typeProps}
570 disabled={disabled}
571 >
572 <FormControl>
573 <SelectTrigger>
574 <SelectValue />
575 </SelectTrigger>
576 </FormControl>
577 <SelectContent>
578 {ServiceTypes.filter((t) => t !== "sketch:latest").map((t) => (
579 <SelectItem key={t} value={t}>
580 {t}
581 </SelectItem>
582 ))}
583 </SelectContent>
584 </Select>
585 <FormMessage />
586 </FormItem>
587 )}
588 />
589 </form>
590 </Form>
591 )}
592 {node.data.type === "sketch:latest" && (
593 <Form {...agentForm}>
594 <form className="space-y-2">
gio69148322025-06-19 23:16:12 +0400595 <FormField
596 control={agentForm.control}
gio69ff7592025-07-03 06:27:21 +0000597 name="model"
598 render={({ field }) => (
599 <FormItem>
600 <FormLabel>AI Model</FormLabel>
601 <Select
602 onValueChange={field.onChange}
603 defaultValue={field.value}
604 disabled={disabled}
605 >
606 <FormControl>
607 <SelectTrigger>
608 <SelectValue placeholder="Select a model" />
609 </SelectTrigger>
610 </FormControl>
611 <SelectContent>
612 <SelectItem value="gemini">Gemini</SelectItem>
613 <SelectItem value="claude">Claude</SelectItem>
614 </SelectContent>
615 </Select>
616 <FormMessage />
617 </FormItem>
618 )}
619 />
620 <Label>API Key</Label>
621 <FormField
622 control={agentForm.control}
623 name="apiKey"
gio69148322025-06-19 23:16:12 +0400624 render={({ field }) => (
625 <FormItem>
gio3d0bf032025-06-05 06:57:26 +0000626 <FormControl>
gio69148322025-06-19 23:16:12 +0400627 <Input
628 type="password"
gio69ff7592025-07-03 06:27:21 +0000629 placeholder="Override AI Model API key"
gio69148322025-06-19 23:16:12 +0400630 {...field}
631 value={field.value || ""}
632 disabled={disabled}
633 />
gio3d0bf032025-06-05 06:57:26 +0000634 </FormControl>
gio69148322025-06-19 23:16:12 +0400635 <FormMessage />
636 </FormItem>
637 )}
638 />
639 </form>
640 </Form>
641 )}
642 {node.data.type !== "sketch:latest" && (
643 <>
644 <Label>Pre-Build Commands</Label>
645 <Textarea
646 placeholder="new line separated list of commands to run before running the service"
647 value={data.preBuildCommands}
648 onChange={setPreBuildCommands}
649 disabled={disabled}
gio3d0bf032025-06-05 06:57:26 +0000650 />
gio69148322025-06-19 23:16:12 +0400651 </>
652 )}
gio3d0bf032025-06-05 06:57:26 +0000653 </>
giod0026612025-05-08 13:00:36 +0000654 );
gio3d0bf032025-06-05 06:57:26 +0000655}
656
gio2e7d2172025-07-04 09:24:53 +0000657function Ports({
658 node,
659 disabled,
660 isOverview,
661}: {
662 node: ServiceNode;
663 disabled?: boolean;
664 isOverview?: boolean;
665}): React.ReactNode {
gio3d0bf032025-06-05 06:57:26 +0000666 const { id, data } = node;
667 const store = useStateStore();
gio9f3d4f52025-07-04 08:42:34 +0000668 const nodes = useNodes<AppNode>();
669 const [portIngresses, setPortIngresses] = useState<Record<string, string[]>>({});
gio2e7d2172025-07-04 09:24:53 +0000670 const [exposingPortId, setExposingPortId] = useState<string | null>(null);
gio9f3d4f52025-07-04 08:42:34 +0000671
672 const httpsGateways = useMemo(
673 () => nodes.filter((n): n is GatewayHttpsNode => n.type === "gateway-https"),
674 [nodes],
675 );
676
677 useEffect(() => {
678 if (!data.ports) {
679 setPortIngresses({});
680 return;
681 }
682 const newIngresses: Record<string, string[]> = {};
683 for (const port of data.ports) {
684 newIngresses[port.id] = [];
685 }
686 for (const gateway of httpsGateways) {
687 const https = gateway.data.https;
688 if (https && https.serviceId === id && https.portId && gateway.data.network && gateway.data.subdomain) {
689 const url = `https://${gateway.data.subdomain}.${gateway.data.network}`;
690 if (newIngresses[https.portId]) {
691 newIngresses[https.portId].push(url);
692 } else {
693 newIngresses[https.portId] = [url];
694 }
695 }
696 }
697 setPortIngresses(newIngresses);
698 console.log(newIngresses);
699 }, [id, data.ports, httpsGateways]);
700
gio3d0bf032025-06-05 06:57:26 +0000701 const [name, setName] = useState("");
702 const [value, setValue] = useState("");
703 const onSubmit = useCallback(() => {
704 const portId = uuidv4();
705 store.updateNodeData<"app">(id, {
706 ports: (data.ports || []).concat({
707 id: portId,
708 name: name.toUpperCase(),
709 value: Number(value),
710 }),
gio73ac16c2025-07-03 14:38:04 +0000711 envVars: (data.envVars || []).concat(
712 {
713 id: uuidv4(),
714 source: null,
715 portId,
716 name: `DODO_PORT_${name.toUpperCase()}`,
717 },
718 {
719 id: uuidv4(),
720 source: null,
721 portId,
722 name: `DODO_PORT_${name.toUpperCase()}`,
723 alias: name.toUpperCase(),
724 },
725 ),
gio3d0bf032025-06-05 06:57:26 +0000726 });
727 setName("");
728 setValue("");
729 }, [id, data, store, name, value, setName, setValue]);
giod0026612025-05-08 13:00:36 +0000730 const removePort = useCallback(
731 (portId: string) => {
732 // TODO(gio): this is ugly
733 const tcpRemoved = new Set<string>();
giod0026612025-05-08 13:00:36 +0000734 store.setEdges(
735 store.edges.filter((e) => {
736 if (e.source !== id || e.sourceHandle !== "ports") {
737 return true;
738 }
739 const tn = store.nodes.find((n) => n.id == e.target)!;
740 if (e.targetHandle === "https") {
741 const t = tn as GatewayHttpsNode;
742 if (t.data.https?.serviceId === id && t.data.https.portId === portId) {
743 return false;
744 }
745 }
746 if (e.targetHandle === "tcp") {
747 const t = tn as GatewayTCPNode;
748 if (tcpRemoved.has(t.id)) {
749 return true;
750 }
751 if (t.data.exposed.find((e) => e.serviceId === id && e.portId === portId)) {
752 tcpRemoved.add(t.id);
753 return false;
754 }
755 }
756 if (e.targetHandle === "env_var") {
757 if (
758 tn &&
759 (tn.data.envVars || []).find(
760 (ev) => ev.source === id && "portId" in ev && ev.portId === portId,
761 )
762 ) {
763 return false;
764 }
765 }
766 return true;
767 }),
768 );
769 store.nodes
770 .filter(
771 (n) =>
772 n.type === "gateway-https" &&
773 n.data.https &&
774 n.data.https.serviceId === id &&
775 n.data.https.portId === portId,
776 )
777 .forEach((n) => {
778 store.updateNodeData<"gateway-https">(n.id, {
779 https: undefined,
780 });
781 });
782 store.nodes
783 .filter((n) => n.type === "gateway-tcp")
784 .forEach((n) => {
785 const filtered = n.data.exposed.filter((e) => {
786 if (e.serviceId === id && e.portId === portId) {
787 return false;
788 } else {
789 return true;
790 }
791 });
792 if (filtered.length != n.data.exposed.length) {
793 store.updateNodeData<"gateway-tcp">(n.id, {
794 exposed: filtered,
795 });
796 }
797 });
798 store.nodes
799 .filter((n) => n.type === "app" && n.data.envVars)
800 .forEach((n) => {
801 store.updateNodeData<"app">(n.id, {
802 envVars: n.data.envVars.filter((ev) => {
803 if (ev.source === id && "portId" in ev && ev.portId === portId) {
804 return false;
805 }
806 return true;
807 }),
808 });
809 });
810 store.updateNodeData<"app">(id, {
811 ports: (data.ports || []).filter((p) => p.id !== portId),
812 envVars: (data.envVars || []).filter(
813 (ev) => !(ev.source === null && "portId" in ev && ev.portId === portId),
814 ),
815 });
816 },
817 [id, data, store],
818 );
gio3d0bf032025-06-05 06:57:26 +0000819 return (
820 <div className="flex flex-col gap-1">
821 <div className="grid grid-cols-[1fr_1fr_auto] gap-1">
822 {data &&
823 data.ports &&
824 data.ports.map((p) => (
gio2e7d2172025-07-04 09:24:53 +0000825 <div key={p.id} className="contents">
gio9f3d4f52025-07-04 08:42:34 +0000826 <div className="contents">
827 <div className="flex items-center px-3">{p.name.toUpperCase()}</div>
828 <div className="flex items-center px-3">{p.value}</div>
gio2e7d2172025-07-04 09:24:53 +0000829 <div className="flex items-center gap-1">
830 {isOverview && (
831 <Button
832 variant="outline"
833 onClick={() => setExposingPortId(p.id)}
834 disabled={disabled}
835 >
836 Expose
837 </Button>
838 )}
gio9f3d4f52025-07-04 08:42:34 +0000839 <Button
840 variant="destructive"
841 className="w-full"
842 onClick={() => removePort(p.id)}
843 disabled={disabled}
844 >
845 Remove
846 </Button>
847 </div>
gio3d0bf032025-06-05 06:57:26 +0000848 </div>
gio9f3d4f52025-07-04 08:42:34 +0000849 {portIngresses[p.id]?.length > 0 && (
850 <div key={p.id} className="col-span-full pl-6">
851 {portIngresses[p.id].map((url) => (
gio2e7d2172025-07-04 09:24:53 +0000852 <Gateway key={url} g={{ type: "https", address: url, name: p.name }} />
gio9f3d4f52025-07-04 08:42:34 +0000853 ))}
854 </div>
855 )}
gio2e7d2172025-07-04 09:24:53 +0000856 {exposingPortId === p.id && (
857 <Dialog open={true} onOpenChange={() => setExposingPortId(null)}>
858 <DialogContent>
859 <DialogHeader>
860 <DialogTitle>
861 Expose Port {p.name}:{p.value}
862 </DialogTitle>
863 </DialogHeader>
864 <ExposeForm
865 node={node}
866 port={p}
867 onDone={() => setExposingPortId(null)}
868 disabled={disabled}
869 />
870 </DialogContent>
871 </Dialog>
872 )}
873 </div>
gio3d0bf032025-06-05 06:57:26 +0000874 ))}
875 <div>
876 <Input
877 placeholder="name"
878 className="uppercase w-0 min-w-full"
879 disabled={disabled}
880 value={name}
881 onChange={(e) => setName(e.target.value)}
882 />
883 </div>
884 <div>
885 <Input
886 placeholder="0"
887 className="w-0 min-w-full"
888 disabled={disabled}
889 value={value}
890 onChange={(e) => setValue(e.target.value)}
891 />
892 </div>
893 <div>
894 <Button type="submit" className="w-full" disabled={disabled} onClick={onSubmit}>
895 Add
896 </Button>
897 </div>
898 </div>
899 </div>
900 );
901}
902
903function EnvVars({ node, disabled }: { node: ServiceNode; disabled?: boolean }): React.ReactNode {
904 const { id, data } = node;
gio8323a052025-08-04 06:12:26 +0000905 const mode = useMode();
906 const env = useEnv();
gio3d0bf032025-06-05 06:57:26 +0000907 const store = useStateStore();
gio1dacf1c2025-07-03 16:39:04 +0000908 const [name, setName] = useState("");
909 const [value, setValue] = useState("");
910
911 const addEnvVar = useCallback(() => {
912 if (!name.trim() || !value.trim()) return;
913 store.updateNodeData<"app">(id, {
914 envVars: (data.envVars || []).concat({
915 id: uuidv4(),
916 source: null,
917 name: name.toUpperCase(),
918 value: value,
919 }),
920 });
921 setName("");
922 setValue("");
923 }, [id, data, store, name, value]);
924
925 const removeEnvVar = useCallback(
926 (varId: string) => {
927 store.updateNodeData<"app">(id, {
928 envVars: (data.envVars || []).filter((v) => v.id !== varId),
929 });
930 },
931 [id, data, store],
932 );
933
934 const editValueEnvVar = useCallback(
935 (varId: string) => {
936 if (disabled) return;
937 store.updateNodeData<"app">(id, {
938 envVars: (data.envVars || []).map((v) => (v.id === varId ? { ...v, isEditting: true } : v)),
939 });
940 },
941 [id, data, store, disabled],
942 );
943
944 const saveValueEnvVar = useCallback(
945 (varId: string, newName: string, newValue: string) => {
946 store.updateNodeData<"app">(id, {
947 envVars: (data.envVars || []).map((v) => {
948 if (v.id === varId) {
949 return { ...v, name: newName.toUpperCase(), value: newValue, isEditting: false };
950 }
951 return v;
952 }),
953 });
954 },
955 [id, data, store],
956 );
957
gio3d0bf032025-06-05 06:57:26 +0000958 const editAlias = useCallback(
959 (e: BoundEnvVar) => {
960 return () => {
gioff9b5522025-07-03 13:50:30 +0000961 if (disabled) {
962 return;
963 }
gio3d0bf032025-06-05 06:57:26 +0000964 store.updateNodeData(id, {
965 ...data,
966 envVars: data.envVars!.map((o) => {
967 if (o.id !== e.id) {
968 return o;
969 } else
970 return {
971 ...o,
972 isEditting: true,
973 };
974 }),
975 });
976 };
977 },
gioff9b5522025-07-03 13:50:30 +0000978 [id, data, store, disabled],
gio3d0bf032025-06-05 06:57:26 +0000979 );
gio1dacf1c2025-07-03 16:39:04 +0000980
gio3d0bf032025-06-05 06:57:26 +0000981 const saveAlias = useCallback(
982 (e: BoundEnvVar, value: string, store: AppState) => {
983 store.updateNodeData(id, {
984 ...data,
985 envVars: data.envVars!.map((o) => {
986 if (o.id !== e.id) {
987 return o;
988 }
989 if (value) {
gio1dacf1c2025-07-03 16:39:04 +0000990 if ("name" in o && value.toUpperCase() === o.name.toUpperCase()) {
991 return {
992 ...o,
993 isEditting: false,
994 alias: undefined,
995 };
996 } else {
997 return {
998 ...o,
999 isEditting: false,
1000 alias: value.toUpperCase(),
1001 };
1002 }
gio3d0bf032025-06-05 06:57:26 +00001003 }
1004 if ("alias" in o) {
1005 const { alias: _, ...rest } = o;
1006 return {
1007 ...rest,
1008 isEditting: false,
1009 };
1010 }
1011 return {
1012 ...o,
1013 isEditting: false,
1014 };
1015 }),
giod0026612025-05-08 13:00:36 +00001016 });
1017 },
gio3d0bf032025-06-05 06:57:26 +00001018 [id, data],
giod0026612025-05-08 13:00:36 +00001019 );
gio1dacf1c2025-07-03 16:39:04 +00001020
gio3d0bf032025-06-05 06:57:26 +00001021 const saveAliasOnEnter = useCallback(
1022 (e: BoundEnvVar) => {
1023 return (event: KeyboardEvent<HTMLInputElement>) => {
1024 if (event.key === "Enter") {
gio3d0bf032025-06-05 06:57:26 +00001025 saveAlias(e, event.currentTarget.value, store);
gio1dacf1c2025-07-03 16:39:04 +00001026 } else if (event.key === "Escape") {
1027 store.updateNodeData(id, {
1028 ...data,
1029 envVars: data.envVars!.map((o) => (o.id === e.id ? { ...o, isEditting: false } : o)),
1030 });
giod0026612025-05-08 13:00:36 +00001031 }
gio3d0bf032025-06-05 06:57:26 +00001032 };
1033 },
gio1dacf1c2025-07-03 16:39:04 +00001034 [store, saveAlias, id, data],
gio3d0bf032025-06-05 06:57:26 +00001035 );
gio1dacf1c2025-07-03 16:39:04 +00001036
gio3d0bf032025-06-05 06:57:26 +00001037 const saveAliasOnBlur = useCallback(
1038 (e: BoundEnvVar) => {
1039 return (event: FocusEvent<HTMLInputElement>) => {
1040 saveAlias(e, event.currentTarget.value, store);
1041 };
1042 },
1043 [store, saveAlias],
1044 );
gio8323a052025-08-04 06:12:26 +00001045 const envVars = useMemo(() => {
1046 if (mode !== "deploy") {
1047 return [];
1048 }
1049 return env.access
1050 .filter((a) => a.name === data.label)
1051 .filter((a) => a.type === "env_var")
1052 .map((a) => ({
1053 name: a.var.split("=", 2)[0],
1054 value: a.var.split("=", 2)[1],
1055 }));
1056 }, [mode, env.access, data.label]);
1057
1058 const hiddenEnvVars = useMemo(() => {
1059 return envVars
1060 .map((v) => {
1061 const { name, value } = v;
1062 const match = value.match(/^(postgresql|mongodb):\/\/([^:]+):([^@]+)@([^:/]+)(?::(\d+))?\/(.+)$/);
1063 if (match) {
1064 const [_, protocol, username, _password, host, port, database] = match;
1065 return {
1066 name,
1067 value,
1068 hidden: `${protocol}://${username}:*****@${host}${port ? `:${port}` : ""}/${database}`,
1069 };
1070 }
1071 return {
1072 name,
1073 value,
1074 hidden: value,
1075 };
1076 })
1077 .map((v) => {
1078 return {
1079 ...v,
1080 hidden: v.hidden.length > 50 ? v.hidden.slice(0, 50) + "..." : v.hidden,
1081 };
1082 });
1083 }, [envVars]);
1084
1085 const [copied, setCopied] = useState(false);
1086 const [blip, setBlip] = useState(false);
1087
1088 const handleCopy = () => {
1089 navigator.clipboard.writeText(envVars.map((v) => `${v.name}=${v.value}`).join("\n"));
1090 setCopied(true);
1091 setBlip(true);
1092 setTimeout(() => setCopied(false), 1000);
1093 setTimeout(() => setBlip(false), 300);
1094 };
1095
1096 if (hiddenEnvVars.length > 0) {
1097 return (
1098 <div className="flex flex-col gap-1">
1099 <div className="grid grid-cols-[auto_minmax(0,1fr)] gap-1 flex-shrink">
1100 {hiddenEnvVars.map((v) => (
1101 <div key={v.name} className="contents">
1102 <div className="uppercase">{v.name}</div>
1103 <div className="min-w-0 truncate">{v.hidden}</div>
1104 </div>
1105 ))}
1106 </div>
1107 <div className="flex justify-end">
1108 <Button onClick={handleCopy} className={blip ? "bg-green-100 transition-colors" : ""}>
1109 {copied ? (
1110 <>
1111 <Check className="w-4 h-4 text-green-600" /> Copy
1112 </>
1113 ) : (
1114 <>
1115 <Copy className="w-4 h-4" /> Copy
1116 </>
1117 )}
1118 </Button>
1119 </div>
1120 </div>
1121 );
1122 }
gio1dacf1c2025-07-03 16:39:04 +00001123
gio3d0bf032025-06-05 06:57:26 +00001124 return (
gio1dacf1c2025-07-03 16:39:04 +00001125 <div className="flex flex-col gap-1">
1126 <div className="grid grid-cols-[auto_1fr_1fr_auto] gap-1">
1127 {data?.envVars?.map((v) => {
1128 if ("value" in v) {
1129 if (v.isEditting) {
1130 return (
1131 <div key={v.id} className="contents">
1132 <Input
1133 className="uppercase col-start-2"
1134 defaultValue={v.name}
1135 onKeyUp={(e) => {
1136 if (e.key === "Enter") {
1137 const nameInput = e.currentTarget;
1138 const valueInput = nameInput.parentElement?.querySelector(
1139 'input[placeholder="Value"]',
1140 ) as HTMLInputElement;
1141 if (valueInput) {
1142 saveValueEnvVar(v.id, nameInput.value, valueInput.value);
1143 }
1144 } else if (e.key === "Escape") {
1145 store.updateNodeData(id, {
1146 ...data,
1147 envVars: data.envVars!.map((o) =>
1148 o.id === v.id ? { ...o, isEditting: false } : o,
1149 ),
1150 });
1151 }
1152 }}
1153 autoFocus
1154 disabled={disabled}
1155 />
1156 <Input
1157 placeholder="Value"
1158 defaultValue={v.value}
1159 onKeyUp={(e) => {
1160 if (e.key === "Enter") {
1161 const valueInput = e.currentTarget;
1162 const nameInput = valueInput.parentElement?.querySelector(
1163 'input:not([placeholder="Value"])',
1164 ) as HTMLInputElement;
1165 if (nameInput) {
1166 saveValueEnvVar(v.id, nameInput.value, valueInput.value);
1167 }
1168 } else if (e.key === "Escape") {
1169 store.updateNodeData(id, {
1170 ...data,
1171 envVars: data.envVars!.map((o) =>
1172 o.id === v.id ? { ...o, isEditting: false } : o,
1173 ),
1174 });
1175 }
1176 }}
1177 disabled={disabled}
1178 />
1179 <Button
1180 variant="destructive"
1181 size="sm"
1182 onClick={() => removeEnvVar(v.id)}
1183 disabled={disabled}
1184 >
1185 Remove
1186 </Button>
1187 </div>
1188 );
1189 }
1190 return (
1191 <div
1192 key={v.id}
1193 className={`contents ${disabled ? "" : "cursor-text"}`}
1194 onClick={() => editValueEnvVar(v.id)}
1195 >
1196 <div>{!disabled && <Pencil className="w-4 h-4" />}</div>
1197 <div className={`${disabled ? "col-span-2" : ""} col-start-2`}>{v.name}</div>
1198 <div>{v.value}</div>
1199 <Button
1200 variant="destructive"
1201 size="sm"
1202 onClick={(e) => {
1203 e.stopPropagation();
1204 removeEnvVar(v.id);
1205 }}
1206 disabled={disabled}
1207 >
1208 Remove
1209 </Button>
1210 </div>
1211 );
1212 }
gio3d0bf032025-06-05 06:57:26 +00001213 if ("name" in v) {
1214 const value = "alias" in v ? v.alias : v.name;
1215 if (v.isEditting) {
1216 return (
gio1dacf1c2025-07-03 16:39:04 +00001217 <Input
1218 type="text"
1219 className="uppercase col-start-2 col-span-3"
1220 defaultValue={value}
1221 onKeyUp={saveAliasOnEnter(v)}
1222 onBlur={saveAliasOnBlur(v)}
1223 autoFocus={true}
1224 disabled={disabled}
1225 />
gio3d0bf032025-06-05 06:57:26 +00001226 );
1227 }
1228 return (
gio1dacf1c2025-07-03 16:39:04 +00001229 <div
1230 key={v.id}
1231 onClick={editAlias(v)}
1232 className={`contents ${disabled ? "" : "cursor-text"}`}
1233 >
1234 {!disabled && <Pencil className="w-4 h-4" />}
1235 <div className="col-start-2 col-span-3">
1236 <TooltipProvider>
1237 <Tooltip>
1238 <TooltipTrigger className="uppercase">{value}</TooltipTrigger>
1239 <TooltipContent>{v.name}</TooltipContent>
1240 </Tooltip>
1241 </TooltipProvider>
1242 </div>
1243 </div>
gio3d0bf032025-06-05 06:57:26 +00001244 );
1245 }
gio1dacf1c2025-07-03 16:39:04 +00001246 return null;
gio3d0bf032025-06-05 06:57:26 +00001247 })}
gio1dacf1c2025-07-03 16:39:04 +00001248 {!disabled && (
1249 <div className="contents">
1250 <Input
1251 placeholder="Name"
1252 className="uppercase col-start-2"
1253 value={name}
1254 onChange={(e) => setName(e.target.value)}
1255 disabled={disabled}
1256 />
1257 <Input
1258 placeholder="Value"
1259 value={value}
1260 onChange={(e) => setValue(e.target.value)}
1261 disabled={disabled}
1262 />
1263 <Button onClick={addEnvVar} disabled={disabled || !name.trim() || !value.trim()}>
1264 Add
1265 </Button>
1266 </div>
1267 )}
1268 </div>
1269 </div>
gio3d0bf032025-06-05 06:57:26 +00001270 );
1271}
1272
gio43e0aad2025-08-01 16:17:27 +04001273function usePrevious<T>(value: T) {
1274 const ref = useRef<T>();
1275 useEffect(() => {
1276 ref.current = value;
1277 }, [value]);
1278 return ref.current;
1279}
1280
1281function DevVM({ node, disabled }: { node: ServiceNode; disabled?: boolean }): React.ReactNode {
gio3d0bf032025-06-05 06:57:26 +00001282 const { id, data } = node;
gio43e0aad2025-08-01 16:17:27 +04001283 const { dev } = data;
1284 const prevDev = usePrevious(dev);
gio3d0bf032025-06-05 06:57:26 +00001285 const env = useEnv();
1286 const store = useStateStore();
gio48fde052025-05-14 09:48:08 +00001287 useEffect(() => {
gio43e0aad2025-08-01 16:17:27 +04001288 console.log("DDDEV", prevDev, dev);
1289 if (!dev && !prevDev) {
1290 return;
1291 }
1292 if (
1293 dev &&
1294 prevDev &&
1295 dev.enabled === prevDev.enabled &&
1296 "mode" in dev &&
1297 "mode" in prevDev &&
1298 dev.mode === prevDev.mode
1299 ) {
1300 return;
1301 }
1302 if (!dev?.enabled || dev.mode !== "VM") {
1303 if (prevDev?.enabled && prevDev.mode === "VM") {
1304 store.setNodes(
1305 store.nodes.filter((n) => n.id !== prevDev.codeServerNodeId && n.id !== prevDev.sshNodeId),
1306 );
1307 store.setEdges(
1308 store.edges.filter((e) => e.target !== prevDev.codeServerNodeId && e.target !== prevDev.sshNodeId),
1309 );
1310 if (dev?.enabled) {
gio48fde052025-05-14 09:48:08 +00001311 store.updateNodeData<"app">(id, {
1312 dev: {
gio43e0aad2025-08-01 16:17:27 +04001313 enabled: dev.enabled,
1314 mode: dev.mode,
gio48fde052025-05-14 09:48:08 +00001315 },
gio43e0aad2025-08-01 16:17:27 +04001316 ports: (data.ports || []).filter((p) => p.name !== "code-server" && p.name !== "ssh"),
gio48fde052025-05-14 09:48:08 +00001317 });
gio48fde052025-05-14 09:48:08 +00001318 } else {
gio48fde052025-05-14 09:48:08 +00001319 store.updateNodeData<"app">(id, {
1320 dev: {
1321 enabled: false,
gio48fde052025-05-14 09:48:08 +00001322 },
1323 ports: (data.ports || []).filter((p) => p.name !== "code-server" && p.name !== "ssh"),
1324 });
1325 }
1326 }
gio43e0aad2025-08-01 16:17:27 +04001327 } else {
1328 if (!prevDev?.enabled || prevDev.mode !== "VM") {
1329 const csGateway: Omit<GatewayHttpsNode, "position"> = {
1330 id: uuidv4(),
1331 type: "gateway-https",
1332 data: {
1333 readonly: true,
1334 https: {
1335 serviceId: id,
1336 portId: `${id}-code-server`,
1337 },
1338 network: dev?.expose?.network,
1339 subdomain: dev?.expose?.subdomain,
1340 label: "",
1341 envVars: [],
1342 ports: [],
1343 },
1344 };
1345 const sshGateway: Omit<GatewayTCPNode, "position"> = {
1346 id: uuidv4(),
1347 type: "gateway-tcp",
1348 data: {
1349 readonly: true,
1350 exposed: [
1351 {
1352 serviceId: id,
1353 portId: `${id}-ssh`,
1354 },
1355 ],
1356 network: dev?.expose?.network,
1357 subdomain: dev?.expose?.subdomain,
1358 label: "",
1359 envVars: [],
1360 ports: [],
1361 },
1362 };
1363 store.addNode(csGateway);
1364 store.addNode(sshGateway);
1365 store.updateNodeData<"app">(id, {
1366 dev: {
1367 enabled: true,
1368 mode: "VM",
1369 expose: dev?.expose,
1370 codeServerNodeId: csGateway.id,
1371 sshNodeId: sshGateway.id,
1372 },
1373 ports: (data.ports || []).concat(
1374 {
1375 id: `${id}-code-server`,
1376 name: "code-server",
1377 value: 9090,
1378 },
1379 {
1380 id: `${id}-ssh`,
1381 name: "ssh",
1382 value: 22,
1383 },
1384 ),
1385 });
1386 let edges = store.edges.concat([
1387 {
1388 id: uuidv4(),
1389 source: id,
1390 sourceHandle: "ports",
1391 target: csGateway.id,
1392 targetHandle: "https",
1393 },
1394 {
1395 id: uuidv4(),
1396 source: id,
1397 sourceHandle: "ports",
1398 target: sshGateway.id,
1399 targetHandle: "tcp",
1400 },
1401 ]);
1402 if (dev?.expose?.network !== undefined) {
1403 edges = edges.concat([
1404 {
1405 id: uuidv4(),
1406 source: csGateway.id,
1407 sourceHandle: "subdomain",
1408 target: dev.expose.network,
1409 targetHandle: "subdomain",
1410 },
1411 {
1412 id: uuidv4(),
1413 source: sshGateway.id,
1414 sourceHandle: "subdomain",
1415 target: dev.expose.network,
1416 targetHandle: "subdomain",
1417 },
1418 ]);
1419 }
1420 store.setEdges(edges);
1421 }
1422 }
1423 }, [id, data, dev, prevDev, store]);
gio48fde052025-05-14 09:48:08 +00001424 const exposeForm = useForm<z.infer<typeof exposeSchema>>({
1425 resolver: zodResolver(exposeSchema),
1426 mode: "onChange",
1427 defaultValues: {
gio43e0aad2025-08-01 16:17:27 +04001428 network: dev && "expose" in dev ? dev.expose?.network : undefined,
1429 subdomain: dev && "expose" in dev ? dev.expose?.subdomain : undefined,
gio48fde052025-05-14 09:48:08 +00001430 },
1431 });
1432 useEffect(() => {
1433 const sub = exposeForm.watch(
1434 (
1435 value: DeepPartial<z.infer<typeof exposeSchema>>,
1436 { name }: { name?: keyof z.infer<typeof exposeSchema> | undefined; type?: EventType | undefined },
1437 ) => {
1438 const { dev } = data;
gio43e0aad2025-08-01 16:17:27 +04001439 if (!dev?.enabled || dev.mode !== "VM") {
gio48fde052025-05-14 09:48:08 +00001440 return;
1441 }
1442 if (name === "network") {
1443 let edges = store.edges;
1444 if (dev.enabled && dev.expose?.network !== undefined) {
1445 edges = edges.filter((e) => {
1446 if (
1447 (e.source === dev.codeServerNodeId || e.source === dev.sshNodeId) &&
1448 e.sourceHandle === "subdomain" &&
1449 e.target === dev.expose?.network &&
1450 e.targetHandle === "subdomain"
1451 ) {
1452 return false;
1453 } else {
1454 return true;
1455 }
1456 });
1457 }
1458 if (value.network !== undefined) {
1459 edges = edges.concat(
1460 {
1461 id: uuidv4(),
1462 source: dev.codeServerNodeId,
1463 sourceHandle: "subdomain",
1464 target: value.network,
1465 targetHandle: "subdomain",
1466 },
1467 {
1468 id: uuidv4(),
1469 source: dev.sshNodeId,
1470 sourceHandle: "subdomain",
1471 target: value.network,
1472 targetHandle: "subdomain",
1473 },
1474 );
1475 }
1476 store.setEdges(edges);
1477 store.updateNodeData<"app">(id, {
1478 dev: {
1479 ...dev,
1480 expose: {
1481 network: value.network,
1482 subdomain: dev.expose?.subdomain,
1483 },
1484 },
1485 });
1486 store.updateNodeData<"gateway-https">(dev.codeServerNodeId, { network: value.network });
1487 store.updateNodeData<"gateway-tcp">(dev.sshNodeId, { network: value.network });
1488 } else if (name === "subdomain") {
1489 store.updateNodeData<"app">(id, {
1490 dev: {
1491 ...dev,
1492 expose: {
1493 network: dev.expose?.network,
1494 subdomain: value.subdomain,
1495 },
1496 },
1497 });
1498 store.updateNodeData<"gateway-https">(dev.codeServerNodeId, { subdomain: value.subdomain });
1499 store.updateNodeData<"gateway-tcp">(dev.sshNodeId, { subdomain: value.subdomain });
1500 }
1501 },
1502 );
1503 return () => sub.unsubscribe();
gio43e0aad2025-08-01 16:17:27 +04001504 }, [id, data, dev, prevDev, exposeForm, store]);
1505 if (!dev?.enabled || dev.mode !== "VM") {
1506 return null;
1507 }
giod0026612025-05-08 13:00:36 +00001508 return (
gio43e0aad2025-08-01 16:17:27 +04001509 <div>
gio29050d62025-05-16 04:49:26 +00001510 {data.dev && data.dev.enabled && (
1511 <Form {...exposeForm}>
1512 <form className="space-y-2">
gio3d0bf032025-06-05 06:57:26 +00001513 <Label>Network</Label>
gio29050d62025-05-16 04:49:26 +00001514 <FormField
1515 control={exposeForm.control}
1516 name="network"
1517 render={({ field }) => (
1518 <FormItem>
gio3ec94242025-05-16 12:46:57 +00001519 <Select
1520 onValueChange={field.onChange}
gio3d0bf032025-06-05 06:57:26 +00001521 value={field.value || ""}
gio3ec94242025-05-16 12:46:57 +00001522 disabled={disabled}
1523 >
gio29050d62025-05-16 04:49:26 +00001524 <FormControl>
1525 <SelectTrigger>
gio3d0bf032025-06-05 06:57:26 +00001526 <SelectValue />
gio29050d62025-05-16 04:49:26 +00001527 </SelectTrigger>
1528 </FormControl>
1529 <SelectContent>
1530 {env.networks.map((n) => (
1531 <SelectItem
1532 key={n.name}
1533 value={n.domain}
1534 >{`${n.name} - ${n.domain}`}</SelectItem>
1535 ))}
1536 </SelectContent>
1537 </Select>
1538 <FormMessage />
1539 </FormItem>
1540 )}
1541 />
gio3d0bf032025-06-05 06:57:26 +00001542 <Label>Subdomain</Label>
gio29050d62025-05-16 04:49:26 +00001543 <FormField
1544 control={exposeForm.control}
1545 name="subdomain"
1546 render={({ field }) => (
1547 <FormItem>
gio48fde052025-05-14 09:48:08 +00001548 <FormControl>
gio3d0bf032025-06-05 06:57:26 +00001549 <Input {...field} disabled={disabled} />
gio48fde052025-05-14 09:48:08 +00001550 </FormControl>
gio29050d62025-05-16 04:49:26 +00001551 <FormMessage />
1552 </FormItem>
1553 )}
1554 />
1555 </form>
1556 </Form>
1557 )}
gio43e0aad2025-08-01 16:17:27 +04001558 </div>
1559 );
1560}
1561
1562function DevProxy({ node, disabled }: { node: ServiceNode; disabled?: boolean }): React.ReactNode {
1563 const { id, data } = node;
1564 const store = useStateStore();
1565 const { toast } = useToast();
1566 const [machines, setMachines] = useState<Machines>([]);
1567 const [loading, setLoading] = useState(false);
1568 const [error, setError] = useState<string | null>(null);
1569
1570 const fetchMachines = useCallback(async () => {
1571 setLoading(true);
1572 setError(null);
1573 try {
1574 const response = await fetch("/api/machines", {
1575 method: "GET",
1576 headers: {
1577 "Content-Type": "application/json",
1578 },
1579 });
1580
1581 if (!response.ok) {
1582 throw new Error(`Failed to fetch machines: ${response.statusText}`);
1583 }
1584
1585 const machinesData = MachinesSchema.safeParse(await response.json());
1586 if (machinesData.success) {
gioa4bf4712025-08-03 02:21:28 +00001587 setMachines(
1588 machinesData.data
1589 .filter((m) => !m.name.startsWith("proxy-dodo-app-"))
1590 .filter(
1591 (m) => m.last_seen && m.last_seen.seconds * 1000 >= Date.now() - 7 * 24 * 60 * 60 * 1000,
1592 ),
1593 );
gio43e0aad2025-08-01 16:17:27 +04001594 } else {
1595 throw new Error("Invalid machines data");
1596 }
1597 } catch (err) {
1598 const errorMessage = err instanceof Error ? err.message : "Failed to fetch machines";
1599 setError(errorMessage);
1600 toast({
1601 variant: "destructive",
1602 title: "Error",
1603 description: errorMessage,
1604 });
1605 } finally {
1606 setLoading(false);
1607 }
1608 }, [toast]);
1609
1610 useEffect(() => {
1611 if (data.dev?.enabled && "mode" in data.dev && data.dev.mode === "PROXY") {
1612 fetchMachines();
1613 }
1614 }, [data.dev, fetchMachines]);
1615
1616 const proxyForm = useForm<z.infer<typeof proxySchema>>({
1617 resolver: zodResolver(proxySchema),
1618 mode: "onChange",
1619 defaultValues: {
1620 address: data.dev && "address" in data.dev ? data.dev.address : undefined,
1621 },
1622 });
1623
1624 useEffect(() => {
1625 const sub = proxyForm.watch((value, { name }) => {
1626 if (name === "address" && value.address) {
1627 store.updateNodeData<"app">(id, {
1628 dev: {
1629 enabled: true,
1630 mode: "PROXY",
1631 address: value.address,
1632 },
1633 });
1634 }
1635 });
1636 return () => sub.unsubscribe();
1637 }, [id, proxyForm, store]);
1638
1639 if (!data.dev?.enabled || data.dev.mode !== "PROXY") {
1640 return null;
1641 }
1642 return (
1643 <div className="space-y-2">
1644 <Form {...proxyForm}>
1645 <form className="space-y-2">
1646 <FormField
1647 control={proxyForm.control}
1648 name="address"
1649 render={({ field }) => (
1650 <FormItem>
1651 <Select
1652 onValueChange={field.onChange}
1653 value={field.value || ""}
1654 disabled={disabled || loading}
1655 >
1656 <FormControl>
1657 <SelectTrigger>
1658 {loading ? (
1659 <div className="flex items-center gap-2">
1660 <LoaderCircle className="h-4 w-4 animate-spin" />
1661 <span>Loading machines...</span>
1662 </div>
1663 ) : (
1664 <SelectValue placeholder="Select a machine" />
1665 )}
1666 </SelectTrigger>
1667 </FormControl>
1668 <SelectContent>
1669 {loading ? (
1670 <div className="flex items-center justify-center p-4">
1671 <LoaderCircle className="h-4 w-4 animate-spin" />
1672 <span className="ml-2">Loading...</span>
1673 </div>
1674 ) : error ? (
1675 <div className="flex flex-col items-center justify-center p-4 text-destructive">
1676 <span className="text-sm">Failed to load machines</span>
1677 <Button
1678 variant="ghost"
1679 size="sm"
1680 className="mt-2"
1681 onClick={fetchMachines}
1682 >
1683 Retry
1684 </Button>
1685 </div>
1686 ) : machines.length === 0 ? (
1687 <div className="flex items-center justify-center p-4 text-muted-foreground">
1688 <span className="text-sm">No machines available</span>
1689 </div>
1690 ) : (
1691 machines.map((machine: Machine) => (
1692 <SelectItem key={machine.name} value={machine.name}>
1693 {machine.name}
1694 </SelectItem>
1695 ))
1696 )}
1697 </SelectContent>
1698 </Select>
1699 <FormMessage />
1700 </FormItem>
1701 )}
1702 />
1703 </form>
1704 </Form>
1705 </div>
1706 );
1707}
1708
1709function Dev({ node, disabled }: { node: ServiceNode; disabled?: boolean }): React.ReactNode {
1710 const { id, data } = node;
1711 const store = useStateStore();
1712 const devForm = useForm<z.infer<typeof devSchema>>({
1713 resolver: zodResolver(devSchema),
1714 mode: "onChange",
1715 defaultValues: {
1716 enabled: data.dev ? data.dev.enabled : false,
1717 mode: data.dev?.enabled ? data.dev.mode : undefined,
1718 },
1719 });
1720 useEffect(() => {
1721 const sub = devForm.watch((value, { name }) => {
1722 console.log("DDDEVV", name, value, data.dev);
1723 if (name === "enabled") {
1724 if (value.enabled) {
1725 if (data.dev?.enabled && data.dev.mode === "VM") {
1726 return;
1727 }
1728 store.updateNodeData<"app">(id, {
1729 dev: {
1730 enabled: true,
1731 mode: "VM",
1732 },
1733 });
1734 devForm.setValue("mode", "VM");
1735 } else {
1736 store.updateNodeData<"app">(id, {
1737 dev: {
1738 enabled: false,
1739 },
1740 });
1741 }
1742 } else if (name === "mode") {
1743 if (data.dev?.enabled && data.dev.mode === value.mode) {
1744 return;
1745 }
1746 store.updateNodeData<"app">(id, {
1747 dev: {
1748 enabled: true,
1749 mode: value.mode,
1750 },
1751 });
1752 }
1753 });
1754 return () => sub.unsubscribe();
1755 }, [id, data, devForm, store]);
1756 return (
1757 <>
1758 <Form {...devForm}>
1759 <form className="space-y-2">
1760 <FormField
1761 control={devForm.control}
1762 name="enabled"
1763 render={({ field }) => (
1764 <FormItem>
1765 <div className="flex flex-row gap-1 items-center">
1766 <Switch
1767 id="devEnabled"
1768 onCheckedChange={field.onChange}
1769 checked={field.value}
1770 disabled={disabled}
1771 />
1772 <Label htmlFor="devEnabled">Development Mode</Label>
1773 </div>
1774 <FormMessage />
1775 </FormItem>
1776 )}
1777 />
1778 {data.dev?.enabled && (
1779 <FormField
1780 control={devForm.control}
1781 name="mode"
1782 render={({ field }) => (
1783 <FormItem>
1784 <div className="flex flex-row gap-1 items-center">
1785 <RadioGroup
1786 onValueChange={field.onChange}
1787 value={field.value}
1788 disabled={disabled}
1789 >
1790 <div className="flex items-center space-x-2">
1791 <RadioGroupItem value="VM" id="vm" />
1792 <Label htmlFor="vm">Create a VM</Label>
1793 </div>
1794 <div className="flex items-center space-x-2">
1795 <RadioGroupItem value="PROXY" id="proxy" />
1796 <Label htmlFor="proxy">Proxy to existing machine</Label>
1797 </div>
1798 </RadioGroup>
1799 </div>
1800 </FormItem>
1801 )}
1802 />
1803 )}
1804 </form>
1805 </Form>
1806 <DevVM node={node} disabled={disabled} />
1807 <DevProxy node={node} disabled={disabled} />
giod0026612025-05-08 13:00:36 +00001808 </>
1809 );
1810}
gio3d0bf032025-06-05 06:57:26 +00001811
1812function SourceRepo({ node, disabled }: { node: ServiceNode; disabled?: boolean }): React.ReactNode {
1813 const { id, data } = node;
1814 const store = useStateStore();
1815 const nodes = useNodes<AppNode>();
1816 const repo = useMemo(() => {
1817 return nodes
1818 .filter((n): n is GithubNode => n.type === "github")
1819 .find((n) => n.id === data.repository?.repoNodeId);
1820 }, [nodes, data.repository?.repoNodeId]);
1821 const repos = useGithubRepositories();
1822 const sourceForm = useForm<z.infer<typeof sourceSchema>>({
1823 resolver: zodResolver(sourceSchema),
1824 mode: "onChange",
1825 defaultValues: {
1826 id: data?.repository?.id?.toString(),
1827 branch: data.repository && "branch" in data.repository ? data.repository.branch : undefined,
1828 rootDir: data.repository && "rootDir" in data.repository ? data.repository.rootDir : undefined,
1829 },
1830 });
1831 useEffect(() => {
1832 const sub = sourceForm.watch(
1833 (
1834 value: DeepPartial<z.infer<typeof sourceSchema>>,
1835 { name }: { name?: keyof z.infer<typeof sourceSchema> | undefined; type?: EventType | undefined },
1836 ) => {
1837 if (name === "id") {
1838 const newRepoId = value.id ? parseInt(value.id, 10) : undefined;
1839 if (!newRepoId) return;
1840
1841 const oldGithubNodeId = data.repository?.repoNodeId;
1842 const selectedRepo = repos.find((r) => r.id === newRepoId);
1843
1844 if (!selectedRepo) return;
1845
1846 // If a node for the selected repo already exists, connect to it.
1847 const existingNodeForSelectedRepo = nodes
1848 .filter((n): n is GithubNode => n.type === "github")
1849 .find((n) => n.data.repository?.id === selectedRepo.id);
1850
1851 if (existingNodeForSelectedRepo) {
1852 let { nodes, edges } = store;
1853 if (oldGithubNodeId) {
1854 edges = edges.filter(
1855 (e) =>
1856 !(
1857 e.target === id &&
1858 e.source === oldGithubNodeId &&
1859 e.targetHandle === "repository"
1860 ),
1861 );
1862 }
1863 edges = edges.concat({
1864 id: uuidv4(),
1865 source: existingNodeForSelectedRepo.id,
1866 sourceHandle: "repository",
1867 target: id,
1868 targetHandle: "repository",
1869 });
1870 nodes = nodes.map((n) => {
1871 if (n.id !== id) {
1872 return n;
1873 } else {
1874 const sn = n as ServiceNode;
1875 return {
1876 ...sn,
1877 data: {
1878 ...sn.data,
1879 repository: {
1880 ...sn.data.repository,
1881 id: newRepoId,
1882 repoNodeId: existingNodeForSelectedRepo.id,
1883 },
1884 },
1885 };
1886 }
1887 });
1888 if (oldGithubNodeId && oldGithubNodeId !== existingNodeForSelectedRepo.id) {
1889 const isOldNodeStillUsed = edges.some(
1890 (e) => e.source === oldGithubNodeId && e.sourceHandle === "repository",
1891 );
1892 if (!isOldNodeStillUsed) {
1893 nodes = nodes.filter((n) => n.id !== oldGithubNodeId);
1894 }
1895 }
1896 store.setNodes(nodes);
1897 store.setEdges(edges);
1898 return;
1899 }
1900
1901 // No node for selected repo, decide whether to update old node or create a new one.
1902 if (oldGithubNodeId) {
1903 const isOldNodeShared =
1904 store.edges.filter(
1905 (e) =>
1906 e.source === oldGithubNodeId && e.target !== id && e.sourceHandle === "repository",
1907 ).length > 0;
1908
1909 if (!isOldNodeShared) {
1910 // Update old node
1911 store.updateNodeData<"github">(oldGithubNodeId, {
1912 repository: {
1913 id: selectedRepo.id,
1914 sshURL: selectedRepo.ssh_url,
1915 fullName: selectedRepo.full_name,
1916 },
1917 label: selectedRepo.full_name,
1918 });
1919 store.updateNodeData<"app">(id, {
1920 repository: {
1921 ...data.repository,
1922 id: newRepoId,
1923 },
1924 });
1925 } else {
1926 // Create new node because old one is shared
1927 const newGithubNodeId = uuidv4();
1928 store.addNode({
1929 id: newGithubNodeId,
1930 type: "github",
1931 data: {
1932 repository: {
1933 id: selectedRepo.id,
1934 sshURL: selectedRepo.ssh_url,
1935 fullName: selectedRepo.full_name,
1936 },
1937 label: selectedRepo.full_name,
1938 envVars: [],
1939 ports: [],
1940 },
1941 });
1942
1943 let edges = store.edges;
1944 // remove old edge
1945 edges = edges.filter(
1946 (e) =>
1947 !(
1948 e.target === id &&
1949 e.source === oldGithubNodeId &&
1950 e.targetHandle === "repository"
1951 ),
1952 );
1953 // add new edge
1954 edges = edges.concat({
1955 id: uuidv4(),
1956 source: newGithubNodeId,
1957 sourceHandle: "repository",
1958 target: id,
1959 targetHandle: "repository",
1960 });
1961 store.setEdges(edges);
1962 store.updateNodeData<"app">(id, {
1963 repository: {
1964 ...data.repository,
1965 id: newRepoId,
1966 repoNodeId: newGithubNodeId,
1967 },
1968 });
1969 }
1970 } else {
1971 // No old github node, so create a new one
1972 const newGithubNodeId = uuidv4();
1973 store.addNode({
1974 id: newGithubNodeId,
1975 type: "github",
1976 data: {
1977 repository: {
1978 id: selectedRepo.id,
1979 sshURL: selectedRepo.ssh_url,
1980 fullName: selectedRepo.full_name,
1981 },
1982 label: selectedRepo.full_name,
1983 envVars: [],
1984 ports: [],
1985 },
1986 });
1987 store.setEdges(
1988 store.edges.concat({
1989 id: uuidv4(),
1990 source: newGithubNodeId,
1991 sourceHandle: "repository",
1992 target: id,
1993 targetHandle: "repository",
1994 }),
1995 );
1996 store.updateNodeData<"app">(id, {
1997 repository: {
1998 ...data.repository,
1999 id: newRepoId,
2000 repoNodeId: newGithubNodeId,
2001 },
2002 });
2003 }
2004 } else if (name === "branch") {
2005 store.updateNodeData<"app">(id, {
2006 repository: {
2007 ...data?.repository,
2008 branch: value.branch,
2009 },
2010 });
2011 } else if (name === "rootDir") {
2012 store.updateNodeData<"app">(id, {
2013 repository: {
2014 ...data?.repository,
2015 rootDir: value.rootDir,
2016 },
2017 });
2018 }
2019 },
2020 );
2021 return () => sub.unsubscribe();
2022 }, [id, data, sourceForm, store, nodes, repos]);
2023 const [isExpanded, setIsExpanded] = useState(false);
2024 // useEffect(() => {
2025 // if (data.repository === undefined) {
2026 // setIsExpanded(true);
2027 // }
2028 // }, [data.repository, setIsExpanded]);
2029 console.log(data.repository, isExpanded, repo);
2030 return (
2031 <Accordion type="single" collapsible>
2032 <AccordionItem value="repository" className="border-none">
2033 <AccordionTrigger onClick={() => setIsExpanded(!isExpanded)}>
2034 Source {!isExpanded && repo !== undefined && repo.data.repository?.fullName}
2035 </AccordionTrigger>
2036 <AccordionContent className="px-1">
2037 <Form {...sourceForm}>
2038 <form className="space-y-2">
2039 <Label>Repository</Label>
2040 <FormField
2041 control={sourceForm.control}
2042 name="id"
2043 render={({ field }) => (
2044 <FormItem>
2045 <Select onValueChange={field.onChange} value={field.value} disabled={disabled}>
2046 <FormControl>
2047 <SelectTrigger>
2048 <SelectValue />
2049 </SelectTrigger>
2050 </FormControl>
2051 <SelectContent>
2052 {repos.map((r) => (
2053 <SelectItem
2054 key={r.id}
2055 value={r.id.toString()}
2056 >{`${r.full_name}`}</SelectItem>
2057 ))}
2058 </SelectContent>
2059 </Select>
2060 <FormMessage />
2061 </FormItem>
2062 )}
2063 />
2064 <Label>Branch</Label>
2065 <FormField
2066 control={sourceForm.control}
2067 name="branch"
2068 render={({ field }) => (
2069 <FormItem>
2070 <FormControl>
2071 <Input
2072 placeholder="master"
2073 className="lowercase"
2074 {...field}
2075 disabled={disabled}
2076 />
2077 </FormControl>
2078 <FormMessage />
2079 </FormItem>
2080 )}
2081 />
2082 <Label>Root Directory</Label>
2083 <FormField
2084 control={sourceForm.control}
2085 name="rootDir"
2086 render={({ field }) => (
2087 <FormItem>
2088 <FormControl>
2089 <Input placeholder="/" {...field} disabled={disabled} />
2090 </FormControl>
2091 <FormMessage />
2092 </FormItem>
2093 )}
2094 />
2095 </form>
2096 </Form>
2097 </AccordionContent>
2098 </AccordionItem>
2099 </Accordion>
2100 );
2101}