blob: e63c76a625f3929d58e45897f22c964b40693990 [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";
gio43e0aad2025-08-01 16:17:27 +04004import {
5 ServiceNode,
6 ServiceTypes,
7 GatewayHttpsNode,
8 GatewayTCPNode,
9 BoundEnvVar,
10 AppNode,
11 GithubNode,
12 Machines,
13 Machine,
14 MachinesSchema,
15} from "config";
16import { KeyboardEvent, FocusEvent, useCallback, useEffect, useMemo, useState, useRef } from "react";
gio5f2f1002025-03-20 18:38:48 +040017import { z } from "zod";
gio3d0bf032025-06-05 06:57:26 +000018import { useForm, EventType, DeepPartial } from "react-hook-form";
giod0026612025-05-08 13:00:36 +000019import { zodResolver } from "@hookform/resolvers/zod";
gio69ff7592025-07-03 06:27:21 +000020import { Form, FormControl, FormField, FormItem, FormMessage, FormLabel } from "./ui/form";
giod0026612025-05-08 13:00:36 +000021import { Button } from "./ui/button";
gio33990c62025-05-06 07:51:24 +000022import { Handle, Position, useNodes } from "@xyflow/react";
gio5f2f1002025-03-20 18:38:48 +040023import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
gio5f2f1002025-03-20 18:38:48 +040024import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip";
gio91165612025-05-03 17:07:38 +000025import { Textarea } from "./ui/textarea";
giofcefd7c2025-05-13 08:01:07 +000026import { Input } from "./ui/input";
gio3d0bf032025-06-05 06:57:26 +000027import { Switch } from "./ui/switch";
gio48fde052025-05-14 09:48:08 +000028import { Label } from "./ui/label";
gio3d0bf032025-06-05 06:57:26 +000029import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs";
30import { Code, Container, Network, Pencil, Variable } from "lucide-react";
gio3d0bf032025-06-05 06:57:26 +000031import { Badge } from "./ui/badge";
32import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from "./ui/accordion";
gio3fb133d2025-06-13 07:20:24 +000033import { Name } from "./node-name";
34import { NodeDetailsProps } from "@/lib/types";
gio9f3d4f52025-07-04 08:42:34 +000035import { Gateway } from "@/Gateways";
gio2e7d2172025-07-04 09:24:53 +000036import { Port } from "config";
37import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
gio43e0aad2025-08-01 16:17:27 +040038import { RadioGroup, RadioGroupItem } from "./ui/radio-group";
39import { useToast } from "@/hooks/use-toast";
40import { LoaderCircle } from "lucide-react";
gio2e7d2172025-07-04 09:24:53 +000041
42const sourceSchema = z.object({
43 id: z.string().min(1, "required"),
44 branch: z.string(),
45 rootDir: z.string(),
46});
47
48const devSchema = z.object({
49 enabled: z.boolean(),
gio43e0aad2025-08-01 16:17:27 +040050 mode: z.enum(["VM", "PROXY"]).optional(),
gio2e7d2172025-07-04 09:24:53 +000051});
52
53const exposeSchema = z.object({
54 network: z.string().min(1, "reqired"),
55 subdomain: z.string().min(1, "required"),
56});
57
58const agentSchema = z.object({
59 model: z.enum(["gemini", "claude"]),
60 apiKey: z.string().optional(),
61});
62
gio43e0aad2025-08-01 16:17:27 +040063const proxySchema = z.object({
64 address: z.string().min(1, "required"),
65});
66
gio2e7d2172025-07-04 09:24:53 +000067const portExposeSchema = z
68 .object({
69 type: z.enum(["https", "tcp"]),
70 network: z.string().min(1, "Required"),
71 subdomain: z.string().optional(),
72 })
73 .refine(
74 (data) => {
75 if (data.type === "https" || data.type === "tcp") {
76 return !!data.subdomain && data.subdomain.length > 0;
77 }
78 return true;
79 },
80 {
81 message: "Subdomain is required",
82 path: ["subdomain"],
83 },
84 );
85
86type PortExposeFormValues = z.infer<typeof portExposeSchema>;
gio5f2f1002025-03-20 18:38:48 +040087
88export function NodeApp(node: ServiceNode) {
giod0026612025-05-08 13:00:36 +000089 const { id, selected } = node;
90 const isConnectablePorts = useMemo(() => nodeIsConnectable(node, "ports"), [node]);
91 const isConnectableRepository = useMemo(() => nodeIsConnectable(node, "repository"), [node]);
92 return (
gio69148322025-06-19 23:16:12 +040093 <NodeRect id={id} selected={selected} node={node} state={node.data.state}>
giod0026612025-05-08 13:00:36 +000094 <div style={{ padding: "10px 20px" }}>
95 {nodeLabel(node)}
96 <Handle
97 id="repository"
98 type={"target"}
99 position={Position.Left}
100 isConnectableStart={isConnectableRepository}
101 isConnectableEnd={isConnectableRepository}
102 isConnectable={isConnectableRepository}
103 />
104 <Handle
105 id="ports"
106 type={"source"}
107 position={Position.Top}
108 isConnectableStart={isConnectablePorts}
109 isConnectableEnd={isConnectablePorts}
110 isConnectable={isConnectablePorts}
111 />
112 <Handle
113 id="env_var"
114 type={"target"}
115 position={Position.Bottom}
116 isConnectableStart={true}
117 isConnectableEnd={true}
118 isConnectable={true}
119 />
120 </div>
121 </NodeRect>
122 );
gio5f2f1002025-03-20 18:38:48 +0400123}
124
125const schema = z.object({
giod0026612025-05-08 13:00:36 +0000126 name: z.string().min(1, "requried"),
127 type: z.enum(ServiceTypes),
gio5f2f1002025-03-20 18:38:48 +0400128});
129
gio2e7d2172025-07-04 09:24:53 +0000130function ExposeForm({
131 node,
132 port,
133 onDone,
134 disabled,
135}: {
136 node: ServiceNode;
137 port: Port;
138 onDone: () => void;
139 disabled?: boolean;
140}) {
141 const store = useStateStore();
142 const nodes = useNodes<AppNode>();
143 const env = useEnv();
144 const form = useForm<PortExposeFormValues>({
145 resolver: zodResolver(portExposeSchema),
146 mode: "onChange",
147 defaultValues: {
148 type: "https",
149 },
150 });
gio33990c62025-05-06 07:51:24 +0000151
gio2e7d2172025-07-04 09:24:53 +0000152 const onSubmit = (data: PortExposeFormValues) => {
153 const networkNode = nodes.find((n) => n.type === "network" && n.data.domain === data.network);
154 if (!networkNode) {
155 // TODO: should show an error to the user
156 return;
157 }
158 if (data.type === "https") {
159 const newNode: Omit<GatewayHttpsNode, "position"> = {
160 id: uuidv4(),
161 type: "gateway-https",
162 data: {
163 https: {
164 serviceId: node.id,
165 portId: port.id,
166 },
167 network: data.network,
168 subdomain: data.subdomain!,
169 label: "",
170 envVars: [],
171 ports: [],
172 },
173 };
174 store.addNode(newNode);
175 store.setEdges(
176 store.edges.concat(
177 {
178 id: uuidv4(),
179 source: node.id,
180 sourceHandle: "ports",
181 target: newNode.id,
182 targetHandle: "https",
183 },
184 {
185 id: uuidv4(),
186 source: newNode.id,
187 sourceHandle: "subdomain",
188 target: networkNode.id,
189 targetHandle: "subdomain",
190 },
191 ),
192 );
193 } else if (data.type === "tcp") {
194 const existingGateway = nodes.find(
195 (n): n is GatewayTCPNode =>
196 n.type === "gateway-tcp" && n.data.network === data.network && n.data.subdomain === data.subdomain,
197 );
198 if (existingGateway) {
199 store.updateNodeData<"gateway-tcp">(existingGateway.id, {
200 exposed: [...existingGateway.data.exposed, { serviceId: node.id, portId: port.id }],
201 });
202 let edges = store.edges.concat({
203 id: uuidv4(),
204 source: node.id,
205 sourceHandle: "ports",
206 target: existingGateway.id,
207 targetHandle: "tcp",
208 });
209 if (
210 !edges.find(
211 (e) =>
212 e.source === existingGateway.id &&
213 e.target === networkNode.id &&
214 e.sourceHandle === "subdomain" &&
215 e.targetHandle === "subdomain",
216 )
217 ) {
218 edges = edges.concat({
219 id: uuidv4(),
220 source: existingGateway.id,
221 sourceHandle: "subdomain",
222 target: networkNode.id,
223 targetHandle: "subdomain",
224 });
225 }
226 store.setEdges(edges);
227 } else {
228 const newNode: Omit<GatewayTCPNode, "position"> = {
229 id: uuidv4(),
230 type: "gateway-tcp",
231 data: {
232 exposed: [{ serviceId: node.id, portId: port.id }],
233 network: data.network,
234 subdomain: data.subdomain,
235 label: "",
236 envVars: [],
237 ports: [],
238 },
239 };
240 store.addNode(newNode);
241 store.setEdges(
242 store.edges.concat(
243 {
244 id: uuidv4(),
245 source: node.id,
246 sourceHandle: "ports",
247 target: newNode.id,
248 targetHandle: "tcp",
249 },
250 {
251 id: uuidv4(),
252 source: newNode.id,
253 sourceHandle: "subdomain",
254 target: networkNode.id,
255 targetHandle: "subdomain",
256 },
257 ),
258 );
259 }
260 }
261 onDone();
262 };
gio48fde052025-05-14 09:48:08 +0000263
gio2e7d2172025-07-04 09:24:53 +0000264 const type = form.watch("type");
gio48fde052025-05-14 09:48:08 +0000265
gio2e7d2172025-07-04 09:24:53 +0000266 return (
267 <Form {...form}>
268 <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-2 border-t mt-2 pt-2">
269 <FormField
270 control={form.control}
271 name="type"
272 render={({ field }) => (
273 <FormItem>
274 <FormLabel>Gateway Type</FormLabel>
275 <Select onValueChange={field.onChange} defaultValue={field.value} disabled={disabled}>
276 <FormControl>
277 <SelectTrigger>
278 <SelectValue placeholder="Select a type" />
279 </SelectTrigger>
280 </FormControl>
281 <SelectContent>
282 <SelectItem value="https">HTTPS</SelectItem>
283 <SelectItem value="tcp">TCP</SelectItem>
284 </SelectContent>
285 </Select>
286 <FormMessage />
287 </FormItem>
288 )}
289 />
290 <FormField
291 control={form.control}
292 name="network"
293 render={({ field }) => (
294 <FormItem>
295 <FormLabel>Network</FormLabel>
296 <Select onValueChange={field.onChange} defaultValue={field.value} disabled={disabled}>
297 <FormControl>
298 <SelectTrigger>
299 <SelectValue placeholder="Select a network" />
300 </SelectTrigger>
301 </FormControl>
302 <SelectContent>
303 {env.networks.map((n) => (
304 <SelectItem key={n.domain} value={n.domain}>
305 {n.name} - {n.domain}
306 </SelectItem>
307 ))}
308 </SelectContent>
309 </Select>
310 <FormMessage />
311 </FormItem>
312 )}
313 />
314 {(type === "https" || type === "tcp") && (
315 <FormField
316 control={form.control}
317 name="subdomain"
318 render={({ field }) => (
319 <FormItem>
320 <FormLabel>Subdomain</FormLabel>
321 <FormControl>
322 <Input placeholder="subdomain" {...field} disabled={disabled} />
323 </FormControl>
324 <FormMessage />
325 </FormItem>
326 )}
327 />
328 )}
329 <div className="flex justify-end gap-2">
330 <Button type="button" variant="ghost" onClick={onDone} disabled={disabled}>
331 Cancel
332 </Button>
333 <Button type="submit" disabled={disabled || !form.formState.isValid}>
334 Expose
335 </Button>
336 </div>
337 </form>
338 </Form>
339 );
340}
gio69148322025-06-19 23:16:12 +0400341
gioe7734b22025-06-13 10:12:04 +0000342export function NodeAppDetails({ node, disabled, showName = true, isOverview = false }: NodeDetailsProps<ServiceNode>) {
gio3d0bf032025-06-05 06:57:26 +0000343 const { data } = node;
gio43e0aad2025-08-01 16:17:27 +0400344 const defaultTab = useMemo(() => {
345 if (data.dev?.enabled) {
346 return "dev";
347 }
348 return "runtime";
349 }, [data]);
gio3d0bf032025-06-05 06:57:26 +0000350 return (
351 <>
gio3fb133d2025-06-13 07:20:24 +0000352 {showName ? <Name node={node} disabled={disabled} /> : null}
gio43e0aad2025-08-01 16:17:27 +0400353 <Tabs defaultValue={defaultTab}>
gio3d0bf032025-06-05 06:57:26 +0000354 <TabsList className="w-full flex flex-row justify-between">
355 <TabsTrigger value="runtime">
gioe7734b22025-06-13 10:12:04 +0000356 {isOverview ? (
357 <div className="flex flex-row gap-1 items-center">
358 <Container /> Runtime
359 </div>
360 ) : (
361 <TooltipProvider>
362 <Tooltip>
363 <TooltipTrigger>
364 <Container />
365 </TooltipTrigger>
366 <TooltipContent>Runtime</TooltipContent>
367 </Tooltip>
368 </TooltipProvider>
369 )}
gio3d0bf032025-06-05 06:57:26 +0000370 </TabsTrigger>
371 <TabsTrigger value="ports">
gioe7734b22025-06-13 10:12:04 +0000372 {isOverview ? (
373 <div className="flex flex-row gap-1 items-center">
374 <Network /> Ports
375 <Badge className="rounded-full">{data.ports?.length ?? 0}</Badge>
376 </div>
377 ) : (
378 <TooltipProvider>
379 <Tooltip>
380 <TooltipTrigger className="flex flex-row gap-1 items-center">
381 <Network />
382 </TooltipTrigger>
383 <TooltipContent>
384 Ports{" "}
385 <Badge variant="secondary" className="rounded-full">
386 {data.ports?.length ?? 0}
387 </Badge>
388 </TooltipContent>
389 </Tooltip>
390 </TooltipProvider>
391 )}
gio3d0bf032025-06-05 06:57:26 +0000392 </TabsTrigger>
393 <TabsTrigger value="vars">
gioe7734b22025-06-13 10:12:04 +0000394 {isOverview ? (
395 <div className="flex flex-row gap-1 items-center">
396 <Variable /> Variables
397 <Badge className="rounded-full">{data.envVars?.length ?? 0}</Badge>
398 </div>
399 ) : (
400 <TooltipProvider>
401 <Tooltip>
402 <TooltipTrigger className="flex flex-row gap-1 items-center">
403 <Variable />
404 </TooltipTrigger>
405 <TooltipContent>
406 Variables{" "}
407 <Badge variant="secondary" className="rounded-full">
408 {data.envVars?.length ?? 0}
409 </Badge>
410 </TooltipContent>
411 </Tooltip>
412 </TooltipProvider>
413 )}
gio3d0bf032025-06-05 06:57:26 +0000414 </TabsTrigger>
gio69148322025-06-19 23:16:12 +0400415 {node.data.type !== "sketch:latest" && (
416 <TabsTrigger value="dev">
417 {isOverview ? (
418 <div className="flex flex-row gap-1 items-center">
419 <Code /> Dev
420 </div>
421 ) : (
422 <TooltipProvider>
423 <Tooltip>
424 <TooltipTrigger className="flex flex-row gap-1 items-center">
425 <Code />
426 </TooltipTrigger>
427 <TooltipContent>Dev</TooltipContent>
428 </Tooltip>
429 </TooltipProvider>
430 )}
431 </TabsTrigger>
432 )}
gio3d0bf032025-06-05 06:57:26 +0000433 </TabsList>
434 <TabsContent value="runtime">
435 <Runtime node={node} disabled={disabled} />
436 </TabsContent>
437 <TabsContent value="ports">
gio2e7d2172025-07-04 09:24:53 +0000438 <Ports node={node} disabled={disabled} isOverview={isOverview} />
gio3d0bf032025-06-05 06:57:26 +0000439 </TabsContent>
440 <TabsContent value="vars">
441 <EnvVars node={node} disabled={disabled} />
442 </TabsContent>
gio69148322025-06-19 23:16:12 +0400443 {node.data.type !== "sketch:latest" && (
444 <TabsContent value="dev">
445 <Dev node={node} disabled={disabled} />
446 </TabsContent>
447 )}
gio3d0bf032025-06-05 06:57:26 +0000448 </Tabs>
449 </>
450 );
451}
452
gio3d0bf032025-06-05 06:57:26 +0000453function Runtime({ node, disabled }: { node: ServiceNode; disabled?: boolean }): React.ReactNode {
454 const { id, data } = node;
455 const store = useStateStore();
giod0026612025-05-08 13:00:36 +0000456 const form = useForm<z.infer<typeof schema>>({
457 resolver: zodResolver(schema),
458 mode: "onChange",
459 defaultValues: {
460 name: data.label,
461 type: data.type,
462 },
463 });
giod0026612025-05-08 13:00:36 +0000464 useEffect(() => {
465 const sub = form.watch(
466 (
467 value: DeepPartial<z.infer<typeof schema>>,
468 { name, type }: { name?: keyof z.infer<typeof schema> | undefined; type?: EventType | undefined },
469 ) => {
giod0026612025-05-08 13:00:36 +0000470 if (type !== "change") {
471 return;
472 }
473 switch (name) {
474 case "name":
475 if (!value.name) {
476 break;
477 }
478 store.updateNodeData<"app">(id, {
479 label: value.name,
480 });
481 break;
482 case "type":
483 if (!value.type) {
484 break;
485 }
486 store.updateNodeData<"app">(id, {
487 type: value.type,
488 });
489 break;
490 }
491 },
492 );
493 return () => sub.unsubscribe();
494 }, [id, form, store]);
giod0026612025-05-08 13:00:36 +0000495 const [typeProps, setTypeProps] = useState({});
496 useEffect(() => {
497 if (data.activeField === "type") {
498 setTypeProps({
499 open: true,
500 onOpenChange: () => store.updateNodeData(id, { activeField: undefined }),
501 });
502 } else {
503 setTypeProps({});
504 }
505 }, [id, data, store, setTypeProps]);
gio3d0bf032025-06-05 06:57:26 +0000506 const setPreBuildCommands = useCallback(
507 (e: React.ChangeEvent<HTMLTextAreaElement>) => {
508 store.updateNodeData<"app">(id, {
509 preBuildCommands: e.currentTarget.value,
giod0026612025-05-08 13:00:36 +0000510 });
511 },
gio3d0bf032025-06-05 06:57:26 +0000512 [id, store],
giod0026612025-05-08 13:00:36 +0000513 );
gio69148322025-06-19 23:16:12 +0400514 const agentForm = useForm<z.infer<typeof agentSchema>>({
515 resolver: zodResolver(agentSchema),
516 mode: "onChange",
517 defaultValues: {
gio69ff7592025-07-03 06:27:21 +0000518 apiKey: data.model?.apiKey,
519 model: data.model?.name,
gio69148322025-06-19 23:16:12 +0400520 },
521 });
522 useEffect(() => {
gio69ff7592025-07-03 06:27:21 +0000523 const sub = agentForm.watch((value, { name }: { name?: keyof z.infer<typeof agentSchema> | undefined }) => {
524 switch (name) {
525 case "model":
526 agentForm.setValue("apiKey", "", { shouldDirty: true });
527 store.updateNodeData<"app">(id, {
528 model: {
529 name: value.model,
530 apiKey: undefined,
531 },
532 });
533 break;
534 case "apiKey":
535 store.updateNodeData<"app">(id, {
536 model: {
537 name: data.model?.name,
538 apiKey: value.apiKey,
539 },
540 });
541 break;
542 }
gio69148322025-06-19 23:16:12 +0400543 });
544 return () => sub.unsubscribe();
gio69ff7592025-07-03 06:27:21 +0000545 }, [id, agentForm, store, data]);
gio3d0bf032025-06-05 06:57:26 +0000546 return (
547 <>
548 <SourceRepo node={node} disabled={disabled} />
gio69148322025-06-19 23:16:12 +0400549 {node.data.type !== "sketch:latest" && (
550 <Form {...form}>
551 <form className="space-y-2">
552 <Label>Container Image</Label>
553 <FormField
554 control={form.control}
555 name="type"
556 render={({ field }) => (
557 <FormItem>
558 <Select
559 onValueChange={field.onChange}
560 value={field.value || ""}
561 {...typeProps}
562 disabled={disabled}
563 >
564 <FormControl>
565 <SelectTrigger>
566 <SelectValue />
567 </SelectTrigger>
568 </FormControl>
569 <SelectContent>
570 {ServiceTypes.filter((t) => t !== "sketch:latest").map((t) => (
571 <SelectItem key={t} value={t}>
572 {t}
573 </SelectItem>
574 ))}
575 </SelectContent>
576 </Select>
577 <FormMessage />
578 </FormItem>
579 )}
580 />
581 </form>
582 </Form>
583 )}
584 {node.data.type === "sketch:latest" && (
585 <Form {...agentForm}>
586 <form className="space-y-2">
gio69148322025-06-19 23:16:12 +0400587 <FormField
588 control={agentForm.control}
gio69ff7592025-07-03 06:27:21 +0000589 name="model"
590 render={({ field }) => (
591 <FormItem>
592 <FormLabel>AI Model</FormLabel>
593 <Select
594 onValueChange={field.onChange}
595 defaultValue={field.value}
596 disabled={disabled}
597 >
598 <FormControl>
599 <SelectTrigger>
600 <SelectValue placeholder="Select a model" />
601 </SelectTrigger>
602 </FormControl>
603 <SelectContent>
604 <SelectItem value="gemini">Gemini</SelectItem>
605 <SelectItem value="claude">Claude</SelectItem>
606 </SelectContent>
607 </Select>
608 <FormMessage />
609 </FormItem>
610 )}
611 />
612 <Label>API Key</Label>
613 <FormField
614 control={agentForm.control}
615 name="apiKey"
gio69148322025-06-19 23:16:12 +0400616 render={({ field }) => (
617 <FormItem>
gio3d0bf032025-06-05 06:57:26 +0000618 <FormControl>
gio69148322025-06-19 23:16:12 +0400619 <Input
620 type="password"
gio69ff7592025-07-03 06:27:21 +0000621 placeholder="Override AI Model API key"
gio69148322025-06-19 23:16:12 +0400622 {...field}
623 value={field.value || ""}
624 disabled={disabled}
625 />
gio3d0bf032025-06-05 06:57:26 +0000626 </FormControl>
gio69148322025-06-19 23:16:12 +0400627 <FormMessage />
628 </FormItem>
629 )}
630 />
631 </form>
632 </Form>
633 )}
634 {node.data.type !== "sketch:latest" && (
635 <>
636 <Label>Pre-Build Commands</Label>
637 <Textarea
638 placeholder="new line separated list of commands to run before running the service"
639 value={data.preBuildCommands}
640 onChange={setPreBuildCommands}
641 disabled={disabled}
gio3d0bf032025-06-05 06:57:26 +0000642 />
gio69148322025-06-19 23:16:12 +0400643 </>
644 )}
gio3d0bf032025-06-05 06:57:26 +0000645 </>
giod0026612025-05-08 13:00:36 +0000646 );
gio3d0bf032025-06-05 06:57:26 +0000647}
648
gio2e7d2172025-07-04 09:24:53 +0000649function Ports({
650 node,
651 disabled,
652 isOverview,
653}: {
654 node: ServiceNode;
655 disabled?: boolean;
656 isOverview?: boolean;
657}): React.ReactNode {
gio3d0bf032025-06-05 06:57:26 +0000658 const { id, data } = node;
659 const store = useStateStore();
gio9f3d4f52025-07-04 08:42:34 +0000660 const nodes = useNodes<AppNode>();
661 const [portIngresses, setPortIngresses] = useState<Record<string, string[]>>({});
gio2e7d2172025-07-04 09:24:53 +0000662 const [exposingPortId, setExposingPortId] = useState<string | null>(null);
gio9f3d4f52025-07-04 08:42:34 +0000663
664 const httpsGateways = useMemo(
665 () => nodes.filter((n): n is GatewayHttpsNode => n.type === "gateway-https"),
666 [nodes],
667 );
668
669 useEffect(() => {
670 if (!data.ports) {
671 setPortIngresses({});
672 return;
673 }
674 const newIngresses: Record<string, string[]> = {};
675 for (const port of data.ports) {
676 newIngresses[port.id] = [];
677 }
678 for (const gateway of httpsGateways) {
679 const https = gateway.data.https;
680 if (https && https.serviceId === id && https.portId && gateway.data.network && gateway.data.subdomain) {
681 const url = `https://${gateway.data.subdomain}.${gateway.data.network}`;
682 if (newIngresses[https.portId]) {
683 newIngresses[https.portId].push(url);
684 } else {
685 newIngresses[https.portId] = [url];
686 }
687 }
688 }
689 setPortIngresses(newIngresses);
690 console.log(newIngresses);
691 }, [id, data.ports, httpsGateways]);
692
gio3d0bf032025-06-05 06:57:26 +0000693 const [name, setName] = useState("");
694 const [value, setValue] = useState("");
695 const onSubmit = useCallback(() => {
696 const portId = uuidv4();
697 store.updateNodeData<"app">(id, {
698 ports: (data.ports || []).concat({
699 id: portId,
700 name: name.toUpperCase(),
701 value: Number(value),
702 }),
gio73ac16c2025-07-03 14:38:04 +0000703 envVars: (data.envVars || []).concat(
704 {
705 id: uuidv4(),
706 source: null,
707 portId,
708 name: `DODO_PORT_${name.toUpperCase()}`,
709 },
710 {
711 id: uuidv4(),
712 source: null,
713 portId,
714 name: `DODO_PORT_${name.toUpperCase()}`,
715 alias: name.toUpperCase(),
716 },
717 ),
gio3d0bf032025-06-05 06:57:26 +0000718 });
719 setName("");
720 setValue("");
721 }, [id, data, store, name, value, setName, setValue]);
giod0026612025-05-08 13:00:36 +0000722 const removePort = useCallback(
723 (portId: string) => {
724 // TODO(gio): this is ugly
725 const tcpRemoved = new Set<string>();
giod0026612025-05-08 13:00:36 +0000726 store.setEdges(
727 store.edges.filter((e) => {
728 if (e.source !== id || e.sourceHandle !== "ports") {
729 return true;
730 }
731 const tn = store.nodes.find((n) => n.id == e.target)!;
732 if (e.targetHandle === "https") {
733 const t = tn as GatewayHttpsNode;
734 if (t.data.https?.serviceId === id && t.data.https.portId === portId) {
735 return false;
736 }
737 }
738 if (e.targetHandle === "tcp") {
739 const t = tn as GatewayTCPNode;
740 if (tcpRemoved.has(t.id)) {
741 return true;
742 }
743 if (t.data.exposed.find((e) => e.serviceId === id && e.portId === portId)) {
744 tcpRemoved.add(t.id);
745 return false;
746 }
747 }
748 if (e.targetHandle === "env_var") {
749 if (
750 tn &&
751 (tn.data.envVars || []).find(
752 (ev) => ev.source === id && "portId" in ev && ev.portId === portId,
753 )
754 ) {
755 return false;
756 }
757 }
758 return true;
759 }),
760 );
761 store.nodes
762 .filter(
763 (n) =>
764 n.type === "gateway-https" &&
765 n.data.https &&
766 n.data.https.serviceId === id &&
767 n.data.https.portId === portId,
768 )
769 .forEach((n) => {
770 store.updateNodeData<"gateway-https">(n.id, {
771 https: undefined,
772 });
773 });
774 store.nodes
775 .filter((n) => n.type === "gateway-tcp")
776 .forEach((n) => {
777 const filtered = n.data.exposed.filter((e) => {
778 if (e.serviceId === id && e.portId === portId) {
779 return false;
780 } else {
781 return true;
782 }
783 });
784 if (filtered.length != n.data.exposed.length) {
785 store.updateNodeData<"gateway-tcp">(n.id, {
786 exposed: filtered,
787 });
788 }
789 });
790 store.nodes
791 .filter((n) => n.type === "app" && n.data.envVars)
792 .forEach((n) => {
793 store.updateNodeData<"app">(n.id, {
794 envVars: n.data.envVars.filter((ev) => {
795 if (ev.source === id && "portId" in ev && ev.portId === portId) {
796 return false;
797 }
798 return true;
799 }),
800 });
801 });
802 store.updateNodeData<"app">(id, {
803 ports: (data.ports || []).filter((p) => p.id !== portId),
804 envVars: (data.envVars || []).filter(
805 (ev) => !(ev.source === null && "portId" in ev && ev.portId === portId),
806 ),
807 });
808 },
809 [id, data, store],
810 );
gio3d0bf032025-06-05 06:57:26 +0000811 return (
812 <div className="flex flex-col gap-1">
813 <div className="grid grid-cols-[1fr_1fr_auto] gap-1">
814 {data &&
815 data.ports &&
816 data.ports.map((p) => (
gio2e7d2172025-07-04 09:24:53 +0000817 <div key={p.id} className="contents">
gio9f3d4f52025-07-04 08:42:34 +0000818 <div className="contents">
819 <div className="flex items-center px-3">{p.name.toUpperCase()}</div>
820 <div className="flex items-center px-3">{p.value}</div>
gio2e7d2172025-07-04 09:24:53 +0000821 <div className="flex items-center gap-1">
822 {isOverview && (
823 <Button
824 variant="outline"
825 onClick={() => setExposingPortId(p.id)}
826 disabled={disabled}
827 >
828 Expose
829 </Button>
830 )}
gio9f3d4f52025-07-04 08:42:34 +0000831 <Button
832 variant="destructive"
833 className="w-full"
834 onClick={() => removePort(p.id)}
835 disabled={disabled}
836 >
837 Remove
838 </Button>
839 </div>
gio3d0bf032025-06-05 06:57:26 +0000840 </div>
gio9f3d4f52025-07-04 08:42:34 +0000841 {portIngresses[p.id]?.length > 0 && (
842 <div key={p.id} className="col-span-full pl-6">
843 {portIngresses[p.id].map((url) => (
gio2e7d2172025-07-04 09:24:53 +0000844 <Gateway key={url} g={{ type: "https", address: url, name: p.name }} />
gio9f3d4f52025-07-04 08:42:34 +0000845 ))}
846 </div>
847 )}
gio2e7d2172025-07-04 09:24:53 +0000848 {exposingPortId === p.id && (
849 <Dialog open={true} onOpenChange={() => setExposingPortId(null)}>
850 <DialogContent>
851 <DialogHeader>
852 <DialogTitle>
853 Expose Port {p.name}:{p.value}
854 </DialogTitle>
855 </DialogHeader>
856 <ExposeForm
857 node={node}
858 port={p}
859 onDone={() => setExposingPortId(null)}
860 disabled={disabled}
861 />
862 </DialogContent>
863 </Dialog>
864 )}
865 </div>
gio3d0bf032025-06-05 06:57:26 +0000866 ))}
867 <div>
868 <Input
869 placeholder="name"
870 className="uppercase w-0 min-w-full"
871 disabled={disabled}
872 value={name}
873 onChange={(e) => setName(e.target.value)}
874 />
875 </div>
876 <div>
877 <Input
878 placeholder="0"
879 className="w-0 min-w-full"
880 disabled={disabled}
881 value={value}
882 onChange={(e) => setValue(e.target.value)}
883 />
884 </div>
885 <div>
886 <Button type="submit" className="w-full" disabled={disabled} onClick={onSubmit}>
887 Add
888 </Button>
889 </div>
890 </div>
891 </div>
892 );
893}
894
895function EnvVars({ node, disabled }: { node: ServiceNode; disabled?: boolean }): React.ReactNode {
896 const { id, data } = node;
897 const store = useStateStore();
gio1dacf1c2025-07-03 16:39:04 +0000898 const [name, setName] = useState("");
899 const [value, setValue] = useState("");
900
901 const addEnvVar = useCallback(() => {
902 if (!name.trim() || !value.trim()) return;
903 store.updateNodeData<"app">(id, {
904 envVars: (data.envVars || []).concat({
905 id: uuidv4(),
906 source: null,
907 name: name.toUpperCase(),
908 value: value,
909 }),
910 });
911 setName("");
912 setValue("");
913 }, [id, data, store, name, value]);
914
915 const removeEnvVar = useCallback(
916 (varId: string) => {
917 store.updateNodeData<"app">(id, {
918 envVars: (data.envVars || []).filter((v) => v.id !== varId),
919 });
920 },
921 [id, data, store],
922 );
923
924 const editValueEnvVar = useCallback(
925 (varId: string) => {
926 if (disabled) return;
927 store.updateNodeData<"app">(id, {
928 envVars: (data.envVars || []).map((v) => (v.id === varId ? { ...v, isEditting: true } : v)),
929 });
930 },
931 [id, data, store, disabled],
932 );
933
934 const saveValueEnvVar = useCallback(
935 (varId: string, newName: string, newValue: string) => {
936 store.updateNodeData<"app">(id, {
937 envVars: (data.envVars || []).map((v) => {
938 if (v.id === varId) {
939 return { ...v, name: newName.toUpperCase(), value: newValue, isEditting: false };
940 }
941 return v;
942 }),
943 });
944 },
945 [id, data, store],
946 );
947
gio3d0bf032025-06-05 06:57:26 +0000948 const editAlias = useCallback(
949 (e: BoundEnvVar) => {
950 return () => {
gioff9b5522025-07-03 13:50:30 +0000951 if (disabled) {
952 return;
953 }
gio3d0bf032025-06-05 06:57:26 +0000954 store.updateNodeData(id, {
955 ...data,
956 envVars: data.envVars!.map((o) => {
957 if (o.id !== e.id) {
958 return o;
959 } else
960 return {
961 ...o,
962 isEditting: true,
963 };
964 }),
965 });
966 };
967 },
gioff9b5522025-07-03 13:50:30 +0000968 [id, data, store, disabled],
gio3d0bf032025-06-05 06:57:26 +0000969 );
gio1dacf1c2025-07-03 16:39:04 +0000970
gio3d0bf032025-06-05 06:57:26 +0000971 const saveAlias = useCallback(
972 (e: BoundEnvVar, value: string, store: AppState) => {
973 store.updateNodeData(id, {
974 ...data,
975 envVars: data.envVars!.map((o) => {
976 if (o.id !== e.id) {
977 return o;
978 }
979 if (value) {
gio1dacf1c2025-07-03 16:39:04 +0000980 if ("name" in o && value.toUpperCase() === o.name.toUpperCase()) {
981 return {
982 ...o,
983 isEditting: false,
984 alias: undefined,
985 };
986 } else {
987 return {
988 ...o,
989 isEditting: false,
990 alias: value.toUpperCase(),
991 };
992 }
gio3d0bf032025-06-05 06:57:26 +0000993 }
994 if ("alias" in o) {
995 const { alias: _, ...rest } = o;
996 return {
997 ...rest,
998 isEditting: false,
999 };
1000 }
1001 return {
1002 ...o,
1003 isEditting: false,
1004 };
1005 }),
giod0026612025-05-08 13:00:36 +00001006 });
1007 },
gio3d0bf032025-06-05 06:57:26 +00001008 [id, data],
giod0026612025-05-08 13:00:36 +00001009 );
gio1dacf1c2025-07-03 16:39:04 +00001010
gio3d0bf032025-06-05 06:57:26 +00001011 const saveAliasOnEnter = useCallback(
1012 (e: BoundEnvVar) => {
1013 return (event: KeyboardEvent<HTMLInputElement>) => {
1014 if (event.key === "Enter") {
gio3d0bf032025-06-05 06:57:26 +00001015 saveAlias(e, event.currentTarget.value, store);
gio1dacf1c2025-07-03 16:39:04 +00001016 } else if (event.key === "Escape") {
1017 store.updateNodeData(id, {
1018 ...data,
1019 envVars: data.envVars!.map((o) => (o.id === e.id ? { ...o, isEditting: false } : o)),
1020 });
giod0026612025-05-08 13:00:36 +00001021 }
gio3d0bf032025-06-05 06:57:26 +00001022 };
1023 },
gio1dacf1c2025-07-03 16:39:04 +00001024 [store, saveAlias, id, data],
gio3d0bf032025-06-05 06:57:26 +00001025 );
gio1dacf1c2025-07-03 16:39:04 +00001026
gio3d0bf032025-06-05 06:57:26 +00001027 const saveAliasOnBlur = useCallback(
1028 (e: BoundEnvVar) => {
1029 return (event: FocusEvent<HTMLInputElement>) => {
1030 saveAlias(e, event.currentTarget.value, store);
1031 };
1032 },
1033 [store, saveAlias],
1034 );
gio1dacf1c2025-07-03 16:39:04 +00001035
gio3d0bf032025-06-05 06:57:26 +00001036 return (
gio1dacf1c2025-07-03 16:39:04 +00001037 <div className="flex flex-col gap-1">
1038 <div className="grid grid-cols-[auto_1fr_1fr_auto] gap-1">
1039 {data?.envVars?.map((v) => {
1040 if ("value" in v) {
1041 if (v.isEditting) {
1042 return (
1043 <div key={v.id} className="contents">
1044 <Input
1045 className="uppercase col-start-2"
1046 defaultValue={v.name}
1047 onKeyUp={(e) => {
1048 if (e.key === "Enter") {
1049 const nameInput = e.currentTarget;
1050 const valueInput = nameInput.parentElement?.querySelector(
1051 'input[placeholder="Value"]',
1052 ) as HTMLInputElement;
1053 if (valueInput) {
1054 saveValueEnvVar(v.id, nameInput.value, valueInput.value);
1055 }
1056 } else if (e.key === "Escape") {
1057 store.updateNodeData(id, {
1058 ...data,
1059 envVars: data.envVars!.map((o) =>
1060 o.id === v.id ? { ...o, isEditting: false } : o,
1061 ),
1062 });
1063 }
1064 }}
1065 autoFocus
1066 disabled={disabled}
1067 />
1068 <Input
1069 placeholder="Value"
1070 defaultValue={v.value}
1071 onKeyUp={(e) => {
1072 if (e.key === "Enter") {
1073 const valueInput = e.currentTarget;
1074 const nameInput = valueInput.parentElement?.querySelector(
1075 'input:not([placeholder="Value"])',
1076 ) as HTMLInputElement;
1077 if (nameInput) {
1078 saveValueEnvVar(v.id, nameInput.value, valueInput.value);
1079 }
1080 } else if (e.key === "Escape") {
1081 store.updateNodeData(id, {
1082 ...data,
1083 envVars: data.envVars!.map((o) =>
1084 o.id === v.id ? { ...o, isEditting: false } : o,
1085 ),
1086 });
1087 }
1088 }}
1089 disabled={disabled}
1090 />
1091 <Button
1092 variant="destructive"
1093 size="sm"
1094 onClick={() => removeEnvVar(v.id)}
1095 disabled={disabled}
1096 >
1097 Remove
1098 </Button>
1099 </div>
1100 );
1101 }
1102 return (
1103 <div
1104 key={v.id}
1105 className={`contents ${disabled ? "" : "cursor-text"}`}
1106 onClick={() => editValueEnvVar(v.id)}
1107 >
1108 <div>{!disabled && <Pencil className="w-4 h-4" />}</div>
1109 <div className={`${disabled ? "col-span-2" : ""} col-start-2`}>{v.name}</div>
1110 <div>{v.value}</div>
1111 <Button
1112 variant="destructive"
1113 size="sm"
1114 onClick={(e) => {
1115 e.stopPropagation();
1116 removeEnvVar(v.id);
1117 }}
1118 disabled={disabled}
1119 >
1120 Remove
1121 </Button>
1122 </div>
1123 );
1124 }
gio3d0bf032025-06-05 06:57:26 +00001125 if ("name" in v) {
1126 const value = "alias" in v ? v.alias : v.name;
1127 if (v.isEditting) {
1128 return (
gio1dacf1c2025-07-03 16:39:04 +00001129 <Input
1130 type="text"
1131 className="uppercase col-start-2 col-span-3"
1132 defaultValue={value}
1133 onKeyUp={saveAliasOnEnter(v)}
1134 onBlur={saveAliasOnBlur(v)}
1135 autoFocus={true}
1136 disabled={disabled}
1137 />
gio3d0bf032025-06-05 06:57:26 +00001138 );
1139 }
1140 return (
gio1dacf1c2025-07-03 16:39:04 +00001141 <div
1142 key={v.id}
1143 onClick={editAlias(v)}
1144 className={`contents ${disabled ? "" : "cursor-text"}`}
1145 >
1146 {!disabled && <Pencil className="w-4 h-4" />}
1147 <div className="col-start-2 col-span-3">
1148 <TooltipProvider>
1149 <Tooltip>
1150 <TooltipTrigger className="uppercase">{value}</TooltipTrigger>
1151 <TooltipContent>{v.name}</TooltipContent>
1152 </Tooltip>
1153 </TooltipProvider>
1154 </div>
1155 </div>
gio3d0bf032025-06-05 06:57:26 +00001156 );
1157 }
gio1dacf1c2025-07-03 16:39:04 +00001158 return null;
gio3d0bf032025-06-05 06:57:26 +00001159 })}
gio1dacf1c2025-07-03 16:39:04 +00001160 {!disabled && (
1161 <div className="contents">
1162 <Input
1163 placeholder="Name"
1164 className="uppercase col-start-2"
1165 value={name}
1166 onChange={(e) => setName(e.target.value)}
1167 disabled={disabled}
1168 />
1169 <Input
1170 placeholder="Value"
1171 value={value}
1172 onChange={(e) => setValue(e.target.value)}
1173 disabled={disabled}
1174 />
1175 <Button onClick={addEnvVar} disabled={disabled || !name.trim() || !value.trim()}>
1176 Add
1177 </Button>
1178 </div>
1179 )}
1180 </div>
1181 </div>
gio3d0bf032025-06-05 06:57:26 +00001182 );
1183}
1184
gio43e0aad2025-08-01 16:17:27 +04001185function usePrevious<T>(value: T) {
1186 const ref = useRef<T>();
1187 useEffect(() => {
1188 ref.current = value;
1189 }, [value]);
1190 return ref.current;
1191}
1192
1193function DevVM({ node, disabled }: { node: ServiceNode; disabled?: boolean }): React.ReactNode {
gio3d0bf032025-06-05 06:57:26 +00001194 const { id, data } = node;
gio43e0aad2025-08-01 16:17:27 +04001195 const { dev } = data;
1196 const prevDev = usePrevious(dev);
gio3d0bf032025-06-05 06:57:26 +00001197 const env = useEnv();
1198 const store = useStateStore();
gio48fde052025-05-14 09:48:08 +00001199 useEffect(() => {
gio43e0aad2025-08-01 16:17:27 +04001200 console.log("DDDEV", prevDev, dev);
1201 if (!dev && !prevDev) {
1202 return;
1203 }
1204 if (
1205 dev &&
1206 prevDev &&
1207 dev.enabled === prevDev.enabled &&
1208 "mode" in dev &&
1209 "mode" in prevDev &&
1210 dev.mode === prevDev.mode
1211 ) {
1212 return;
1213 }
1214 if (!dev?.enabled || dev.mode !== "VM") {
1215 if (prevDev?.enabled && prevDev.mode === "VM") {
1216 store.setNodes(
1217 store.nodes.filter((n) => n.id !== prevDev.codeServerNodeId && n.id !== prevDev.sshNodeId),
1218 );
1219 store.setEdges(
1220 store.edges.filter((e) => e.target !== prevDev.codeServerNodeId && e.target !== prevDev.sshNodeId),
1221 );
1222 if (dev?.enabled) {
gio48fde052025-05-14 09:48:08 +00001223 store.updateNodeData<"app">(id, {
1224 dev: {
gio43e0aad2025-08-01 16:17:27 +04001225 enabled: dev.enabled,
1226 mode: dev.mode,
gio48fde052025-05-14 09:48:08 +00001227 },
gio43e0aad2025-08-01 16:17:27 +04001228 ports: (data.ports || []).filter((p) => p.name !== "code-server" && p.name !== "ssh"),
gio48fde052025-05-14 09:48:08 +00001229 });
gio48fde052025-05-14 09:48:08 +00001230 } else {
gio48fde052025-05-14 09:48:08 +00001231 store.updateNodeData<"app">(id, {
1232 dev: {
1233 enabled: false,
gio48fde052025-05-14 09:48:08 +00001234 },
1235 ports: (data.ports || []).filter((p) => p.name !== "code-server" && p.name !== "ssh"),
1236 });
1237 }
1238 }
gio43e0aad2025-08-01 16:17:27 +04001239 } else {
1240 if (!prevDev?.enabled || prevDev.mode !== "VM") {
1241 const csGateway: Omit<GatewayHttpsNode, "position"> = {
1242 id: uuidv4(),
1243 type: "gateway-https",
1244 data: {
1245 readonly: true,
1246 https: {
1247 serviceId: id,
1248 portId: `${id}-code-server`,
1249 },
1250 network: dev?.expose?.network,
1251 subdomain: dev?.expose?.subdomain,
1252 label: "",
1253 envVars: [],
1254 ports: [],
1255 },
1256 };
1257 const sshGateway: Omit<GatewayTCPNode, "position"> = {
1258 id: uuidv4(),
1259 type: "gateway-tcp",
1260 data: {
1261 readonly: true,
1262 exposed: [
1263 {
1264 serviceId: id,
1265 portId: `${id}-ssh`,
1266 },
1267 ],
1268 network: dev?.expose?.network,
1269 subdomain: dev?.expose?.subdomain,
1270 label: "",
1271 envVars: [],
1272 ports: [],
1273 },
1274 };
1275 store.addNode(csGateway);
1276 store.addNode(sshGateway);
1277 store.updateNodeData<"app">(id, {
1278 dev: {
1279 enabled: true,
1280 mode: "VM",
1281 expose: dev?.expose,
1282 codeServerNodeId: csGateway.id,
1283 sshNodeId: sshGateway.id,
1284 },
1285 ports: (data.ports || []).concat(
1286 {
1287 id: `${id}-code-server`,
1288 name: "code-server",
1289 value: 9090,
1290 },
1291 {
1292 id: `${id}-ssh`,
1293 name: "ssh",
1294 value: 22,
1295 },
1296 ),
1297 });
1298 let edges = store.edges.concat([
1299 {
1300 id: uuidv4(),
1301 source: id,
1302 sourceHandle: "ports",
1303 target: csGateway.id,
1304 targetHandle: "https",
1305 },
1306 {
1307 id: uuidv4(),
1308 source: id,
1309 sourceHandle: "ports",
1310 target: sshGateway.id,
1311 targetHandle: "tcp",
1312 },
1313 ]);
1314 if (dev?.expose?.network !== undefined) {
1315 edges = edges.concat([
1316 {
1317 id: uuidv4(),
1318 source: csGateway.id,
1319 sourceHandle: "subdomain",
1320 target: dev.expose.network,
1321 targetHandle: "subdomain",
1322 },
1323 {
1324 id: uuidv4(),
1325 source: sshGateway.id,
1326 sourceHandle: "subdomain",
1327 target: dev.expose.network,
1328 targetHandle: "subdomain",
1329 },
1330 ]);
1331 }
1332 store.setEdges(edges);
1333 }
1334 }
1335 }, [id, data, dev, prevDev, store]);
gio48fde052025-05-14 09:48:08 +00001336 const exposeForm = useForm<z.infer<typeof exposeSchema>>({
1337 resolver: zodResolver(exposeSchema),
1338 mode: "onChange",
1339 defaultValues: {
gio43e0aad2025-08-01 16:17:27 +04001340 network: dev && "expose" in dev ? dev.expose?.network : undefined,
1341 subdomain: dev && "expose" in dev ? dev.expose?.subdomain : undefined,
gio48fde052025-05-14 09:48:08 +00001342 },
1343 });
1344 useEffect(() => {
1345 const sub = exposeForm.watch(
1346 (
1347 value: DeepPartial<z.infer<typeof exposeSchema>>,
1348 { name }: { name?: keyof z.infer<typeof exposeSchema> | undefined; type?: EventType | undefined },
1349 ) => {
1350 const { dev } = data;
gio43e0aad2025-08-01 16:17:27 +04001351 if (!dev?.enabled || dev.mode !== "VM") {
gio48fde052025-05-14 09:48:08 +00001352 return;
1353 }
1354 if (name === "network") {
1355 let edges = store.edges;
1356 if (dev.enabled && dev.expose?.network !== undefined) {
1357 edges = edges.filter((e) => {
1358 if (
1359 (e.source === dev.codeServerNodeId || e.source === dev.sshNodeId) &&
1360 e.sourceHandle === "subdomain" &&
1361 e.target === dev.expose?.network &&
1362 e.targetHandle === "subdomain"
1363 ) {
1364 return false;
1365 } else {
1366 return true;
1367 }
1368 });
1369 }
1370 if (value.network !== undefined) {
1371 edges = edges.concat(
1372 {
1373 id: uuidv4(),
1374 source: dev.codeServerNodeId,
1375 sourceHandle: "subdomain",
1376 target: value.network,
1377 targetHandle: "subdomain",
1378 },
1379 {
1380 id: uuidv4(),
1381 source: dev.sshNodeId,
1382 sourceHandle: "subdomain",
1383 target: value.network,
1384 targetHandle: "subdomain",
1385 },
1386 );
1387 }
1388 store.setEdges(edges);
1389 store.updateNodeData<"app">(id, {
1390 dev: {
1391 ...dev,
1392 expose: {
1393 network: value.network,
1394 subdomain: dev.expose?.subdomain,
1395 },
1396 },
1397 });
1398 store.updateNodeData<"gateway-https">(dev.codeServerNodeId, { network: value.network });
1399 store.updateNodeData<"gateway-tcp">(dev.sshNodeId, { network: value.network });
1400 } else if (name === "subdomain") {
1401 store.updateNodeData<"app">(id, {
1402 dev: {
1403 ...dev,
1404 expose: {
1405 network: dev.expose?.network,
1406 subdomain: value.subdomain,
1407 },
1408 },
1409 });
1410 store.updateNodeData<"gateway-https">(dev.codeServerNodeId, { subdomain: value.subdomain });
1411 store.updateNodeData<"gateway-tcp">(dev.sshNodeId, { subdomain: value.subdomain });
1412 }
1413 },
1414 );
1415 return () => sub.unsubscribe();
gio43e0aad2025-08-01 16:17:27 +04001416 }, [id, data, dev, prevDev, exposeForm, store]);
1417 if (!dev?.enabled || dev.mode !== "VM") {
1418 return null;
1419 }
giod0026612025-05-08 13:00:36 +00001420 return (
gio43e0aad2025-08-01 16:17:27 +04001421 <div>
gio29050d62025-05-16 04:49:26 +00001422 {data.dev && data.dev.enabled && (
1423 <Form {...exposeForm}>
1424 <form className="space-y-2">
gio3d0bf032025-06-05 06:57:26 +00001425 <Label>Network</Label>
gio29050d62025-05-16 04:49:26 +00001426 <FormField
1427 control={exposeForm.control}
1428 name="network"
1429 render={({ field }) => (
1430 <FormItem>
gio3ec94242025-05-16 12:46:57 +00001431 <Select
1432 onValueChange={field.onChange}
gio3d0bf032025-06-05 06:57:26 +00001433 value={field.value || ""}
gio3ec94242025-05-16 12:46:57 +00001434 disabled={disabled}
1435 >
gio29050d62025-05-16 04:49:26 +00001436 <FormControl>
1437 <SelectTrigger>
gio3d0bf032025-06-05 06:57:26 +00001438 <SelectValue />
gio29050d62025-05-16 04:49:26 +00001439 </SelectTrigger>
1440 </FormControl>
1441 <SelectContent>
1442 {env.networks.map((n) => (
1443 <SelectItem
1444 key={n.name}
1445 value={n.domain}
1446 >{`${n.name} - ${n.domain}`}</SelectItem>
1447 ))}
1448 </SelectContent>
1449 </Select>
1450 <FormMessage />
1451 </FormItem>
1452 )}
1453 />
gio3d0bf032025-06-05 06:57:26 +00001454 <Label>Subdomain</Label>
gio29050d62025-05-16 04:49:26 +00001455 <FormField
1456 control={exposeForm.control}
1457 name="subdomain"
1458 render={({ field }) => (
1459 <FormItem>
gio48fde052025-05-14 09:48:08 +00001460 <FormControl>
gio3d0bf032025-06-05 06:57:26 +00001461 <Input {...field} disabled={disabled} />
gio48fde052025-05-14 09:48:08 +00001462 </FormControl>
gio29050d62025-05-16 04:49:26 +00001463 <FormMessage />
1464 </FormItem>
1465 )}
1466 />
1467 </form>
1468 </Form>
1469 )}
gio43e0aad2025-08-01 16:17:27 +04001470 </div>
1471 );
1472}
1473
1474function DevProxy({ node, disabled }: { node: ServiceNode; disabled?: boolean }): React.ReactNode {
1475 const { id, data } = node;
1476 const store = useStateStore();
1477 const { toast } = useToast();
1478 const [machines, setMachines] = useState<Machines>([]);
1479 const [loading, setLoading] = useState(false);
1480 const [error, setError] = useState<string | null>(null);
1481
1482 const fetchMachines = useCallback(async () => {
1483 setLoading(true);
1484 setError(null);
1485 try {
1486 const response = await fetch("/api/machines", {
1487 method: "GET",
1488 headers: {
1489 "Content-Type": "application/json",
1490 },
1491 });
1492
1493 if (!response.ok) {
1494 throw new Error(`Failed to fetch machines: ${response.statusText}`);
1495 }
1496
1497 const machinesData = MachinesSchema.safeParse(await response.json());
1498 if (machinesData.success) {
gioa4bf4712025-08-03 02:21:28 +00001499 setMachines(
1500 machinesData.data
1501 .filter((m) => !m.name.startsWith("proxy-dodo-app-"))
1502 .filter(
1503 (m) => m.last_seen && m.last_seen.seconds * 1000 >= Date.now() - 7 * 24 * 60 * 60 * 1000,
1504 ),
1505 );
gio43e0aad2025-08-01 16:17:27 +04001506 } else {
1507 throw new Error("Invalid machines data");
1508 }
1509 } catch (err) {
1510 const errorMessage = err instanceof Error ? err.message : "Failed to fetch machines";
1511 setError(errorMessage);
1512 toast({
1513 variant: "destructive",
1514 title: "Error",
1515 description: errorMessage,
1516 });
1517 } finally {
1518 setLoading(false);
1519 }
1520 }, [toast]);
1521
1522 useEffect(() => {
1523 if (data.dev?.enabled && "mode" in data.dev && data.dev.mode === "PROXY") {
1524 fetchMachines();
1525 }
1526 }, [data.dev, fetchMachines]);
1527
1528 const proxyForm = useForm<z.infer<typeof proxySchema>>({
1529 resolver: zodResolver(proxySchema),
1530 mode: "onChange",
1531 defaultValues: {
1532 address: data.dev && "address" in data.dev ? data.dev.address : undefined,
1533 },
1534 });
1535
1536 useEffect(() => {
1537 const sub = proxyForm.watch((value, { name }) => {
1538 if (name === "address" && value.address) {
1539 store.updateNodeData<"app">(id, {
1540 dev: {
1541 enabled: true,
1542 mode: "PROXY",
1543 address: value.address,
1544 },
1545 });
1546 }
1547 });
1548 return () => sub.unsubscribe();
1549 }, [id, proxyForm, store]);
1550
1551 if (!data.dev?.enabled || data.dev.mode !== "PROXY") {
1552 return null;
1553 }
1554 return (
1555 <div className="space-y-2">
1556 <Form {...proxyForm}>
1557 <form className="space-y-2">
1558 <FormField
1559 control={proxyForm.control}
1560 name="address"
1561 render={({ field }) => (
1562 <FormItem>
1563 <Select
1564 onValueChange={field.onChange}
1565 value={field.value || ""}
1566 disabled={disabled || loading}
1567 >
1568 <FormControl>
1569 <SelectTrigger>
1570 {loading ? (
1571 <div className="flex items-center gap-2">
1572 <LoaderCircle className="h-4 w-4 animate-spin" />
1573 <span>Loading machines...</span>
1574 </div>
1575 ) : (
1576 <SelectValue placeholder="Select a machine" />
1577 )}
1578 </SelectTrigger>
1579 </FormControl>
1580 <SelectContent>
1581 {loading ? (
1582 <div className="flex items-center justify-center p-4">
1583 <LoaderCircle className="h-4 w-4 animate-spin" />
1584 <span className="ml-2">Loading...</span>
1585 </div>
1586 ) : error ? (
1587 <div className="flex flex-col items-center justify-center p-4 text-destructive">
1588 <span className="text-sm">Failed to load machines</span>
1589 <Button
1590 variant="ghost"
1591 size="sm"
1592 className="mt-2"
1593 onClick={fetchMachines}
1594 >
1595 Retry
1596 </Button>
1597 </div>
1598 ) : machines.length === 0 ? (
1599 <div className="flex items-center justify-center p-4 text-muted-foreground">
1600 <span className="text-sm">No machines available</span>
1601 </div>
1602 ) : (
1603 machines.map((machine: Machine) => (
1604 <SelectItem key={machine.name} value={machine.name}>
1605 {machine.name}
1606 </SelectItem>
1607 ))
1608 )}
1609 </SelectContent>
1610 </Select>
1611 <FormMessage />
1612 </FormItem>
1613 )}
1614 />
1615 </form>
1616 </Form>
1617 </div>
1618 );
1619}
1620
1621function Dev({ node, disabled }: { node: ServiceNode; disabled?: boolean }): React.ReactNode {
1622 const { id, data } = node;
1623 const store = useStateStore();
1624 const devForm = useForm<z.infer<typeof devSchema>>({
1625 resolver: zodResolver(devSchema),
1626 mode: "onChange",
1627 defaultValues: {
1628 enabled: data.dev ? data.dev.enabled : false,
1629 mode: data.dev?.enabled ? data.dev.mode : undefined,
1630 },
1631 });
1632 useEffect(() => {
1633 const sub = devForm.watch((value, { name }) => {
1634 console.log("DDDEVV", name, value, data.dev);
1635 if (name === "enabled") {
1636 if (value.enabled) {
1637 if (data.dev?.enabled && data.dev.mode === "VM") {
1638 return;
1639 }
1640 store.updateNodeData<"app">(id, {
1641 dev: {
1642 enabled: true,
1643 mode: "VM",
1644 },
1645 });
1646 devForm.setValue("mode", "VM");
1647 } else {
1648 store.updateNodeData<"app">(id, {
1649 dev: {
1650 enabled: false,
1651 },
1652 });
1653 }
1654 } else if (name === "mode") {
1655 if (data.dev?.enabled && data.dev.mode === value.mode) {
1656 return;
1657 }
1658 store.updateNodeData<"app">(id, {
1659 dev: {
1660 enabled: true,
1661 mode: value.mode,
1662 },
1663 });
1664 }
1665 });
1666 return () => sub.unsubscribe();
1667 }, [id, data, devForm, store]);
1668 return (
1669 <>
1670 <Form {...devForm}>
1671 <form className="space-y-2">
1672 <FormField
1673 control={devForm.control}
1674 name="enabled"
1675 render={({ field }) => (
1676 <FormItem>
1677 <div className="flex flex-row gap-1 items-center">
1678 <Switch
1679 id="devEnabled"
1680 onCheckedChange={field.onChange}
1681 checked={field.value}
1682 disabled={disabled}
1683 />
1684 <Label htmlFor="devEnabled">Development Mode</Label>
1685 </div>
1686 <FormMessage />
1687 </FormItem>
1688 )}
1689 />
1690 {data.dev?.enabled && (
1691 <FormField
1692 control={devForm.control}
1693 name="mode"
1694 render={({ field }) => (
1695 <FormItem>
1696 <div className="flex flex-row gap-1 items-center">
1697 <RadioGroup
1698 onValueChange={field.onChange}
1699 value={field.value}
1700 disabled={disabled}
1701 >
1702 <div className="flex items-center space-x-2">
1703 <RadioGroupItem value="VM" id="vm" />
1704 <Label htmlFor="vm">Create a VM</Label>
1705 </div>
1706 <div className="flex items-center space-x-2">
1707 <RadioGroupItem value="PROXY" id="proxy" />
1708 <Label htmlFor="proxy">Proxy to existing machine</Label>
1709 </div>
1710 </RadioGroup>
1711 </div>
1712 </FormItem>
1713 )}
1714 />
1715 )}
1716 </form>
1717 </Form>
1718 <DevVM node={node} disabled={disabled} />
1719 <DevProxy node={node} disabled={disabled} />
giod0026612025-05-08 13:00:36 +00001720 </>
1721 );
1722}
gio3d0bf032025-06-05 06:57:26 +00001723
1724function SourceRepo({ node, disabled }: { node: ServiceNode; disabled?: boolean }): React.ReactNode {
1725 const { id, data } = node;
1726 const store = useStateStore();
1727 const nodes = useNodes<AppNode>();
1728 const repo = useMemo(() => {
1729 return nodes
1730 .filter((n): n is GithubNode => n.type === "github")
1731 .find((n) => n.id === data.repository?.repoNodeId);
1732 }, [nodes, data.repository?.repoNodeId]);
1733 const repos = useGithubRepositories();
1734 const sourceForm = useForm<z.infer<typeof sourceSchema>>({
1735 resolver: zodResolver(sourceSchema),
1736 mode: "onChange",
1737 defaultValues: {
1738 id: data?.repository?.id?.toString(),
1739 branch: data.repository && "branch" in data.repository ? data.repository.branch : undefined,
1740 rootDir: data.repository && "rootDir" in data.repository ? data.repository.rootDir : undefined,
1741 },
1742 });
1743 useEffect(() => {
1744 const sub = sourceForm.watch(
1745 (
1746 value: DeepPartial<z.infer<typeof sourceSchema>>,
1747 { name }: { name?: keyof z.infer<typeof sourceSchema> | undefined; type?: EventType | undefined },
1748 ) => {
1749 if (name === "id") {
1750 const newRepoId = value.id ? parseInt(value.id, 10) : undefined;
1751 if (!newRepoId) return;
1752
1753 const oldGithubNodeId = data.repository?.repoNodeId;
1754 const selectedRepo = repos.find((r) => r.id === newRepoId);
1755
1756 if (!selectedRepo) return;
1757
1758 // If a node for the selected repo already exists, connect to it.
1759 const existingNodeForSelectedRepo = nodes
1760 .filter((n): n is GithubNode => n.type === "github")
1761 .find((n) => n.data.repository?.id === selectedRepo.id);
1762
1763 if (existingNodeForSelectedRepo) {
1764 let { nodes, edges } = store;
1765 if (oldGithubNodeId) {
1766 edges = edges.filter(
1767 (e) =>
1768 !(
1769 e.target === id &&
1770 e.source === oldGithubNodeId &&
1771 e.targetHandle === "repository"
1772 ),
1773 );
1774 }
1775 edges = edges.concat({
1776 id: uuidv4(),
1777 source: existingNodeForSelectedRepo.id,
1778 sourceHandle: "repository",
1779 target: id,
1780 targetHandle: "repository",
1781 });
1782 nodes = nodes.map((n) => {
1783 if (n.id !== id) {
1784 return n;
1785 } else {
1786 const sn = n as ServiceNode;
1787 return {
1788 ...sn,
1789 data: {
1790 ...sn.data,
1791 repository: {
1792 ...sn.data.repository,
1793 id: newRepoId,
1794 repoNodeId: existingNodeForSelectedRepo.id,
1795 },
1796 },
1797 };
1798 }
1799 });
1800 if (oldGithubNodeId && oldGithubNodeId !== existingNodeForSelectedRepo.id) {
1801 const isOldNodeStillUsed = edges.some(
1802 (e) => e.source === oldGithubNodeId && e.sourceHandle === "repository",
1803 );
1804 if (!isOldNodeStillUsed) {
1805 nodes = nodes.filter((n) => n.id !== oldGithubNodeId);
1806 }
1807 }
1808 store.setNodes(nodes);
1809 store.setEdges(edges);
1810 return;
1811 }
1812
1813 // No node for selected repo, decide whether to update old node or create a new one.
1814 if (oldGithubNodeId) {
1815 const isOldNodeShared =
1816 store.edges.filter(
1817 (e) =>
1818 e.source === oldGithubNodeId && e.target !== id && e.sourceHandle === "repository",
1819 ).length > 0;
1820
1821 if (!isOldNodeShared) {
1822 // Update old node
1823 store.updateNodeData<"github">(oldGithubNodeId, {
1824 repository: {
1825 id: selectedRepo.id,
1826 sshURL: selectedRepo.ssh_url,
1827 fullName: selectedRepo.full_name,
1828 },
1829 label: selectedRepo.full_name,
1830 });
1831 store.updateNodeData<"app">(id, {
1832 repository: {
1833 ...data.repository,
1834 id: newRepoId,
1835 },
1836 });
1837 } else {
1838 // Create new node because old one is shared
1839 const newGithubNodeId = uuidv4();
1840 store.addNode({
1841 id: newGithubNodeId,
1842 type: "github",
1843 data: {
1844 repository: {
1845 id: selectedRepo.id,
1846 sshURL: selectedRepo.ssh_url,
1847 fullName: selectedRepo.full_name,
1848 },
1849 label: selectedRepo.full_name,
1850 envVars: [],
1851 ports: [],
1852 },
1853 });
1854
1855 let edges = store.edges;
1856 // remove old edge
1857 edges = edges.filter(
1858 (e) =>
1859 !(
1860 e.target === id &&
1861 e.source === oldGithubNodeId &&
1862 e.targetHandle === "repository"
1863 ),
1864 );
1865 // add new edge
1866 edges = edges.concat({
1867 id: uuidv4(),
1868 source: newGithubNodeId,
1869 sourceHandle: "repository",
1870 target: id,
1871 targetHandle: "repository",
1872 });
1873 store.setEdges(edges);
1874 store.updateNodeData<"app">(id, {
1875 repository: {
1876 ...data.repository,
1877 id: newRepoId,
1878 repoNodeId: newGithubNodeId,
1879 },
1880 });
1881 }
1882 } else {
1883 // No old github node, so create a new one
1884 const newGithubNodeId = uuidv4();
1885 store.addNode({
1886 id: newGithubNodeId,
1887 type: "github",
1888 data: {
1889 repository: {
1890 id: selectedRepo.id,
1891 sshURL: selectedRepo.ssh_url,
1892 fullName: selectedRepo.full_name,
1893 },
1894 label: selectedRepo.full_name,
1895 envVars: [],
1896 ports: [],
1897 },
1898 });
1899 store.setEdges(
1900 store.edges.concat({
1901 id: uuidv4(),
1902 source: newGithubNodeId,
1903 sourceHandle: "repository",
1904 target: id,
1905 targetHandle: "repository",
1906 }),
1907 );
1908 store.updateNodeData<"app">(id, {
1909 repository: {
1910 ...data.repository,
1911 id: newRepoId,
1912 repoNodeId: newGithubNodeId,
1913 },
1914 });
1915 }
1916 } else if (name === "branch") {
1917 store.updateNodeData<"app">(id, {
1918 repository: {
1919 ...data?.repository,
1920 branch: value.branch,
1921 },
1922 });
1923 } else if (name === "rootDir") {
1924 store.updateNodeData<"app">(id, {
1925 repository: {
1926 ...data?.repository,
1927 rootDir: value.rootDir,
1928 },
1929 });
1930 }
1931 },
1932 );
1933 return () => sub.unsubscribe();
1934 }, [id, data, sourceForm, store, nodes, repos]);
1935 const [isExpanded, setIsExpanded] = useState(false);
1936 // useEffect(() => {
1937 // if (data.repository === undefined) {
1938 // setIsExpanded(true);
1939 // }
1940 // }, [data.repository, setIsExpanded]);
1941 console.log(data.repository, isExpanded, repo);
1942 return (
1943 <Accordion type="single" collapsible>
1944 <AccordionItem value="repository" className="border-none">
1945 <AccordionTrigger onClick={() => setIsExpanded(!isExpanded)}>
1946 Source {!isExpanded && repo !== undefined && repo.data.repository?.fullName}
1947 </AccordionTrigger>
1948 <AccordionContent className="px-1">
1949 <Form {...sourceForm}>
1950 <form className="space-y-2">
1951 <Label>Repository</Label>
1952 <FormField
1953 control={sourceForm.control}
1954 name="id"
1955 render={({ field }) => (
1956 <FormItem>
1957 <Select onValueChange={field.onChange} value={field.value} disabled={disabled}>
1958 <FormControl>
1959 <SelectTrigger>
1960 <SelectValue />
1961 </SelectTrigger>
1962 </FormControl>
1963 <SelectContent>
1964 {repos.map((r) => (
1965 <SelectItem
1966 key={r.id}
1967 value={r.id.toString()}
1968 >{`${r.full_name}`}</SelectItem>
1969 ))}
1970 </SelectContent>
1971 </Select>
1972 <FormMessage />
1973 </FormItem>
1974 )}
1975 />
1976 <Label>Branch</Label>
1977 <FormField
1978 control={sourceForm.control}
1979 name="branch"
1980 render={({ field }) => (
1981 <FormItem>
1982 <FormControl>
1983 <Input
1984 placeholder="master"
1985 className="lowercase"
1986 {...field}
1987 disabled={disabled}
1988 />
1989 </FormControl>
1990 <FormMessage />
1991 </FormItem>
1992 )}
1993 />
1994 <Label>Root Directory</Label>
1995 <FormField
1996 control={sourceForm.control}
1997 name="rootDir"
1998 render={({ field }) => (
1999 <FormItem>
2000 <FormControl>
2001 <Input placeholder="/" {...field} disabled={disabled} />
2002 </FormControl>
2003 <FormMessage />
2004 </FormItem>
2005 )}
2006 />
2007 </form>
2008 </Form>
2009 </AccordionContent>
2010 </AccordionItem>
2011 </Accordion>
2012 );
2013}