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