blob: 317b7e05879fc987d42cf3c18ffecd2ad9ce9f83 [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) {
1499 setMachines(machinesData.data);
1500 } else {
1501 throw new Error("Invalid machines data");
1502 }
1503 } catch (err) {
1504 const errorMessage = err instanceof Error ? err.message : "Failed to fetch machines";
1505 setError(errorMessage);
1506 toast({
1507 variant: "destructive",
1508 title: "Error",
1509 description: errorMessage,
1510 });
1511 } finally {
1512 setLoading(false);
1513 }
1514 }, [toast]);
1515
1516 useEffect(() => {
1517 if (data.dev?.enabled && "mode" in data.dev && data.dev.mode === "PROXY") {
1518 fetchMachines();
1519 }
1520 }, [data.dev, fetchMachines]);
1521
1522 const proxyForm = useForm<z.infer<typeof proxySchema>>({
1523 resolver: zodResolver(proxySchema),
1524 mode: "onChange",
1525 defaultValues: {
1526 address: data.dev && "address" in data.dev ? data.dev.address : undefined,
1527 },
1528 });
1529
1530 useEffect(() => {
1531 const sub = proxyForm.watch((value, { name }) => {
1532 if (name === "address" && value.address) {
1533 store.updateNodeData<"app">(id, {
1534 dev: {
1535 enabled: true,
1536 mode: "PROXY",
1537 address: value.address,
1538 },
1539 });
1540 }
1541 });
1542 return () => sub.unsubscribe();
1543 }, [id, proxyForm, store]);
1544
1545 if (!data.dev?.enabled || data.dev.mode !== "PROXY") {
1546 return null;
1547 }
1548 return (
1549 <div className="space-y-2">
1550 <Form {...proxyForm}>
1551 <form className="space-y-2">
1552 <FormField
1553 control={proxyForm.control}
1554 name="address"
1555 render={({ field }) => (
1556 <FormItem>
1557 <Select
1558 onValueChange={field.onChange}
1559 value={field.value || ""}
1560 disabled={disabled || loading}
1561 >
1562 <FormControl>
1563 <SelectTrigger>
1564 {loading ? (
1565 <div className="flex items-center gap-2">
1566 <LoaderCircle className="h-4 w-4 animate-spin" />
1567 <span>Loading machines...</span>
1568 </div>
1569 ) : (
1570 <SelectValue placeholder="Select a machine" />
1571 )}
1572 </SelectTrigger>
1573 </FormControl>
1574 <SelectContent>
1575 {loading ? (
1576 <div className="flex items-center justify-center p-4">
1577 <LoaderCircle className="h-4 w-4 animate-spin" />
1578 <span className="ml-2">Loading...</span>
1579 </div>
1580 ) : error ? (
1581 <div className="flex flex-col items-center justify-center p-4 text-destructive">
1582 <span className="text-sm">Failed to load machines</span>
1583 <Button
1584 variant="ghost"
1585 size="sm"
1586 className="mt-2"
1587 onClick={fetchMachines}
1588 >
1589 Retry
1590 </Button>
1591 </div>
1592 ) : machines.length === 0 ? (
1593 <div className="flex items-center justify-center p-4 text-muted-foreground">
1594 <span className="text-sm">No machines available</span>
1595 </div>
1596 ) : (
1597 machines.map((machine: Machine) => (
1598 <SelectItem key={machine.name} value={machine.name}>
1599 {machine.name}
1600 </SelectItem>
1601 ))
1602 )}
1603 </SelectContent>
1604 </Select>
1605 <FormMessage />
1606 </FormItem>
1607 )}
1608 />
1609 </form>
1610 </Form>
1611 </div>
1612 );
1613}
1614
1615function Dev({ node, disabled }: { node: ServiceNode; disabled?: boolean }): React.ReactNode {
1616 const { id, data } = node;
1617 const store = useStateStore();
1618 const devForm = useForm<z.infer<typeof devSchema>>({
1619 resolver: zodResolver(devSchema),
1620 mode: "onChange",
1621 defaultValues: {
1622 enabled: data.dev ? data.dev.enabled : false,
1623 mode: data.dev?.enabled ? data.dev.mode : undefined,
1624 },
1625 });
1626 useEffect(() => {
1627 const sub = devForm.watch((value, { name }) => {
1628 console.log("DDDEVV", name, value, data.dev);
1629 if (name === "enabled") {
1630 if (value.enabled) {
1631 if (data.dev?.enabled && data.dev.mode === "VM") {
1632 return;
1633 }
1634 store.updateNodeData<"app">(id, {
1635 dev: {
1636 enabled: true,
1637 mode: "VM",
1638 },
1639 });
1640 devForm.setValue("mode", "VM");
1641 } else {
1642 store.updateNodeData<"app">(id, {
1643 dev: {
1644 enabled: false,
1645 },
1646 });
1647 }
1648 } else if (name === "mode") {
1649 if (data.dev?.enabled && data.dev.mode === value.mode) {
1650 return;
1651 }
1652 store.updateNodeData<"app">(id, {
1653 dev: {
1654 enabled: true,
1655 mode: value.mode,
1656 },
1657 });
1658 }
1659 });
1660 return () => sub.unsubscribe();
1661 }, [id, data, devForm, store]);
1662 return (
1663 <>
1664 <Form {...devForm}>
1665 <form className="space-y-2">
1666 <FormField
1667 control={devForm.control}
1668 name="enabled"
1669 render={({ field }) => (
1670 <FormItem>
1671 <div className="flex flex-row gap-1 items-center">
1672 <Switch
1673 id="devEnabled"
1674 onCheckedChange={field.onChange}
1675 checked={field.value}
1676 disabled={disabled}
1677 />
1678 <Label htmlFor="devEnabled">Development Mode</Label>
1679 </div>
1680 <FormMessage />
1681 </FormItem>
1682 )}
1683 />
1684 {data.dev?.enabled && (
1685 <FormField
1686 control={devForm.control}
1687 name="mode"
1688 render={({ field }) => (
1689 <FormItem>
1690 <div className="flex flex-row gap-1 items-center">
1691 <RadioGroup
1692 onValueChange={field.onChange}
1693 value={field.value}
1694 disabled={disabled}
1695 >
1696 <div className="flex items-center space-x-2">
1697 <RadioGroupItem value="VM" id="vm" />
1698 <Label htmlFor="vm">Create a VM</Label>
1699 </div>
1700 <div className="flex items-center space-x-2">
1701 <RadioGroupItem value="PROXY" id="proxy" />
1702 <Label htmlFor="proxy">Proxy to existing machine</Label>
1703 </div>
1704 </RadioGroup>
1705 </div>
1706 </FormItem>
1707 )}
1708 />
1709 )}
1710 </form>
1711 </Form>
1712 <DevVM node={node} disabled={disabled} />
1713 <DevProxy node={node} disabled={disabled} />
giod0026612025-05-08 13:00:36 +00001714 </>
1715 );
1716}
gio3d0bf032025-06-05 06:57:26 +00001717
1718function SourceRepo({ node, disabled }: { node: ServiceNode; disabled?: boolean }): React.ReactNode {
1719 const { id, data } = node;
1720 const store = useStateStore();
1721 const nodes = useNodes<AppNode>();
1722 const repo = useMemo(() => {
1723 return nodes
1724 .filter((n): n is GithubNode => n.type === "github")
1725 .find((n) => n.id === data.repository?.repoNodeId);
1726 }, [nodes, data.repository?.repoNodeId]);
1727 const repos = useGithubRepositories();
1728 const sourceForm = useForm<z.infer<typeof sourceSchema>>({
1729 resolver: zodResolver(sourceSchema),
1730 mode: "onChange",
1731 defaultValues: {
1732 id: data?.repository?.id?.toString(),
1733 branch: data.repository && "branch" in data.repository ? data.repository.branch : undefined,
1734 rootDir: data.repository && "rootDir" in data.repository ? data.repository.rootDir : undefined,
1735 },
1736 });
1737 useEffect(() => {
1738 const sub = sourceForm.watch(
1739 (
1740 value: DeepPartial<z.infer<typeof sourceSchema>>,
1741 { name }: { name?: keyof z.infer<typeof sourceSchema> | undefined; type?: EventType | undefined },
1742 ) => {
1743 if (name === "id") {
1744 const newRepoId = value.id ? parseInt(value.id, 10) : undefined;
1745 if (!newRepoId) return;
1746
1747 const oldGithubNodeId = data.repository?.repoNodeId;
1748 const selectedRepo = repos.find((r) => r.id === newRepoId);
1749
1750 if (!selectedRepo) return;
1751
1752 // If a node for the selected repo already exists, connect to it.
1753 const existingNodeForSelectedRepo = nodes
1754 .filter((n): n is GithubNode => n.type === "github")
1755 .find((n) => n.data.repository?.id === selectedRepo.id);
1756
1757 if (existingNodeForSelectedRepo) {
1758 let { nodes, edges } = store;
1759 if (oldGithubNodeId) {
1760 edges = edges.filter(
1761 (e) =>
1762 !(
1763 e.target === id &&
1764 e.source === oldGithubNodeId &&
1765 e.targetHandle === "repository"
1766 ),
1767 );
1768 }
1769 edges = edges.concat({
1770 id: uuidv4(),
1771 source: existingNodeForSelectedRepo.id,
1772 sourceHandle: "repository",
1773 target: id,
1774 targetHandle: "repository",
1775 });
1776 nodes = nodes.map((n) => {
1777 if (n.id !== id) {
1778 return n;
1779 } else {
1780 const sn = n as ServiceNode;
1781 return {
1782 ...sn,
1783 data: {
1784 ...sn.data,
1785 repository: {
1786 ...sn.data.repository,
1787 id: newRepoId,
1788 repoNodeId: existingNodeForSelectedRepo.id,
1789 },
1790 },
1791 };
1792 }
1793 });
1794 if (oldGithubNodeId && oldGithubNodeId !== existingNodeForSelectedRepo.id) {
1795 const isOldNodeStillUsed = edges.some(
1796 (e) => e.source === oldGithubNodeId && e.sourceHandle === "repository",
1797 );
1798 if (!isOldNodeStillUsed) {
1799 nodes = nodes.filter((n) => n.id !== oldGithubNodeId);
1800 }
1801 }
1802 store.setNodes(nodes);
1803 store.setEdges(edges);
1804 return;
1805 }
1806
1807 // No node for selected repo, decide whether to update old node or create a new one.
1808 if (oldGithubNodeId) {
1809 const isOldNodeShared =
1810 store.edges.filter(
1811 (e) =>
1812 e.source === oldGithubNodeId && e.target !== id && e.sourceHandle === "repository",
1813 ).length > 0;
1814
1815 if (!isOldNodeShared) {
1816 // Update old node
1817 store.updateNodeData<"github">(oldGithubNodeId, {
1818 repository: {
1819 id: selectedRepo.id,
1820 sshURL: selectedRepo.ssh_url,
1821 fullName: selectedRepo.full_name,
1822 },
1823 label: selectedRepo.full_name,
1824 });
1825 store.updateNodeData<"app">(id, {
1826 repository: {
1827 ...data.repository,
1828 id: newRepoId,
1829 },
1830 });
1831 } else {
1832 // Create new node because old one is shared
1833 const newGithubNodeId = uuidv4();
1834 store.addNode({
1835 id: newGithubNodeId,
1836 type: "github",
1837 data: {
1838 repository: {
1839 id: selectedRepo.id,
1840 sshURL: selectedRepo.ssh_url,
1841 fullName: selectedRepo.full_name,
1842 },
1843 label: selectedRepo.full_name,
1844 envVars: [],
1845 ports: [],
1846 },
1847 });
1848
1849 let edges = store.edges;
1850 // remove old edge
1851 edges = edges.filter(
1852 (e) =>
1853 !(
1854 e.target === id &&
1855 e.source === oldGithubNodeId &&
1856 e.targetHandle === "repository"
1857 ),
1858 );
1859 // add new edge
1860 edges = edges.concat({
1861 id: uuidv4(),
1862 source: newGithubNodeId,
1863 sourceHandle: "repository",
1864 target: id,
1865 targetHandle: "repository",
1866 });
1867 store.setEdges(edges);
1868 store.updateNodeData<"app">(id, {
1869 repository: {
1870 ...data.repository,
1871 id: newRepoId,
1872 repoNodeId: newGithubNodeId,
1873 },
1874 });
1875 }
1876 } else {
1877 // No old github node, so create a new one
1878 const newGithubNodeId = uuidv4();
1879 store.addNode({
1880 id: newGithubNodeId,
1881 type: "github",
1882 data: {
1883 repository: {
1884 id: selectedRepo.id,
1885 sshURL: selectedRepo.ssh_url,
1886 fullName: selectedRepo.full_name,
1887 },
1888 label: selectedRepo.full_name,
1889 envVars: [],
1890 ports: [],
1891 },
1892 });
1893 store.setEdges(
1894 store.edges.concat({
1895 id: uuidv4(),
1896 source: newGithubNodeId,
1897 sourceHandle: "repository",
1898 target: id,
1899 targetHandle: "repository",
1900 }),
1901 );
1902 store.updateNodeData<"app">(id, {
1903 repository: {
1904 ...data.repository,
1905 id: newRepoId,
1906 repoNodeId: newGithubNodeId,
1907 },
1908 });
1909 }
1910 } else if (name === "branch") {
1911 store.updateNodeData<"app">(id, {
1912 repository: {
1913 ...data?.repository,
1914 branch: value.branch,
1915 },
1916 });
1917 } else if (name === "rootDir") {
1918 store.updateNodeData<"app">(id, {
1919 repository: {
1920 ...data?.repository,
1921 rootDir: value.rootDir,
1922 },
1923 });
1924 }
1925 },
1926 );
1927 return () => sub.unsubscribe();
1928 }, [id, data, sourceForm, store, nodes, repos]);
1929 const [isExpanded, setIsExpanded] = useState(false);
1930 // useEffect(() => {
1931 // if (data.repository === undefined) {
1932 // setIsExpanded(true);
1933 // }
1934 // }, [data.repository, setIsExpanded]);
1935 console.log(data.repository, isExpanded, repo);
1936 return (
1937 <Accordion type="single" collapsible>
1938 <AccordionItem value="repository" className="border-none">
1939 <AccordionTrigger onClick={() => setIsExpanded(!isExpanded)}>
1940 Source {!isExpanded && repo !== undefined && repo.data.repository?.fullName}
1941 </AccordionTrigger>
1942 <AccordionContent className="px-1">
1943 <Form {...sourceForm}>
1944 <form className="space-y-2">
1945 <Label>Repository</Label>
1946 <FormField
1947 control={sourceForm.control}
1948 name="id"
1949 render={({ field }) => (
1950 <FormItem>
1951 <Select onValueChange={field.onChange} value={field.value} disabled={disabled}>
1952 <FormControl>
1953 <SelectTrigger>
1954 <SelectValue />
1955 </SelectTrigger>
1956 </FormControl>
1957 <SelectContent>
1958 {repos.map((r) => (
1959 <SelectItem
1960 key={r.id}
1961 value={r.id.toString()}
1962 >{`${r.full_name}`}</SelectItem>
1963 ))}
1964 </SelectContent>
1965 </Select>
1966 <FormMessage />
1967 </FormItem>
1968 )}
1969 />
1970 <Label>Branch</Label>
1971 <FormField
1972 control={sourceForm.control}
1973 name="branch"
1974 render={({ field }) => (
1975 <FormItem>
1976 <FormControl>
1977 <Input
1978 placeholder="master"
1979 className="lowercase"
1980 {...field}
1981 disabled={disabled}
1982 />
1983 </FormControl>
1984 <FormMessage />
1985 </FormItem>
1986 )}
1987 />
1988 <Label>Root Directory</Label>
1989 <FormField
1990 control={sourceForm.control}
1991 name="rootDir"
1992 render={({ field }) => (
1993 <FormItem>
1994 <FormControl>
1995 <Input placeholder="/" {...field} disabled={disabled} />
1996 </FormControl>
1997 <FormMessage />
1998 </FormItem>
1999 )}
2000 />
2001 </form>
2002 </Form>
2003 </AccordionContent>
2004 </AccordionItem>
2005 </Accordion>
2006 );
2007}