blob: 355e41bcca78af3d4cb05967db6243ebc276fd83 [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 }),
gio73ac16c2025-07-03 14:38:04 +0000401 envVars: (data.envVars || []).concat(
402 {
403 id: uuidv4(),
404 source: null,
405 portId,
406 name: `DODO_PORT_${name.toUpperCase()}`,
407 },
408 {
409 id: uuidv4(),
410 source: null,
411 portId,
412 name: `DODO_PORT_${name.toUpperCase()}`,
413 alias: name.toUpperCase(),
414 },
415 ),
gio3d0bf032025-06-05 06:57:26 +0000416 });
417 setName("");
418 setValue("");
419 }, [id, data, store, name, value, setName, setValue]);
giod0026612025-05-08 13:00:36 +0000420 const removePort = useCallback(
421 (portId: string) => {
422 // TODO(gio): this is ugly
423 const tcpRemoved = new Set<string>();
giod0026612025-05-08 13:00:36 +0000424 store.setEdges(
425 store.edges.filter((e) => {
426 if (e.source !== id || e.sourceHandle !== "ports") {
427 return true;
428 }
429 const tn = store.nodes.find((n) => n.id == e.target)!;
430 if (e.targetHandle === "https") {
431 const t = tn as GatewayHttpsNode;
432 if (t.data.https?.serviceId === id && t.data.https.portId === portId) {
433 return false;
434 }
435 }
436 if (e.targetHandle === "tcp") {
437 const t = tn as GatewayTCPNode;
438 if (tcpRemoved.has(t.id)) {
439 return true;
440 }
441 if (t.data.exposed.find((e) => e.serviceId === id && e.portId === portId)) {
442 tcpRemoved.add(t.id);
443 return false;
444 }
445 }
446 if (e.targetHandle === "env_var") {
447 if (
448 tn &&
449 (tn.data.envVars || []).find(
450 (ev) => ev.source === id && "portId" in ev && ev.portId === portId,
451 )
452 ) {
453 return false;
454 }
455 }
456 return true;
457 }),
458 );
459 store.nodes
460 .filter(
461 (n) =>
462 n.type === "gateway-https" &&
463 n.data.https &&
464 n.data.https.serviceId === id &&
465 n.data.https.portId === portId,
466 )
467 .forEach((n) => {
468 store.updateNodeData<"gateway-https">(n.id, {
469 https: undefined,
470 });
471 });
472 store.nodes
473 .filter((n) => n.type === "gateway-tcp")
474 .forEach((n) => {
475 const filtered = n.data.exposed.filter((e) => {
476 if (e.serviceId === id && e.portId === portId) {
477 return false;
478 } else {
479 return true;
480 }
481 });
482 if (filtered.length != n.data.exposed.length) {
483 store.updateNodeData<"gateway-tcp">(n.id, {
484 exposed: filtered,
485 });
486 }
487 });
488 store.nodes
489 .filter((n) => n.type === "app" && n.data.envVars)
490 .forEach((n) => {
491 store.updateNodeData<"app">(n.id, {
492 envVars: n.data.envVars.filter((ev) => {
493 if (ev.source === id && "portId" in ev && ev.portId === portId) {
494 return false;
495 }
496 return true;
497 }),
498 });
499 });
500 store.updateNodeData<"app">(id, {
501 ports: (data.ports || []).filter((p) => p.id !== portId),
502 envVars: (data.envVars || []).filter(
503 (ev) => !(ev.source === null && "portId" in ev && ev.portId === portId),
504 ),
505 });
506 },
507 [id, data, store],
508 );
gio3d0bf032025-06-05 06:57:26 +0000509 return (
510 <div className="flex flex-col gap-1">
511 <div className="grid grid-cols-[1fr_1fr_auto] gap-1">
512 {data &&
513 data.ports &&
514 data.ports.map((p) => (
515 <>
516 <div className="flex items-center px-3">{p.name.toUpperCase()}</div>
517 <div className="flex items-center px-3">{p.value}</div>
518 <div className="flex items-center">
519 <Button
520 variant="destructive"
521 className="w-full"
522 onClick={() => removePort(p.id)}
523 disabled={disabled}
524 >
525 Remove
526 </Button>
527 </div>
528 </>
529 ))}
530 <div>
531 <Input
532 placeholder="name"
533 className="uppercase w-0 min-w-full"
534 disabled={disabled}
535 value={name}
536 onChange={(e) => setName(e.target.value)}
537 />
538 </div>
539 <div>
540 <Input
541 placeholder="0"
542 className="w-0 min-w-full"
543 disabled={disabled}
544 value={value}
545 onChange={(e) => setValue(e.target.value)}
546 />
547 </div>
548 <div>
549 <Button type="submit" className="w-full" disabled={disabled} onClick={onSubmit}>
550 Add
551 </Button>
552 </div>
553 </div>
554 </div>
555 );
556}
557
558function EnvVars({ node, disabled }: { node: ServiceNode; disabled?: boolean }): React.ReactNode {
559 const { id, data } = node;
560 const store = useStateStore();
gio1dacf1c2025-07-03 16:39:04 +0000561 const [name, setName] = useState("");
562 const [value, setValue] = useState("");
563
564 const addEnvVar = useCallback(() => {
565 if (!name.trim() || !value.trim()) return;
566 store.updateNodeData<"app">(id, {
567 envVars: (data.envVars || []).concat({
568 id: uuidv4(),
569 source: null,
570 name: name.toUpperCase(),
571 value: value,
572 }),
573 });
574 setName("");
575 setValue("");
576 }, [id, data, store, name, value]);
577
578 const removeEnvVar = useCallback(
579 (varId: string) => {
580 store.updateNodeData<"app">(id, {
581 envVars: (data.envVars || []).filter((v) => v.id !== varId),
582 });
583 },
584 [id, data, store],
585 );
586
587 const editValueEnvVar = useCallback(
588 (varId: string) => {
589 if (disabled) return;
590 store.updateNodeData<"app">(id, {
591 envVars: (data.envVars || []).map((v) => (v.id === varId ? { ...v, isEditting: true } : v)),
592 });
593 },
594 [id, data, store, disabled],
595 );
596
597 const saveValueEnvVar = useCallback(
598 (varId: string, newName: string, newValue: string) => {
599 store.updateNodeData<"app">(id, {
600 envVars: (data.envVars || []).map((v) => {
601 if (v.id === varId) {
602 return { ...v, name: newName.toUpperCase(), value: newValue, isEditting: false };
603 }
604 return v;
605 }),
606 });
607 },
608 [id, data, store],
609 );
610
gio3d0bf032025-06-05 06:57:26 +0000611 const editAlias = useCallback(
612 (e: BoundEnvVar) => {
613 return () => {
gioff9b5522025-07-03 13:50:30 +0000614 if (disabled) {
615 return;
616 }
gio3d0bf032025-06-05 06:57:26 +0000617 store.updateNodeData(id, {
618 ...data,
619 envVars: data.envVars!.map((o) => {
620 if (o.id !== e.id) {
621 return o;
622 } else
623 return {
624 ...o,
625 isEditting: true,
626 };
627 }),
628 });
629 };
630 },
gioff9b5522025-07-03 13:50:30 +0000631 [id, data, store, disabled],
gio3d0bf032025-06-05 06:57:26 +0000632 );
gio1dacf1c2025-07-03 16:39:04 +0000633
gio3d0bf032025-06-05 06:57:26 +0000634 const saveAlias = useCallback(
635 (e: BoundEnvVar, value: string, store: AppState) => {
636 store.updateNodeData(id, {
637 ...data,
638 envVars: data.envVars!.map((o) => {
639 if (o.id !== e.id) {
640 return o;
641 }
642 if (value) {
gio1dacf1c2025-07-03 16:39:04 +0000643 if ("name" in o && value.toUpperCase() === o.name.toUpperCase()) {
644 return {
645 ...o,
646 isEditting: false,
647 alias: undefined,
648 };
649 } else {
650 return {
651 ...o,
652 isEditting: false,
653 alias: value.toUpperCase(),
654 };
655 }
gio3d0bf032025-06-05 06:57:26 +0000656 }
657 if ("alias" in o) {
658 const { alias: _, ...rest } = o;
659 return {
660 ...rest,
661 isEditting: false,
662 };
663 }
664 return {
665 ...o,
666 isEditting: false,
667 };
668 }),
giod0026612025-05-08 13:00:36 +0000669 });
670 },
gio3d0bf032025-06-05 06:57:26 +0000671 [id, data],
giod0026612025-05-08 13:00:36 +0000672 );
gio1dacf1c2025-07-03 16:39:04 +0000673
gio3d0bf032025-06-05 06:57:26 +0000674 const saveAliasOnEnter = useCallback(
675 (e: BoundEnvVar) => {
676 return (event: KeyboardEvent<HTMLInputElement>) => {
677 if (event.key === "Enter") {
gio3d0bf032025-06-05 06:57:26 +0000678 saveAlias(e, event.currentTarget.value, store);
gio1dacf1c2025-07-03 16:39:04 +0000679 } else if (event.key === "Escape") {
680 store.updateNodeData(id, {
681 ...data,
682 envVars: data.envVars!.map((o) => (o.id === e.id ? { ...o, isEditting: false } : o)),
683 });
giod0026612025-05-08 13:00:36 +0000684 }
gio3d0bf032025-06-05 06:57:26 +0000685 };
686 },
gio1dacf1c2025-07-03 16:39:04 +0000687 [store, saveAlias, id, data],
gio3d0bf032025-06-05 06:57:26 +0000688 );
gio1dacf1c2025-07-03 16:39:04 +0000689
gio3d0bf032025-06-05 06:57:26 +0000690 const saveAliasOnBlur = useCallback(
691 (e: BoundEnvVar) => {
692 return (event: FocusEvent<HTMLInputElement>) => {
693 saveAlias(e, event.currentTarget.value, store);
694 };
695 },
696 [store, saveAlias],
697 );
gio1dacf1c2025-07-03 16:39:04 +0000698
gio3d0bf032025-06-05 06:57:26 +0000699 return (
gio1dacf1c2025-07-03 16:39:04 +0000700 <div className="flex flex-col gap-1">
701 <div className="grid grid-cols-[auto_1fr_1fr_auto] gap-1">
702 {data?.envVars?.map((v) => {
703 if ("value" in v) {
704 if (v.isEditting) {
705 return (
706 <div key={v.id} className="contents">
707 <Input
708 className="uppercase col-start-2"
709 defaultValue={v.name}
710 onKeyUp={(e) => {
711 if (e.key === "Enter") {
712 const nameInput = e.currentTarget;
713 const valueInput = nameInput.parentElement?.querySelector(
714 'input[placeholder="Value"]',
715 ) as HTMLInputElement;
716 if (valueInput) {
717 saveValueEnvVar(v.id, nameInput.value, valueInput.value);
718 }
719 } else if (e.key === "Escape") {
720 store.updateNodeData(id, {
721 ...data,
722 envVars: data.envVars!.map((o) =>
723 o.id === v.id ? { ...o, isEditting: false } : o,
724 ),
725 });
726 }
727 }}
728 autoFocus
729 disabled={disabled}
730 />
731 <Input
732 placeholder="Value"
733 defaultValue={v.value}
734 onKeyUp={(e) => {
735 if (e.key === "Enter") {
736 const valueInput = e.currentTarget;
737 const nameInput = valueInput.parentElement?.querySelector(
738 'input:not([placeholder="Value"])',
739 ) as HTMLInputElement;
740 if (nameInput) {
741 saveValueEnvVar(v.id, nameInput.value, valueInput.value);
742 }
743 } else if (e.key === "Escape") {
744 store.updateNodeData(id, {
745 ...data,
746 envVars: data.envVars!.map((o) =>
747 o.id === v.id ? { ...o, isEditting: false } : o,
748 ),
749 });
750 }
751 }}
752 disabled={disabled}
753 />
754 <Button
755 variant="destructive"
756 size="sm"
757 onClick={() => removeEnvVar(v.id)}
758 disabled={disabled}
759 >
760 Remove
761 </Button>
762 </div>
763 );
764 }
765 return (
766 <div
767 key={v.id}
768 className={`contents ${disabled ? "" : "cursor-text"}`}
769 onClick={() => editValueEnvVar(v.id)}
770 >
771 <div>{!disabled && <Pencil className="w-4 h-4" />}</div>
772 <div className={`${disabled ? "col-span-2" : ""} col-start-2`}>{v.name}</div>
773 <div>{v.value}</div>
774 <Button
775 variant="destructive"
776 size="sm"
777 onClick={(e) => {
778 e.stopPropagation();
779 removeEnvVar(v.id);
780 }}
781 disabled={disabled}
782 >
783 Remove
784 </Button>
785 </div>
786 );
787 }
gio3d0bf032025-06-05 06:57:26 +0000788 if ("name" in v) {
789 const value = "alias" in v ? v.alias : v.name;
790 if (v.isEditting) {
791 return (
gio1dacf1c2025-07-03 16:39:04 +0000792 <Input
793 type="text"
794 className="uppercase col-start-2 col-span-3"
795 defaultValue={value}
796 onKeyUp={saveAliasOnEnter(v)}
797 onBlur={saveAliasOnBlur(v)}
798 autoFocus={true}
799 disabled={disabled}
800 />
gio3d0bf032025-06-05 06:57:26 +0000801 );
802 }
803 return (
gio1dacf1c2025-07-03 16:39:04 +0000804 <div
805 key={v.id}
806 onClick={editAlias(v)}
807 className={`contents ${disabled ? "" : "cursor-text"}`}
808 >
809 {!disabled && <Pencil className="w-4 h-4" />}
810 <div className="col-start-2 col-span-3">
811 <TooltipProvider>
812 <Tooltip>
813 <TooltipTrigger className="uppercase">{value}</TooltipTrigger>
814 <TooltipContent>{v.name}</TooltipContent>
815 </Tooltip>
816 </TooltipProvider>
817 </div>
818 </div>
gio3d0bf032025-06-05 06:57:26 +0000819 );
820 }
gio1dacf1c2025-07-03 16:39:04 +0000821 return null;
gio3d0bf032025-06-05 06:57:26 +0000822 })}
gio1dacf1c2025-07-03 16:39:04 +0000823 {!disabled && (
824 <div className="contents">
825 <Input
826 placeholder="Name"
827 className="uppercase col-start-2"
828 value={name}
829 onChange={(e) => setName(e.target.value)}
830 disabled={disabled}
831 />
832 <Input
833 placeholder="Value"
834 value={value}
835 onChange={(e) => setValue(e.target.value)}
836 disabled={disabled}
837 />
838 <Button onClick={addEnvVar} disabled={disabled || !name.trim() || !value.trim()}>
839 Add
840 </Button>
841 </div>
842 )}
843 </div>
844 </div>
gio3d0bf032025-06-05 06:57:26 +0000845 );
846}
847
848function Dev({ node, disabled }: { node: ServiceNode; disabled?: boolean }): React.ReactNode {
849 const { id, data } = node;
850 const env = useEnv();
851 const store = useStateStore();
gio48fde052025-05-14 09:48:08 +0000852 const devForm = useForm<z.infer<typeof devSchema>>({
853 resolver: zodResolver(devSchema),
854 mode: "onChange",
855 defaultValues: {
856 enabled: data.dev ? data.dev.enabled : false,
857 },
858 });
859 useEffect(() => {
860 const sub = devForm.watch((value, { name }) => {
861 if (name === "enabled") {
862 if (value.enabled) {
863 const csGateway: Omit<GatewayHttpsNode, "position"> = {
864 id: uuidv4(),
865 type: "gateway-https",
866 data: {
867 readonly: true,
868 https: {
869 serviceId: id,
870 portId: `${id}-code-server`,
871 },
872 network: data.dev?.expose?.network,
873 subdomain: data.dev?.expose?.subdomain,
874 label: "",
875 envVars: [],
876 ports: [],
877 },
878 };
879 const sshGateway: Omit<GatewayTCPNode, "position"> = {
880 id: uuidv4(),
881 type: "gateway-tcp",
882 data: {
883 readonly: true,
884 exposed: [
885 {
886 serviceId: id,
887 portId: `${id}-ssh`,
888 },
889 ],
890 network: data.dev?.expose?.network,
891 subdomain: data.dev?.expose?.subdomain,
892 label: "",
893 envVars: [],
894 ports: [],
895 },
896 };
897 store.addNode(csGateway);
898 store.addNode(sshGateway);
899 store.updateNodeData<"app">(id, {
900 dev: {
901 enabled: true,
902 expose: data.dev?.expose,
903 codeServerNodeId: csGateway.id,
904 sshNodeId: sshGateway.id,
905 },
906 ports: (data.ports || []).concat(
907 {
908 id: `${id}-code-server`,
909 name: "code-server",
910 value: 9090,
911 },
912 {
913 id: `${id}-ssh`,
914 name: "ssh",
915 value: 22,
916 },
917 ),
918 });
919 let edges = store.edges.concat([
920 {
921 id: uuidv4(),
922 source: id,
923 sourceHandle: "ports",
924 target: csGateway.id,
925 targetHandle: "https",
926 },
927 {
928 id: uuidv4(),
929 source: id,
930 sourceHandle: "ports",
931 target: sshGateway.id,
932 targetHandle: "tcp",
933 },
934 ]);
935 if (data.dev?.expose?.network !== undefined) {
936 edges = edges.concat([
937 {
938 id: uuidv4(),
939 source: csGateway.id,
940 sourceHandle: "subdomain",
941 target: data.dev.expose.network,
942 targetHandle: "subdomain",
943 },
944 {
945 id: uuidv4(),
946 source: sshGateway.id,
947 sourceHandle: "subdomain",
948 target: data.dev.expose.network,
949 targetHandle: "subdomain",
950 },
951 ]);
952 }
953 store.setEdges(edges);
954 } else {
955 const { dev } = data;
956 if (dev?.enabled) {
957 store.setNodes(
958 store.nodes.filter((n) => n.id !== dev.codeServerNodeId && n.id !== dev.sshNodeId),
959 );
960 store.setEdges(
961 store.edges.filter((e) => e.target !== dev.codeServerNodeId && e.target !== dev.sshNodeId),
962 );
963 }
964 store.updateNodeData<"app">(id, {
965 dev: {
966 enabled: false,
967 expose: dev?.expose,
968 },
969 ports: (data.ports || []).filter((p) => p.name !== "code-server" && p.name !== "ssh"),
970 });
971 }
972 }
973 });
974 return () => sub.unsubscribe();
975 }, [id, data, devForm, store]);
976 const exposeForm = useForm<z.infer<typeof exposeSchema>>({
977 resolver: zodResolver(exposeSchema),
978 mode: "onChange",
979 defaultValues: {
980 network: data.dev?.expose?.network,
981 subdomain: data.dev?.expose?.subdomain,
982 },
983 });
984 useEffect(() => {
985 const sub = exposeForm.watch(
986 (
987 value: DeepPartial<z.infer<typeof exposeSchema>>,
988 { name }: { name?: keyof z.infer<typeof exposeSchema> | undefined; type?: EventType | undefined },
989 ) => {
990 const { dev } = data;
991 if (!dev?.enabled) {
992 return;
993 }
994 if (name === "network") {
995 let edges = store.edges;
996 if (dev.enabled && dev.expose?.network !== undefined) {
997 edges = edges.filter((e) => {
998 if (
999 (e.source === dev.codeServerNodeId || e.source === dev.sshNodeId) &&
1000 e.sourceHandle === "subdomain" &&
1001 e.target === dev.expose?.network &&
1002 e.targetHandle === "subdomain"
1003 ) {
1004 return false;
1005 } else {
1006 return true;
1007 }
1008 });
1009 }
1010 if (value.network !== undefined) {
1011 edges = edges.concat(
1012 {
1013 id: uuidv4(),
1014 source: dev.codeServerNodeId,
1015 sourceHandle: "subdomain",
1016 target: value.network,
1017 targetHandle: "subdomain",
1018 },
1019 {
1020 id: uuidv4(),
1021 source: dev.sshNodeId,
1022 sourceHandle: "subdomain",
1023 target: value.network,
1024 targetHandle: "subdomain",
1025 },
1026 );
1027 }
1028 store.setEdges(edges);
1029 store.updateNodeData<"app">(id, {
1030 dev: {
1031 ...dev,
1032 expose: {
1033 network: value.network,
1034 subdomain: dev.expose?.subdomain,
1035 },
1036 },
1037 });
1038 store.updateNodeData<"gateway-https">(dev.codeServerNodeId, { network: value.network });
1039 store.updateNodeData<"gateway-tcp">(dev.sshNodeId, { network: value.network });
1040 } else if (name === "subdomain") {
1041 store.updateNodeData<"app">(id, {
1042 dev: {
1043 ...dev,
1044 expose: {
1045 network: dev.expose?.network,
1046 subdomain: value.subdomain,
1047 },
1048 },
1049 });
1050 store.updateNodeData<"gateway-https">(dev.codeServerNodeId, { subdomain: value.subdomain });
1051 store.updateNodeData<"gateway-tcp">(dev.sshNodeId, { subdomain: value.subdomain });
1052 }
1053 },
1054 );
1055 return () => sub.unsubscribe();
1056 }, [id, data, exposeForm, store]);
giod0026612025-05-08 13:00:36 +00001057 return (
1058 <>
gio48fde052025-05-14 09:48:08 +00001059 <Form {...devForm}>
1060 <form className="space-y-2">
1061 <FormField
1062 control={devForm.control}
1063 name="enabled"
1064 render={({ field }) => (
1065 <FormItem>
1066 <div className="flex flex-row gap-1 items-center">
gio3d0bf032025-06-05 06:57:26 +00001067 <Switch
gio3ec94242025-05-16 12:46:57 +00001068 id="devEnabled"
1069 onCheckedChange={field.onChange}
1070 checked={field.value}
1071 disabled={disabled}
1072 />
gio3d0bf032025-06-05 06:57:26 +00001073 <Label htmlFor="devEnabled">Dev VM</Label>
gio48fde052025-05-14 09:48:08 +00001074 </div>
1075 <FormMessage />
1076 </FormItem>
1077 )}
1078 />
1079 </form>
1080 </Form>
gio29050d62025-05-16 04:49:26 +00001081 {data.dev && data.dev.enabled && (
1082 <Form {...exposeForm}>
1083 <form className="space-y-2">
gio3d0bf032025-06-05 06:57:26 +00001084 <Label>Network</Label>
gio29050d62025-05-16 04:49:26 +00001085 <FormField
1086 control={exposeForm.control}
1087 name="network"
1088 render={({ field }) => (
1089 <FormItem>
gio3ec94242025-05-16 12:46:57 +00001090 <Select
1091 onValueChange={field.onChange}
gio3d0bf032025-06-05 06:57:26 +00001092 value={field.value || ""}
gio3ec94242025-05-16 12:46:57 +00001093 disabled={disabled}
1094 >
gio29050d62025-05-16 04:49:26 +00001095 <FormControl>
1096 <SelectTrigger>
gio3d0bf032025-06-05 06:57:26 +00001097 <SelectValue />
gio29050d62025-05-16 04:49:26 +00001098 </SelectTrigger>
1099 </FormControl>
1100 <SelectContent>
1101 {env.networks.map((n) => (
1102 <SelectItem
1103 key={n.name}
1104 value={n.domain}
1105 >{`${n.name} - ${n.domain}`}</SelectItem>
1106 ))}
1107 </SelectContent>
1108 </Select>
1109 <FormMessage />
1110 </FormItem>
1111 )}
1112 />
gio3d0bf032025-06-05 06:57:26 +00001113 <Label>Subdomain</Label>
gio29050d62025-05-16 04:49:26 +00001114 <FormField
1115 control={exposeForm.control}
1116 name="subdomain"
1117 render={({ field }) => (
1118 <FormItem>
gio48fde052025-05-14 09:48:08 +00001119 <FormControl>
gio3d0bf032025-06-05 06:57:26 +00001120 <Input {...field} disabled={disabled} />
gio48fde052025-05-14 09:48:08 +00001121 </FormControl>
gio29050d62025-05-16 04:49:26 +00001122 <FormMessage />
1123 </FormItem>
1124 )}
1125 />
1126 </form>
1127 </Form>
1128 )}
giod0026612025-05-08 13:00:36 +00001129 </>
1130 );
1131}
gio3d0bf032025-06-05 06:57:26 +00001132
1133function SourceRepo({ node, disabled }: { node: ServiceNode; disabled?: boolean }): React.ReactNode {
1134 const { id, data } = node;
1135 const store = useStateStore();
1136 const nodes = useNodes<AppNode>();
1137 const repo = useMemo(() => {
1138 return nodes
1139 .filter((n): n is GithubNode => n.type === "github")
1140 .find((n) => n.id === data.repository?.repoNodeId);
1141 }, [nodes, data.repository?.repoNodeId]);
1142 const repos = useGithubRepositories();
1143 const sourceForm = useForm<z.infer<typeof sourceSchema>>({
1144 resolver: zodResolver(sourceSchema),
1145 mode: "onChange",
1146 defaultValues: {
1147 id: data?.repository?.id?.toString(),
1148 branch: data.repository && "branch" in data.repository ? data.repository.branch : undefined,
1149 rootDir: data.repository && "rootDir" in data.repository ? data.repository.rootDir : undefined,
1150 },
1151 });
1152 useEffect(() => {
1153 const sub = sourceForm.watch(
1154 (
1155 value: DeepPartial<z.infer<typeof sourceSchema>>,
1156 { name }: { name?: keyof z.infer<typeof sourceSchema> | undefined; type?: EventType | undefined },
1157 ) => {
1158 if (name === "id") {
1159 const newRepoId = value.id ? parseInt(value.id, 10) : undefined;
1160 if (!newRepoId) return;
1161
1162 const oldGithubNodeId = data.repository?.repoNodeId;
1163 const selectedRepo = repos.find((r) => r.id === newRepoId);
1164
1165 if (!selectedRepo) return;
1166
1167 // If a node for the selected repo already exists, connect to it.
1168 const existingNodeForSelectedRepo = nodes
1169 .filter((n): n is GithubNode => n.type === "github")
1170 .find((n) => n.data.repository?.id === selectedRepo.id);
1171
1172 if (existingNodeForSelectedRepo) {
1173 let { nodes, edges } = store;
1174 if (oldGithubNodeId) {
1175 edges = edges.filter(
1176 (e) =>
1177 !(
1178 e.target === id &&
1179 e.source === oldGithubNodeId &&
1180 e.targetHandle === "repository"
1181 ),
1182 );
1183 }
1184 edges = edges.concat({
1185 id: uuidv4(),
1186 source: existingNodeForSelectedRepo.id,
1187 sourceHandle: "repository",
1188 target: id,
1189 targetHandle: "repository",
1190 });
1191 nodes = nodes.map((n) => {
1192 if (n.id !== id) {
1193 return n;
1194 } else {
1195 const sn = n as ServiceNode;
1196 return {
1197 ...sn,
1198 data: {
1199 ...sn.data,
1200 repository: {
1201 ...sn.data.repository,
1202 id: newRepoId,
1203 repoNodeId: existingNodeForSelectedRepo.id,
1204 },
1205 },
1206 };
1207 }
1208 });
1209 if (oldGithubNodeId && oldGithubNodeId !== existingNodeForSelectedRepo.id) {
1210 const isOldNodeStillUsed = edges.some(
1211 (e) => e.source === oldGithubNodeId && e.sourceHandle === "repository",
1212 );
1213 if (!isOldNodeStillUsed) {
1214 nodes = nodes.filter((n) => n.id !== oldGithubNodeId);
1215 }
1216 }
1217 store.setNodes(nodes);
1218 store.setEdges(edges);
1219 return;
1220 }
1221
1222 // No node for selected repo, decide whether to update old node or create a new one.
1223 if (oldGithubNodeId) {
1224 const isOldNodeShared =
1225 store.edges.filter(
1226 (e) =>
1227 e.source === oldGithubNodeId && e.target !== id && e.sourceHandle === "repository",
1228 ).length > 0;
1229
1230 if (!isOldNodeShared) {
1231 // Update old node
1232 store.updateNodeData<"github">(oldGithubNodeId, {
1233 repository: {
1234 id: selectedRepo.id,
1235 sshURL: selectedRepo.ssh_url,
1236 fullName: selectedRepo.full_name,
1237 },
1238 label: selectedRepo.full_name,
1239 });
1240 store.updateNodeData<"app">(id, {
1241 repository: {
1242 ...data.repository,
1243 id: newRepoId,
1244 },
1245 });
1246 } else {
1247 // Create new node because old one is shared
1248 const newGithubNodeId = uuidv4();
1249 store.addNode({
1250 id: newGithubNodeId,
1251 type: "github",
1252 data: {
1253 repository: {
1254 id: selectedRepo.id,
1255 sshURL: selectedRepo.ssh_url,
1256 fullName: selectedRepo.full_name,
1257 },
1258 label: selectedRepo.full_name,
1259 envVars: [],
1260 ports: [],
1261 },
1262 });
1263
1264 let edges = store.edges;
1265 // remove old edge
1266 edges = edges.filter(
1267 (e) =>
1268 !(
1269 e.target === id &&
1270 e.source === oldGithubNodeId &&
1271 e.targetHandle === "repository"
1272 ),
1273 );
1274 // add new edge
1275 edges = edges.concat({
1276 id: uuidv4(),
1277 source: newGithubNodeId,
1278 sourceHandle: "repository",
1279 target: id,
1280 targetHandle: "repository",
1281 });
1282 store.setEdges(edges);
1283 store.updateNodeData<"app">(id, {
1284 repository: {
1285 ...data.repository,
1286 id: newRepoId,
1287 repoNodeId: newGithubNodeId,
1288 },
1289 });
1290 }
1291 } else {
1292 // No old github node, so create a new one
1293 const newGithubNodeId = uuidv4();
1294 store.addNode({
1295 id: newGithubNodeId,
1296 type: "github",
1297 data: {
1298 repository: {
1299 id: selectedRepo.id,
1300 sshURL: selectedRepo.ssh_url,
1301 fullName: selectedRepo.full_name,
1302 },
1303 label: selectedRepo.full_name,
1304 envVars: [],
1305 ports: [],
1306 },
1307 });
1308 store.setEdges(
1309 store.edges.concat({
1310 id: uuidv4(),
1311 source: newGithubNodeId,
1312 sourceHandle: "repository",
1313 target: id,
1314 targetHandle: "repository",
1315 }),
1316 );
1317 store.updateNodeData<"app">(id, {
1318 repository: {
1319 ...data.repository,
1320 id: newRepoId,
1321 repoNodeId: newGithubNodeId,
1322 },
1323 });
1324 }
1325 } else if (name === "branch") {
1326 store.updateNodeData<"app">(id, {
1327 repository: {
1328 ...data?.repository,
1329 branch: value.branch,
1330 },
1331 });
1332 } else if (name === "rootDir") {
1333 store.updateNodeData<"app">(id, {
1334 repository: {
1335 ...data?.repository,
1336 rootDir: value.rootDir,
1337 },
1338 });
1339 }
1340 },
1341 );
1342 return () => sub.unsubscribe();
1343 }, [id, data, sourceForm, store, nodes, repos]);
1344 const [isExpanded, setIsExpanded] = useState(false);
1345 // useEffect(() => {
1346 // if (data.repository === undefined) {
1347 // setIsExpanded(true);
1348 // }
1349 // }, [data.repository, setIsExpanded]);
1350 console.log(data.repository, isExpanded, repo);
1351 return (
1352 <Accordion type="single" collapsible>
1353 <AccordionItem value="repository" className="border-none">
1354 <AccordionTrigger onClick={() => setIsExpanded(!isExpanded)}>
1355 Source {!isExpanded && repo !== undefined && repo.data.repository?.fullName}
1356 </AccordionTrigger>
1357 <AccordionContent className="px-1">
1358 <Form {...sourceForm}>
1359 <form className="space-y-2">
1360 <Label>Repository</Label>
1361 <FormField
1362 control={sourceForm.control}
1363 name="id"
1364 render={({ field }) => (
1365 <FormItem>
1366 <Select onValueChange={field.onChange} value={field.value} disabled={disabled}>
1367 <FormControl>
1368 <SelectTrigger>
1369 <SelectValue />
1370 </SelectTrigger>
1371 </FormControl>
1372 <SelectContent>
1373 {repos.map((r) => (
1374 <SelectItem
1375 key={r.id}
1376 value={r.id.toString()}
1377 >{`${r.full_name}`}</SelectItem>
1378 ))}
1379 </SelectContent>
1380 </Select>
1381 <FormMessage />
1382 </FormItem>
1383 )}
1384 />
1385 <Label>Branch</Label>
1386 <FormField
1387 control={sourceForm.control}
1388 name="branch"
1389 render={({ field }) => (
1390 <FormItem>
1391 <FormControl>
1392 <Input
1393 placeholder="master"
1394 className="lowercase"
1395 {...field}
1396 disabled={disabled}
1397 />
1398 </FormControl>
1399 <FormMessage />
1400 </FormItem>
1401 )}
1402 />
1403 <Label>Root Directory</Label>
1404 <FormField
1405 control={sourceForm.control}
1406 name="rootDir"
1407 render={({ field }) => (
1408 <FormItem>
1409 <FormControl>
1410 <Input placeholder="/" {...field} disabled={disabled} />
1411 </FormControl>
1412 <FormMessage />
1413 </FormItem>
1414 )}
1415 />
1416 </form>
1417 </Form>
1418 </AccordionContent>
1419 </AccordionItem>
1420 </Accordion>
1421 );
1422}