blob: 7516d19891cd2e1c93de0c95f0b1a4c5a87801e8 [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
gio3ec94242025-05-16 12:46:57 +000094export function NodeAppDetails({ id, data, disabled }: ServiceNode & { disabled?: boolean }) {
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")}
gio3ec94242025-05-16 12:46:57 +0000643 disabled={disabled}
giod0026612025-05-08 13:00:36 +0000644 />
645 </FormControl>
646 <FormMessage />
647 </FormItem>
648 )}
649 />
650 <FormField
651 control={form.control}
652 name="type"
653 render={({ field }) => (
654 <FormItem>
gio3ec94242025-05-16 12:46:57 +0000655 <Select
656 onValueChange={field.onChange}
657 defaultValue={field.value}
658 {...typeProps}
659 disabled={disabled}
660 >
giod0026612025-05-08 13:00:36 +0000661 <FormControl>
662 <SelectTrigger>
663 <SelectValue placeholder="Runtime" />
664 </SelectTrigger>
665 </FormControl>
666 <SelectContent>
667 {ServiceTypes.map((t) => (
668 <SelectItem key={t} value={t}>
669 {t}
670 </SelectItem>
671 ))}
672 </SelectContent>
673 </Select>
674 <FormMessage />
675 </FormItem>
676 )}
677 />
678 </form>
679 </Form>
680 Source
681 <Form {...sourceForm}>
682 <form className="space-y-2">
683 <FormField
684 control={sourceForm.control}
685 name="id"
686 render={({ field }) => (
687 <FormItem>
gio3ec94242025-05-16 12:46:57 +0000688 <Select onValueChange={field.onChange} defaultValue={field.value} disabled={disabled}>
giod0026612025-05-08 13:00:36 +0000689 <FormControl>
690 <SelectTrigger>
691 <SelectValue placeholder="Repository" />
692 </SelectTrigger>
693 </FormControl>
694 <SelectContent>
695 {(
696 nodes.filter(
697 (n) => n.type === "github" && n.data.repository?.id !== undefined,
698 ) as GithubNode[]
699 ).map((n) => (
700 <SelectItem
701 key={n.id}
702 value={n.id}
gio818da4e2025-05-12 14:45:35 +0000703 >{`${n.data.repository?.fullName}`}</SelectItem>
giod0026612025-05-08 13:00:36 +0000704 ))}
705 </SelectContent>
706 </Select>
707 <FormMessage />
708 </FormItem>
709 )}
710 />
711 <FormField
712 control={sourceForm.control}
713 name="branch"
714 render={({ field }) => (
715 <FormItem>
716 <FormControl>
gio3ec94242025-05-16 12:46:57 +0000717 <Input placeholder="master" className="lowercase" {...field} disabled={disabled} />
giod0026612025-05-08 13:00:36 +0000718 </FormControl>
719 <FormMessage />
720 </FormItem>
721 )}
722 />
723 <FormField
724 control={sourceForm.control}
725 name="rootDir"
726 render={({ field }) => (
727 <FormItem>
728 <FormControl>
gio3ec94242025-05-16 12:46:57 +0000729 <Input placeholder="/" {...field} disabled={disabled} />
giod0026612025-05-08 13:00:36 +0000730 </FormControl>
731 <FormMessage />
732 </FormItem>
733 )}
734 />
735 </form>
736 </Form>
737 Ports
738 <ul>
739 {data &&
740 data.ports &&
741 data.ports.map((p) => (
gio818da4e2025-05-12 14:45:35 +0000742 <li key={p.id} className="flex flex-row items-center gap-1">
giof8fa0f82025-05-16 12:34:26 +0000743 <Button
744 size={"icon"}
745 variant={"ghost"}
746 onClick={() => removePort(p.id)}
747 className="w-4 h-4"
gio3ec94242025-05-16 12:46:57 +0000748 disabled={disabled}
giof8fa0f82025-05-16 12:34:26 +0000749 >
giod0026612025-05-08 13:00:36 +0000750 <XIcon />
gio818da4e2025-05-12 14:45:35 +0000751 </Button>
752 <div>
753 {p.name} - {p.value}
754 </div>
giod0026612025-05-08 13:00:36 +0000755 </li>
756 ))}
757 </ul>
758 <Form {...portForm}>
759 <form className="flex flex-row space-x-1" onSubmit={portForm.handleSubmit(onSubmit)}>
760 <FormField
761 control={portForm.control}
762 name="name"
763 render={({ field }) => (
764 <FormItem>
765 <FormControl>
gio3ec94242025-05-16 12:46:57 +0000766 <Input placeholder="name" className="lowercase" {...field} disabled={disabled} />
giod0026612025-05-08 13:00:36 +0000767 </FormControl>
768 <FormMessage />
769 </FormItem>
770 )}
771 />
772 <FormField
773 control={portForm.control}
774 name="value"
775 render={({ field }) => (
776 <FormItem>
777 <FormControl>
gio3ec94242025-05-16 12:46:57 +0000778 <Input placeholder="value" {...field} disabled={disabled} />
giod0026612025-05-08 13:00:36 +0000779 </FormControl>
780 <FormMessage />
781 </FormItem>
782 )}
783 />
gio3ec94242025-05-16 12:46:57 +0000784 <Button type="submit" disabled={disabled}>
785 Add Port
786 </Button>
giod0026612025-05-08 13:00:36 +0000787 </form>
788 </Form>
789 Env Vars
790 <ul>
791 {data &&
792 data.envVars &&
793 data.envVars.map((v) => {
794 if ("name" in v) {
795 const value = "alias" in v ? v.alias : v.name;
796 if (v.isEditting) {
797 return (
798 <li key={v.id}>
799 <Input
800 type="text"
giofcefd7c2025-05-13 08:01:07 +0000801 className="uppercase"
giod0026612025-05-08 13:00:36 +0000802 defaultValue={value}
803 onKeyUp={saveAliasOnEnter(v)}
804 onBlur={saveAliasOnBlur(v)}
805 autoFocus={true}
gio3ec94242025-05-16 12:46:57 +0000806 disabled={disabled}
giod0026612025-05-08 13:00:36 +0000807 />
808 </li>
809 );
810 }
811 return (
812 <li key={v.id} onClick={editAlias(v)}>
813 <TooltipProvider>
814 <Tooltip>
815 <TooltipTrigger>
gio818da4e2025-05-12 14:45:35 +0000816 <div className="flex flex-row items-center gap-1">
giof8fa0f82025-05-16 12:34:26 +0000817 <Button size={"icon"} variant={"ghost"} className="w-4 h-4">
gio818da4e2025-05-12 14:45:35 +0000818 <PencilIcon />
819 </Button>
820 <div>{value}</div>
821 </div>
giod0026612025-05-08 13:00:36 +0000822 </TooltipTrigger>
823 <TooltipContent>{v.name}</TooltipContent>
824 </Tooltip>
825 </TooltipProvider>
826 </li>
827 );
828 }
829 })}
830 </ul>
831 Pre-Build Commands
832 <Textarea
833 placeholder="new line separated list of commands to run before running the service"
834 value={data.preBuildCommands}
835 onChange={setPreBuildCommands}
gio3ec94242025-05-16 12:46:57 +0000836 disabled={disabled}
giod0026612025-05-08 13:00:36 +0000837 />
gio48fde052025-05-14 09:48:08 +0000838 Dev
839 <Form {...devForm}>
840 <form className="space-y-2">
841 <FormField
842 control={devForm.control}
843 name="enabled"
844 render={({ field }) => (
845 <FormItem>
846 <div className="flex flex-row gap-1 items-center">
gio3ec94242025-05-16 12:46:57 +0000847 <Checkbox
848 id="devEnabled"
849 onCheckedChange={field.onChange}
850 checked={field.value}
851 disabled={disabled}
852 />
gio48fde052025-05-14 09:48:08 +0000853 <Label htmlFor="devEnabled">Enabled</Label>
854 </div>
855 <FormMessage />
856 </FormItem>
857 )}
858 />
859 </form>
860 </Form>
gio29050d62025-05-16 04:49:26 +0000861 {data.dev && data.dev.enabled && (
862 <Form {...exposeForm}>
863 <form className="space-y-2">
864 <FormField
865 control={exposeForm.control}
866 name="network"
867 render={({ field }) => (
868 <FormItem>
gio3ec94242025-05-16 12:46:57 +0000869 <Select
870 onValueChange={field.onChange}
871 defaultValue={field.value}
872 disabled={disabled}
873 >
gio29050d62025-05-16 04:49:26 +0000874 <FormControl>
875 <SelectTrigger>
876 <SelectValue placeholder="Network" />
877 </SelectTrigger>
878 </FormControl>
879 <SelectContent>
880 {env.networks.map((n) => (
881 <SelectItem
882 key={n.name}
883 value={n.domain}
884 >{`${n.name} - ${n.domain}`}</SelectItem>
885 ))}
886 </SelectContent>
887 </Select>
888 <FormMessage />
889 </FormItem>
890 )}
891 />
892 <FormField
893 control={exposeForm.control}
894 name="subdomain"
895 render={({ field }) => (
896 <FormItem>
gio48fde052025-05-14 09:48:08 +0000897 <FormControl>
gio3ec94242025-05-16 12:46:57 +0000898 <Input placeholder="subdomain" {...field} disabled={disabled} />
gio48fde052025-05-14 09:48:08 +0000899 </FormControl>
gio29050d62025-05-16 04:49:26 +0000900 <FormMessage />
901 </FormItem>
902 )}
903 />
904 </form>
905 </Form>
906 )}
giod0026612025-05-08 13:00:36 +0000907 </>
908 );
909}