blob: bf41a4bf8620fd1632584bbc8b0d9c031b8f2cb7 [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";
3import {
4 useStateStore,
5 ServiceNode,
6 ServiceTypes,
7 nodeLabel,
8 BoundEnvVar,
9 AppState,
10 nodeIsConnectable,
11 GatewayTCPNode,
12 GatewayHttpsNode,
13 AppNode,
gio818da4e2025-05-12 14:45:35 +000014 GithubNode,
gio48fde052025-05-14 09:48:08 +000015 useEnv,
giod0026612025-05-08 13:00:36 +000016} from "@/lib/state";
17import { KeyboardEvent, FocusEvent, useCallback, useEffect, useMemo, useState } from "react";
gio5f2f1002025-03-20 18:38:48 +040018import { z } from "zod";
giod0026612025-05-08 13:00:36 +000019import { DeepPartial, EventType, useForm, ControllerRenderProps, FieldPath } from "react-hook-form";
20import { zodResolver } from "@hookform/resolvers/zod";
21import { Form, FormControl, FormField, FormItem, FormMessage } from "./ui/form";
giod0026612025-05-08 13:00:36 +000022import { Button } from "./ui/button";
gio33990c62025-05-06 07:51:24 +000023import { Handle, Position, useNodes } from "@xyflow/react";
gio5f2f1002025-03-20 18:38:48 +040024import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
giob41ecae2025-04-24 08:46:50 +000025import { PencilIcon, XIcon } from "lucide-react";
gio5f2f1002025-03-20 18:38:48 +040026import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip";
gio91165612025-05-03 17:07:38 +000027import { Textarea } from "./ui/textarea";
giofcefd7c2025-05-13 08:01:07 +000028import { Input } from "./ui/input";
gio48fde052025-05-14 09:48:08 +000029import { Checkbox } from "./ui/checkbox";
30import { Label } from "./ui/label";
gio5f2f1002025-03-20 18:38:48 +040031
32export function NodeApp(node: ServiceNode) {
giod0026612025-05-08 13:00:36 +000033 const { id, selected } = node;
34 const isConnectablePorts = useMemo(() => nodeIsConnectable(node, "ports"), [node]);
35 const isConnectableRepository = useMemo(() => nodeIsConnectable(node, "repository"), [node]);
36 return (
37 <NodeRect id={id} selected={selected} type={node.type} state={node.data.state}>
38 <div style={{ padding: "10px 20px" }}>
39 {nodeLabel(node)}
40 <Handle
41 id="repository"
42 type={"target"}
43 position={Position.Left}
44 isConnectableStart={isConnectableRepository}
45 isConnectableEnd={isConnectableRepository}
46 isConnectable={isConnectableRepository}
47 />
48 <Handle
49 id="ports"
50 type={"source"}
51 position={Position.Top}
52 isConnectableStart={isConnectablePorts}
53 isConnectableEnd={isConnectablePorts}
54 isConnectable={isConnectablePorts}
55 />
56 <Handle
57 id="env_var"
58 type={"target"}
59 position={Position.Bottom}
60 isConnectableStart={true}
61 isConnectableEnd={true}
62 isConnectable={true}
63 />
64 </div>
65 </NodeRect>
66 );
gio5f2f1002025-03-20 18:38:48 +040067}
68
69const schema = z.object({
giod0026612025-05-08 13:00:36 +000070 name: z.string().min(1, "requried"),
71 type: z.enum(ServiceTypes),
gio5f2f1002025-03-20 18:38:48 +040072});
73
74const portSchema = z.object({
giod0026612025-05-08 13:00:36 +000075 name: z.string().min(1, "required"),
gio818da4e2025-05-12 14:45:35 +000076 value: z.coerce.number().gt(0, "must be positive").lte(65535, "must be less than 65535"),
gio5f2f1002025-03-20 18:38:48 +040077});
78
gio33990c62025-05-06 07:51:24 +000079const sourceSchema = z.object({
giod0026612025-05-08 13:00:36 +000080 id: z.string().min(1, "required"),
81 branch: z.string(),
82 rootDir: z.string(),
gio33990c62025-05-06 07:51:24 +000083});
84
gio48fde052025-05-14 09:48:08 +000085const devSchema = z.object({
86 enabled: z.boolean(),
87});
88
89const exposeSchema = z.object({
90 network: z.string().min(1, "reqired"),
91 subdomain: z.string().min(1, "required"),
92});
93
gio5f2f1002025-03-20 18:38:48 +040094export function NodeAppDetails({ id, data }: ServiceNode) {
giod0026612025-05-08 13:00:36 +000095 const store = useStateStore();
96 const nodes = useNodes<AppNode>();
gio48fde052025-05-14 09:48:08 +000097 const env = useEnv();
giod0026612025-05-08 13:00:36 +000098 const form = useForm<z.infer<typeof schema>>({
99 resolver: zodResolver(schema),
100 mode: "onChange",
101 defaultValues: {
102 name: data.label,
103 type: data.type,
104 },
105 });
106 const portForm = useForm<z.infer<typeof portSchema>>({
107 resolver: zodResolver(portSchema),
108 mode: "onSubmit",
109 defaultValues: {
110 name: "",
111 value: 0,
112 },
113 });
114 const onSubmit = useCallback(
115 (values: z.infer<typeof portSchema>) => {
116 const portId = uuidv4();
117 store.updateNodeData<"app">(id, {
118 ports: (data.ports || []).concat({
119 id: portId,
gio818da4e2025-05-12 14:45:35 +0000120 name: values.name.toLowerCase(),
giod0026612025-05-08 13:00:36 +0000121 value: values.value,
122 }),
123 envVars: (data.envVars || []).concat({
124 id: uuidv4(),
125 source: null,
126 portId,
127 name: `DODO_PORT_${values.name.toUpperCase()}`,
128 }),
129 });
130 portForm.reset();
131 },
132 [id, data, portForm, store],
133 );
134 useEffect(() => {
135 const sub = form.watch(
136 (
137 value: DeepPartial<z.infer<typeof schema>>,
138 { name, type }: { name?: keyof z.infer<typeof schema> | undefined; type?: EventType | undefined },
139 ) => {
140 console.log({ name, type });
141 if (type !== "change") {
142 return;
143 }
144 switch (name) {
145 case "name":
146 if (!value.name) {
147 break;
148 }
149 store.updateNodeData<"app">(id, {
150 label: value.name,
151 });
152 break;
153 case "type":
154 if (!value.type) {
155 break;
156 }
157 store.updateNodeData<"app">(id, {
158 type: value.type,
159 });
160 break;
161 }
162 },
163 );
164 return () => sub.unsubscribe();
165 }, [id, form, store]);
166 const focus = useCallback(
167 (field: ControllerRenderProps<z.infer<typeof schema>, FieldPath<z.infer<typeof schema>>>, name: string) => {
168 return (e: HTMLElement | null) => {
169 field.ref(e);
170 if (e != null && name === data.activeField) {
171 console.log(e);
172 e.focus();
173 store.updateNodeData(id, {
174 activeField: undefined,
175 });
176 }
177 };
178 },
179 [id, data, store],
180 );
181 const [typeProps, setTypeProps] = useState({});
182 useEffect(() => {
183 if (data.activeField === "type") {
184 setTypeProps({
185 open: true,
186 onOpenChange: () => store.updateNodeData(id, { activeField: undefined }),
187 });
188 } else {
189 setTypeProps({});
190 }
191 }, [id, data, store, setTypeProps]);
192 const editAlias = useCallback(
193 (e: BoundEnvVar) => {
194 return () => {
195 store.updateNodeData(id, {
196 ...data,
197 envVars: data.envVars!.map((o) => {
198 if (o.id !== e.id) {
199 return o;
200 } else
201 return {
202 ...o,
203 isEditting: true,
204 };
205 }),
206 });
207 };
208 },
209 [id, data, store],
210 );
211 const saveAlias = useCallback(
212 (e: BoundEnvVar, value: string, store: AppState) => {
213 store.updateNodeData(id, {
214 ...data,
215 envVars: data.envVars!.map((o) => {
216 if (o.id !== e.id) {
217 return o;
218 }
219 if (value) {
220 return {
221 ...o,
222 isEditting: false,
223 alias: value.toUpperCase(),
224 };
225 }
226 console.log(o);
227 if ("alias" in o) {
228 const { alias: _, ...rest } = o;
229 console.log(rest);
230 return {
231 ...rest,
232 isEditting: false,
233 };
234 }
235 return {
236 ...o,
237 isEditting: false,
238 };
239 }),
240 });
241 },
242 [id, data],
243 );
244 const saveAliasOnEnter = useCallback(
245 (e: BoundEnvVar) => {
246 return (event: KeyboardEvent<HTMLInputElement>) => {
247 if (event.key === "Enter") {
248 event.preventDefault();
249 saveAlias(e, event.currentTarget.value, store);
250 }
251 };
252 },
253 [store, saveAlias],
254 );
255 const saveAliasOnBlur = useCallback(
256 (e: BoundEnvVar) => {
257 return (event: FocusEvent<HTMLInputElement>) => {
258 saveAlias(e, event.currentTarget.value, store);
259 };
260 },
261 [store, saveAlias],
262 );
263 const removePort = useCallback(
264 (portId: string) => {
265 // TODO(gio): this is ugly
266 const tcpRemoved = new Set<string>();
267 console.log(store.edges);
268 store.setEdges(
269 store.edges.filter((e) => {
270 if (e.source !== id || e.sourceHandle !== "ports") {
271 return true;
272 }
273 const tn = store.nodes.find((n) => n.id == e.target)!;
274 if (e.targetHandle === "https") {
275 const t = tn as GatewayHttpsNode;
276 if (t.data.https?.serviceId === id && t.data.https.portId === portId) {
277 return false;
278 }
279 }
280 if (e.targetHandle === "tcp") {
281 const t = tn as GatewayTCPNode;
282 if (tcpRemoved.has(t.id)) {
283 return true;
284 }
285 if (t.data.exposed.find((e) => e.serviceId === id && e.portId === portId)) {
286 tcpRemoved.add(t.id);
287 return false;
288 }
289 }
290 if (e.targetHandle === "env_var") {
291 if (
292 tn &&
293 (tn.data.envVars || []).find(
294 (ev) => ev.source === id && "portId" in ev && ev.portId === portId,
295 )
296 ) {
297 return false;
298 }
299 }
300 return true;
301 }),
302 );
303 store.nodes
304 .filter(
305 (n) =>
306 n.type === "gateway-https" &&
307 n.data.https &&
308 n.data.https.serviceId === id &&
309 n.data.https.portId === portId,
310 )
311 .forEach((n) => {
312 store.updateNodeData<"gateway-https">(n.id, {
313 https: undefined,
314 });
315 });
316 store.nodes
317 .filter((n) => n.type === "gateway-tcp")
318 .forEach((n) => {
319 const filtered = n.data.exposed.filter((e) => {
320 if (e.serviceId === id && e.portId === portId) {
321 return false;
322 } else {
323 return true;
324 }
325 });
326 if (filtered.length != n.data.exposed.length) {
327 store.updateNodeData<"gateway-tcp">(n.id, {
328 exposed: filtered,
329 });
330 }
331 });
332 store.nodes
333 .filter((n) => n.type === "app" && n.data.envVars)
334 .forEach((n) => {
335 store.updateNodeData<"app">(n.id, {
336 envVars: n.data.envVars.filter((ev) => {
337 if (ev.source === id && "portId" in ev && ev.portId === portId) {
338 return false;
339 }
340 return true;
341 }),
342 });
343 });
344 store.updateNodeData<"app">(id, {
345 ports: (data.ports || []).filter((p) => p.id !== portId),
346 envVars: (data.envVars || []).filter(
347 (ev) => !(ev.source === null && "portId" in ev && ev.portId === portId),
348 ),
349 });
350 },
351 [id, data, store],
352 );
353 const setPreBuildCommands = useCallback(
354 (e: React.ChangeEvent<HTMLTextAreaElement>) => {
355 store.updateNodeData<"app">(id, {
356 preBuildCommands: e.currentTarget.value,
357 });
358 },
359 [id, store],
360 );
gio33990c62025-05-06 07:51:24 +0000361
giod0026612025-05-08 13:00:36 +0000362 const sourceForm = useForm<z.infer<typeof sourceSchema>>({
363 resolver: zodResolver(sourceSchema),
364 mode: "onChange",
365 defaultValues: {
366 id: data?.repository?.id,
367 branch: data.repository && "branch" in data.repository ? data.repository.branch : undefined,
368 rootDir: data.repository && "rootDir" in data.repository ? data.repository.rootDir : undefined,
369 },
370 });
371 useEffect(() => {
372 const sub = sourceForm.watch(
373 (
374 value: DeepPartial<z.infer<typeof sourceSchema>>,
375 { name }: { name?: keyof z.infer<typeof sourceSchema> | undefined; type?: EventType | undefined },
376 ) => {
377 console.log(value);
378 if (name === "id") {
379 let edges = store.edges;
380 if (data?.repository?.id !== undefined) {
381 edges = edges.filter((e) => {
382 if (e.target === id && e.targetHandle === "repository" && e.source === data.repository.id) {
383 return false;
384 } else {
385 return true;
386 }
387 });
388 }
389 if (value.id !== undefined) {
390 edges = edges.concat({
391 id: uuidv4(),
392 source: value.id,
393 sourceHandle: "repository",
394 target: id,
395 targetHandle: "repository",
396 });
397 }
398 store.setEdges(edges);
399 store.updateNodeData<"app">(id, {
400 repository: {
401 id: value.id,
402 },
403 });
404 } else if (name === "branch") {
405 store.updateNodeData<"app">(id, {
406 repository: {
407 ...data?.repository,
408 branch: value.branch,
409 },
410 });
411 } else if (name === "rootDir") {
412 store.updateNodeData<"app">(id, {
413 repository: {
414 ...data?.repository,
415 rootDir: value.rootDir,
416 },
417 });
418 }
419 },
420 );
421 return () => sub.unsubscribe();
422 }, [id, data, sourceForm, store]);
gio48fde052025-05-14 09:48:08 +0000423 const devForm = useForm<z.infer<typeof devSchema>>({
424 resolver: zodResolver(devSchema),
425 mode: "onChange",
426 defaultValues: {
427 enabled: data.dev ? data.dev.enabled : false,
428 },
429 });
430 useEffect(() => {
431 const sub = devForm.watch((value, { name }) => {
432 if (name === "enabled") {
433 if (value.enabled) {
434 const csGateway: Omit<GatewayHttpsNode, "position"> = {
435 id: uuidv4(),
436 type: "gateway-https",
437 data: {
438 readonly: true,
439 https: {
440 serviceId: id,
441 portId: `${id}-code-server`,
442 },
443 network: data.dev?.expose?.network,
444 subdomain: data.dev?.expose?.subdomain,
445 label: "",
446 envVars: [],
447 ports: [],
448 },
449 };
450 const sshGateway: Omit<GatewayTCPNode, "position"> = {
451 id: uuidv4(),
452 type: "gateway-tcp",
453 data: {
454 readonly: true,
455 exposed: [
456 {
457 serviceId: id,
458 portId: `${id}-ssh`,
459 },
460 ],
461 network: data.dev?.expose?.network,
462 subdomain: data.dev?.expose?.subdomain,
463 label: "",
464 envVars: [],
465 ports: [],
466 },
467 };
468 store.addNode(csGateway);
469 store.addNode(sshGateway);
470 store.updateNodeData<"app">(id, {
471 dev: {
472 enabled: true,
473 expose: data.dev?.expose,
474 codeServerNodeId: csGateway.id,
475 sshNodeId: sshGateway.id,
476 },
477 ports: (data.ports || []).concat(
478 {
479 id: `${id}-code-server`,
480 name: "code-server",
481 value: 9090,
482 },
483 {
484 id: `${id}-ssh`,
485 name: "ssh",
486 value: 22,
487 },
488 ),
489 });
490 let edges = store.edges.concat([
491 {
492 id: uuidv4(),
493 source: id,
494 sourceHandle: "ports",
495 target: csGateway.id,
496 targetHandle: "https",
497 },
498 {
499 id: uuidv4(),
500 source: id,
501 sourceHandle: "ports",
502 target: sshGateway.id,
503 targetHandle: "tcp",
504 },
505 ]);
506 if (data.dev?.expose?.network !== undefined) {
507 edges = edges.concat([
508 {
509 id: uuidv4(),
510 source: csGateway.id,
511 sourceHandle: "subdomain",
512 target: data.dev.expose.network,
513 targetHandle: "subdomain",
514 },
515 {
516 id: uuidv4(),
517 source: sshGateway.id,
518 sourceHandle: "subdomain",
519 target: data.dev.expose.network,
520 targetHandle: "subdomain",
521 },
522 ]);
523 }
524 store.setEdges(edges);
525 } else {
526 const { dev } = data;
527 if (dev?.enabled) {
528 store.setNodes(
529 store.nodes.filter((n) => n.id !== dev.codeServerNodeId && n.id !== dev.sshNodeId),
530 );
531 store.setEdges(
532 store.edges.filter((e) => e.target !== dev.codeServerNodeId && e.target !== dev.sshNodeId),
533 );
534 }
535 store.updateNodeData<"app">(id, {
536 dev: {
537 enabled: false,
538 expose: dev?.expose,
539 },
540 ports: (data.ports || []).filter((p) => p.name !== "code-server" && p.name !== "ssh"),
541 });
542 }
543 }
544 });
545 return () => sub.unsubscribe();
546 }, [id, data, devForm, store]);
547 const exposeForm = useForm<z.infer<typeof exposeSchema>>({
548 resolver: zodResolver(exposeSchema),
549 mode: "onChange",
550 defaultValues: {
551 network: data.dev?.expose?.network,
552 subdomain: data.dev?.expose?.subdomain,
553 },
554 });
555 useEffect(() => {
556 const sub = exposeForm.watch(
557 (
558 value: DeepPartial<z.infer<typeof exposeSchema>>,
559 { name }: { name?: keyof z.infer<typeof exposeSchema> | undefined; type?: EventType | undefined },
560 ) => {
561 const { dev } = data;
562 if (!dev?.enabled) {
563 return;
564 }
565 if (name === "network") {
566 let edges = store.edges;
567 if (dev.enabled && dev.expose?.network !== undefined) {
568 edges = edges.filter((e) => {
569 if (
570 (e.source === dev.codeServerNodeId || e.source === dev.sshNodeId) &&
571 e.sourceHandle === "subdomain" &&
572 e.target === dev.expose?.network &&
573 e.targetHandle === "subdomain"
574 ) {
575 return false;
576 } else {
577 return true;
578 }
579 });
580 }
581 if (value.network !== undefined) {
582 edges = edges.concat(
583 {
584 id: uuidv4(),
585 source: dev.codeServerNodeId,
586 sourceHandle: "subdomain",
587 target: value.network,
588 targetHandle: "subdomain",
589 },
590 {
591 id: uuidv4(),
592 source: dev.sshNodeId,
593 sourceHandle: "subdomain",
594 target: value.network,
595 targetHandle: "subdomain",
596 },
597 );
598 }
599 store.setEdges(edges);
600 store.updateNodeData<"app">(id, {
601 dev: {
602 ...dev,
603 expose: {
604 network: value.network,
605 subdomain: dev.expose?.subdomain,
606 },
607 },
608 });
609 store.updateNodeData<"gateway-https">(dev.codeServerNodeId, { network: value.network });
610 store.updateNodeData<"gateway-tcp">(dev.sshNodeId, { network: value.network });
611 } else if (name === "subdomain") {
612 store.updateNodeData<"app">(id, {
613 dev: {
614 ...dev,
615 expose: {
616 network: dev.expose?.network,
617 subdomain: value.subdomain,
618 },
619 },
620 });
621 store.updateNodeData<"gateway-https">(dev.codeServerNodeId, { subdomain: value.subdomain });
622 store.updateNodeData<"gateway-tcp">(dev.sshNodeId, { subdomain: value.subdomain });
623 }
624 },
625 );
626 return () => sub.unsubscribe();
627 }, [id, data, exposeForm, store]);
giod0026612025-05-08 13:00:36 +0000628 return (
629 <>
gio48fde052025-05-14 09:48:08 +0000630 <Form {...exposeForm}>
giofcefd7c2025-05-13 08:01:07 +0000631 <form className="space-y-2">
giod0026612025-05-08 13:00:36 +0000632 <FormField
633 control={form.control}
634 name="name"
635 render={({ field }) => (
636 <FormItem>
637 <FormControl>
638 <Input
639 placeholder="name"
giofcefd7c2025-05-13 08:01:07 +0000640 className="lowercase"
giod0026612025-05-08 13:00:36 +0000641 {...field}
642 ref={focus(field, "name")}
643 />
644 </FormControl>
645 <FormMessage />
646 </FormItem>
647 )}
648 />
649 <FormField
650 control={form.control}
651 name="type"
652 render={({ field }) => (
653 <FormItem>
654 <Select onValueChange={field.onChange} defaultValue={field.value} {...typeProps}>
655 <FormControl>
656 <SelectTrigger>
657 <SelectValue placeholder="Runtime" />
658 </SelectTrigger>
659 </FormControl>
660 <SelectContent>
661 {ServiceTypes.map((t) => (
662 <SelectItem key={t} value={t}>
663 {t}
664 </SelectItem>
665 ))}
666 </SelectContent>
667 </Select>
668 <FormMessage />
669 </FormItem>
670 )}
671 />
672 </form>
673 </Form>
674 Source
675 <Form {...sourceForm}>
676 <form className="space-y-2">
677 <FormField
678 control={sourceForm.control}
679 name="id"
680 render={({ field }) => (
681 <FormItem>
682 <Select onValueChange={field.onChange} defaultValue={field.value}>
683 <FormControl>
684 <SelectTrigger>
685 <SelectValue placeholder="Repository" />
686 </SelectTrigger>
687 </FormControl>
688 <SelectContent>
689 {(
690 nodes.filter(
691 (n) => n.type === "github" && n.data.repository?.id !== undefined,
692 ) as GithubNode[]
693 ).map((n) => (
694 <SelectItem
695 key={n.id}
696 value={n.id}
gio818da4e2025-05-12 14:45:35 +0000697 >{`${n.data.repository?.fullName}`}</SelectItem>
giod0026612025-05-08 13:00:36 +0000698 ))}
699 </SelectContent>
700 </Select>
701 <FormMessage />
702 </FormItem>
703 )}
704 />
705 <FormField
706 control={sourceForm.control}
707 name="branch"
708 render={({ field }) => (
709 <FormItem>
710 <FormControl>
giofcefd7c2025-05-13 08:01:07 +0000711 <Input placeholder="master" className="lowercase" {...field} />
giod0026612025-05-08 13:00:36 +0000712 </FormControl>
713 <FormMessage />
714 </FormItem>
715 )}
716 />
717 <FormField
718 control={sourceForm.control}
719 name="rootDir"
720 render={({ field }) => (
721 <FormItem>
722 <FormControl>
giofcefd7c2025-05-13 08:01:07 +0000723 <Input placeholder="/" {...field} />
giod0026612025-05-08 13:00:36 +0000724 </FormControl>
725 <FormMessage />
726 </FormItem>
727 )}
728 />
729 </form>
730 </Form>
731 Ports
732 <ul>
733 {data &&
734 data.ports &&
735 data.ports.map((p) => (
gio818da4e2025-05-12 14:45:35 +0000736 <li key={p.id} className="flex flex-row items-center gap-1">
giod0026612025-05-08 13:00:36 +0000737 <Button size={"icon"} variant={"ghost"} onClick={() => removePort(p.id)}>
738 <XIcon />
gio818da4e2025-05-12 14:45:35 +0000739 </Button>
740 <div>
741 {p.name} - {p.value}
742 </div>
giod0026612025-05-08 13:00:36 +0000743 </li>
744 ))}
745 </ul>
746 <Form {...portForm}>
747 <form className="flex flex-row space-x-1" onSubmit={portForm.handleSubmit(onSubmit)}>
748 <FormField
749 control={portForm.control}
750 name="name"
751 render={({ field }) => (
752 <FormItem>
753 <FormControl>
giofcefd7c2025-05-13 08:01:07 +0000754 <Input placeholder="name" className="lowercase" {...field} />
giod0026612025-05-08 13:00:36 +0000755 </FormControl>
756 <FormMessage />
757 </FormItem>
758 )}
759 />
760 <FormField
761 control={portForm.control}
762 name="value"
763 render={({ field }) => (
764 <FormItem>
765 <FormControl>
giofcefd7c2025-05-13 08:01:07 +0000766 <Input placeholder="value" {...field} />
giod0026612025-05-08 13:00:36 +0000767 </FormControl>
768 <FormMessage />
769 </FormItem>
770 )}
771 />
772 <Button type="submit">Add Port</Button>
773 </form>
774 </Form>
775 Env Vars
776 <ul>
777 {data &&
778 data.envVars &&
779 data.envVars.map((v) => {
780 if ("name" in v) {
781 const value = "alias" in v ? v.alias : v.name;
782 if (v.isEditting) {
783 return (
784 <li key={v.id}>
785 <Input
786 type="text"
giofcefd7c2025-05-13 08:01:07 +0000787 className="uppercase"
giod0026612025-05-08 13:00:36 +0000788 defaultValue={value}
789 onKeyUp={saveAliasOnEnter(v)}
790 onBlur={saveAliasOnBlur(v)}
791 autoFocus={true}
792 />
793 </li>
794 );
795 }
796 return (
797 <li key={v.id} onClick={editAlias(v)}>
798 <TooltipProvider>
799 <Tooltip>
800 <TooltipTrigger>
gio818da4e2025-05-12 14:45:35 +0000801 <div className="flex flex-row items-center gap-1">
802 <Button size={"icon"} variant={"ghost"}>
803 <PencilIcon />
804 </Button>
805 <div>{value}</div>
806 </div>
giod0026612025-05-08 13:00:36 +0000807 </TooltipTrigger>
808 <TooltipContent>{v.name}</TooltipContent>
809 </Tooltip>
810 </TooltipProvider>
811 </li>
812 );
813 }
814 })}
815 </ul>
816 Pre-Build Commands
817 <Textarea
818 placeholder="new line separated list of commands to run before running the service"
819 value={data.preBuildCommands}
820 onChange={setPreBuildCommands}
821 />
gio48fde052025-05-14 09:48:08 +0000822 Dev
823 <Form {...devForm}>
824 <form className="space-y-2">
825 <FormField
826 control={devForm.control}
827 name="enabled"
828 render={({ field }) => (
829 <FormItem>
830 <div className="flex flex-row gap-1 items-center">
831 <Checkbox id="devEnabled" onCheckedChange={field.onChange} checked={field.value} />
832 <Label htmlFor="devEnabled">Enabled</Label>
833 </div>
834 <FormMessage />
835 </FormItem>
836 )}
837 />
838 </form>
839 </Form>
gio29050d62025-05-16 04:49:26 +0000840 {data.dev && data.dev.enabled && (
841 <Form {...exposeForm}>
842 <form className="space-y-2">
843 <FormField
844 control={exposeForm.control}
845 name="network"
846 render={({ field }) => (
847 <FormItem>
848 <Select onValueChange={field.onChange} defaultValue={field.value}>
849 <FormControl>
850 <SelectTrigger>
851 <SelectValue placeholder="Network" />
852 </SelectTrigger>
853 </FormControl>
854 <SelectContent>
855 {env.networks.map((n) => (
856 <SelectItem
857 key={n.name}
858 value={n.domain}
859 >{`${n.name} - ${n.domain}`}</SelectItem>
860 ))}
861 </SelectContent>
862 </Select>
863 <FormMessage />
864 </FormItem>
865 )}
866 />
867 <FormField
868 control={exposeForm.control}
869 name="subdomain"
870 render={({ field }) => (
871 <FormItem>
gio48fde052025-05-14 09:48:08 +0000872 <FormControl>
gio29050d62025-05-16 04:49:26 +0000873 <Input placeholder="subdomain" {...field} />
gio48fde052025-05-14 09:48:08 +0000874 </FormControl>
gio29050d62025-05-16 04:49:26 +0000875 <FormMessage />
876 </FormItem>
877 )}
878 />
879 </form>
880 </Form>
881 )}
giod0026612025-05-08 13:00:36 +0000882 </>
883 );
884}