blob: 3598a4d88fb3e5665155f35a0d2058685783055a [file] [log] [blame]
gio5f2f1002025-03-20 18:38:48 +04001import { v4 as uuidv4 } from "uuid";
2import { NodeRect } from './node-rect';
gioaba9a962025-04-25 14:19:40 +00003import { useStateStore, ServiceNode, ServiceTypes, nodeLabel, BoundEnvVar, AppState, nodeIsConnectable, GatewayTCPNode, GatewayHttpsNode } from '@/lib/state';
gio5f2f1002025-03-20 18:38:48 +04004import { KeyboardEvent, FocusEvent, useCallback, useEffect, useMemo, useState } from 'react';
5import { z } from "zod";
6import { DeepPartial, EventType, useForm } from 'react-hook-form';
7import { zodResolver } from '@hookform/resolvers/zod';
8import { Form, FormControl, FormField, FormItem, FormMessage } from './ui/form';
9import { Input } from './ui/input';
10import { Button } from './ui/button';
11import { Handle, Position } from "@xyflow/react";
12import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
giob41ecae2025-04-24 08:46:50 +000013import { PencilIcon, XIcon } from "lucide-react";
gio5f2f1002025-03-20 18:38:48 +040014import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip";
15
16export function NodeApp(node: ServiceNode) {
17 const { id, selected } = node;
18 const isConnectablePorts = useMemo(() => nodeIsConnectable(node, "ports"), [node]);
19 const isConnectableRepository = useMemo(() => nodeIsConnectable(node, "repository"), [node]);
20 return (
gio1dc800a2025-04-24 17:15:43 +000021 <NodeRect id={id} selected={selected} type={node.type} state={node.data.state}>
gio5f2f1002025-03-20 18:38:48 +040022 <div style={{ padding: '10px 20px' }}>
23 {nodeLabel(node)}
24 <Handle
25 id="repository"
26 type={"target"}
27 position={Position.Left}
28 isConnectableStart={isConnectableRepository}
29 isConnectableEnd={isConnectableRepository}
30 isConnectable={isConnectableRepository}
31 />
32 <Handle
33 id="ports"
34 type={"source"}
35 position={Position.Top}
36 isConnectableStart={isConnectablePorts}
37 isConnectableEnd={isConnectablePorts}
38 isConnectable={isConnectablePorts}
39 />
40 <Handle
41 id="env_var"
42 type={"target"}
43 position={Position.Bottom}
44 isConnectableStart={true}
45 isConnectableEnd={true}
46 isConnectable={true}
47 />
48 </div>
49 </NodeRect>
50 );
51}
52
53const schema = z.object({
54 name: z.string().min(1, "requried"),
55 type: z.enum(ServiceTypes),
56});
57
58const portSchema = z.object({
59 name: z.string().min(1, "required"),
60 value: z.coerce.number().gt(0, "can not be negative"),
61});
62
63export function NodeAppDetails({ id, data }: ServiceNode) {
64 const store = useStateStore();
65 const form = useForm<z.infer<typeof schema>>({
66 resolver: zodResolver(schema),
67 mode: "onChange",
68 defaultValues: {
69 name: data.label,
70 type: data.type,
71 }
72 });
73 const portForm = useForm<z.infer<typeof portSchema>>({
74 resolver: zodResolver(portSchema),
75 mode: "onSubmit",
76 defaultValues: {
77 name: "",
78 value: 0,
79 }
80 });
81 const onSubmit = useCallback((values: z.infer<typeof portSchema>) => {
giob41ecae2025-04-24 08:46:50 +000082 const portId = uuidv4();
gio5f2f1002025-03-20 18:38:48 +040083 store.updateNodeData<"app">(id, {
84 ports: (data.ports || []).concat({
giob41ecae2025-04-24 08:46:50 +000085 id: portId,
gio5f2f1002025-03-20 18:38:48 +040086 name: values.name,
87 value: values.value,
gio355883e2025-04-23 14:10:51 +000088 }),
89 envVars: (data.envVars || []).concat({
90 id: uuidv4(),
91 source: null,
giob41ecae2025-04-24 08:46:50 +000092 portId,
gio355883e2025-04-23 14:10:51 +000093 name: `DODO_PORT_${values.name.toUpperCase()}`,
94 }),
gio5f2f1002025-03-20 18:38:48 +040095 });
96 portForm.reset();
97 }, [data, portForm]);
98 useEffect(() => {
99 const sub = form.watch((value: DeepPartial<z.infer<typeof schema>>, { name, type }: { name?: keyof z.infer<typeof schema> | undefined, type?: EventType | undefined }) => {
100 console.log({ name, type });
101 if (type !== "change") {
102 return;
103 }
104 switch (name) {
105 case "name":
106 if (!value.name) {
107 break;
108 }
109 store.updateNodeData<"app">(id, {
110 label: value.name,
111 });
112 break;
113 case "type":
114 if (!value.type) {
115 break;
116 }
117 store.updateNodeData<"app">(id, {
118 type: value.type,
119 })
120 break;
121 }
122 });
123 return () => sub.unsubscribe();
124 }, [form, store]);
125 const focus = useCallback((field: any, name: string) => {
126 return (e: HTMLElement | null) => {
127 field.ref(e);
128 if (e != null && name === data.activeField) {
129 console.log(e);
130 e.focus();
131 store.updateNodeData(id, {
132 activeField: undefined,
133 });
134 }
135 }
136 }, [data, store]);
137 const [typeProps, setTypeProps] = useState({});
138 useEffect(() => {
139 if (data.activeField === "type") {
140 setTypeProps({
141 open: true,
142 onOpenChange: () => store.updateNodeData(id, { activeField: undefined }),
143 });
144 } else {
145 setTypeProps({});
146 }
147 }, [store, data, setTypeProps]);
148 const editAlias = useCallback((e: BoundEnvVar) => {
149 return () => {
150 store.updateNodeData(id, {
151 ...data,
152 envVars: data.envVars!.map((o) => {
153 if (o.id !== e.id) {
154 return o;
155 } else return {
156 ...o,
157 isEditting: true,
158 }
159 }),
160 });
161 };
162 }, [id, data, store]);
163 const saveAlias = (e: BoundEnvVar, value: string, store: AppState) => {
164 store.updateNodeData(id, {
165 ...data,
166 envVars: data.envVars!.map((o) => {
167 if (o.id !== e.id) {
168 return o;
169 }
170 if (value) {
171 return {
172 ...o,
173 isEditting: false,
174 alias: value.toUpperCase(),
175 }
176 }
177 console.log(o);
178 if ("alias" in o) {
179 const { alias: tmp, ...rest } = o;
180 console.log(rest);
181 return {
182 ...rest,
183 isEditting: false,
184 };
185 }
186 return {
187 ...o,
188 isEditting: false,
189 };
190 }),
191 });
192 };
193 const saveAliasOnEnter = useCallback((e: BoundEnvVar) => {
194 return (event: KeyboardEvent<HTMLInputElement>) => {
195 if (event.key === "Enter") {
196 event.preventDefault();
197 saveAlias(e, event.currentTarget.value, store);
198 }
199 }
200 }, [id, data, store]);
201 const saveAliasOnBlur = useCallback((e: BoundEnvVar) => {
202 return (event: FocusEvent<HTMLInputElement>) => {
203 saveAlias(e, event.currentTarget.value, store);
204 }
205 }, [id, data, store]);
giob41ecae2025-04-24 08:46:50 +0000206 const removePort = useCallback((portId: string) => {
gioaba9a962025-04-25 14:19:40 +0000207 // TODO(gio): this is ugly
208 const tcpRemoved = new Set<string>();
209 console.log(store.edges);
giob41ecae2025-04-24 08:46:50 +0000210 store.setEdges(store.edges.filter((e) => {
211 if (e.source !== id || e.sourceHandle !== "ports") {
212 return true;
213 }
gioaba9a962025-04-25 14:19:40 +0000214 const tn = store.nodes.find((n) => n.id == e.target)!;
giob41ecae2025-04-24 08:46:50 +0000215 if (e.targetHandle === "https") {
gioaba9a962025-04-25 14:19:40 +0000216 const t = tn as GatewayHttpsNode;
217 if (t.data.https?.serviceId === id && t.data.https.portId === portId) {
218 return false;
219 }
220 }
221 if (e.targetHandle === "tcp") {
222 const t = tn as GatewayTCPNode;
223 if (tcpRemoved.has(t.id)) {
224 return true;
225 }
226 if (t.data.exposed.find((e) => e.serviceId === id && e.portId === portId)) {
227 console.log(11111, e);
228 tcpRemoved.add(t.id);
229 return false;
230 }
giob41ecae2025-04-24 08:46:50 +0000231 }
232 if (e.targetHandle === "env_var") {
giob41ecae2025-04-24 08:46:50 +0000233 if (tn && (tn.data.envVars || []).find((ev) => ev.source === id && "portId" in ev && ev.portId === portId)) {
234 return false;
235 }
236 }
237 return true;
238 }));
239 store.nodes.filter((n) => n.type === "gateway-https" && n.data.https && n.data.https.serviceId === id && n.data.https.portId === portId).forEach((n) => {
240 store.updateNodeData<"gateway-https">(n.id, {
241 https: undefined,
242 });
243 });
gioaba9a962025-04-25 14:19:40 +0000244 store.nodes.filter((n) => n.type === "gateway-tcp").forEach((n) => {
245 const filtered = n.data.exposed.filter((e) => {
246 if (e.serviceId === id && e.portId === portId) {
247 return false;
248 } else {
249 return true;
250 }
251 })
252 if (filtered.length != n.data.exposed.length) {
253 store.updateNodeData<"gateway-tcp">(n.id, {
254 exposed: filtered,
255 });
256 }
257 });
giob41ecae2025-04-24 08:46:50 +0000258 store.nodes.filter((n) => n.type === "app" && n.data.envVars).forEach((n) => {
259 store.updateNodeData<"app">(n.id, {
260 envVars: n.data.envVars.filter((ev) => {
261 if (ev.source === id && "portId" in ev && ev.portId === portId) {
262 return false;
263 }
264 return true;
265 })
266 });
gioaba9a962025-04-25 14:19:40 +0000267 });
giob41ecae2025-04-24 08:46:50 +0000268 store.updateNodeData<"app">(id, {
269 ports: (data.ports || []).filter((p) => p.id !== portId),
270 envVars: (data.envVars || []).filter((ev) => !(ev.source === null && "portId" in ev && ev.portId === portId)),
271 });
272 }, [id, data, store]);
gio5f2f1002025-03-20 18:38:48 +0400273 return (
274 <>
275 <Form {...form}>
276 <form>
277 <FormField
278 control={form.control}
279 name="name"
280 render={({ field }) => (
281 <FormItem>
282 <FormControl>
283 <Input placeholder="name" className="border border-black" {...field} ref={focus(field, "name")} />
284 </FormControl>
285 <FormMessage />
286 </FormItem>
287 )}
288 />
289 <FormField
290 control={form.control}
291 name="type"
292 render={({ field }) => (
293 <FormItem>
294 <Select onValueChange={field.onChange} defaultValue={field.value} {...typeProps}>
295 <FormControl>
296 <SelectTrigger>
297 <SelectValue placeholder="Runtime" />
298 </SelectTrigger>
299 </FormControl>
300 <SelectContent>
301 {ServiceTypes.map((t) => (
302 <SelectItem key={t} value={t}>{t}</SelectItem>
303 ))}
304 </SelectContent>
305 </Select>
306 <FormMessage />
307 </FormItem>
308 )}
309 />
310 </form>
311 </Form>
312 Ports
313 <ul>
giob41ecae2025-04-24 08:46:50 +0000314 {data && data.ports && data.ports.map((p) => (<li key={p.id}><Button size={"icon"} variant={"ghost"} onClick={() => removePort(p.id)}><XIcon /></Button> {p.name} - {p.value}</li>))}
gio5f2f1002025-03-20 18:38:48 +0400315 </ul>
316 <Form {...portForm}>
317 <form className="flex flex-row space-x-1" onSubmit={portForm.handleSubmit(onSubmit)}>
318 <FormField
319 control={portForm.control}
320 name="name"
321 render={({ field }) => (
322 <FormItem>
323 <FormControl>
324 <Input placeholder="name" className="border border-black" {...field} />
325 </FormControl>
326 <FormMessage />
327 </FormItem>
328 )}
329 />
330 <FormField
331 control={portForm.control}
332 name="value"
333 render={({ field }) => (
334 <FormItem>
335 <FormControl>
336 <Input placeholder="value" className="border border-black" {...field} />
337 </FormControl>
338 <FormMessage />
339 </FormItem>
340 )}
341 />
342 <Button type="submit">Add Port</Button>
343 </form>
344 </Form>
345 Env Vars
346 <ul>
347 {data && data.envVars && data.envVars.map((v) => {
348 if ("name" in v) {
349 const value = "alias" in v ? v.alias : v.name;
350 if (v.isEditting) {
351 return (<li key={v.id}><Input type="text" className="border border-black" defaultValue={value} onKeyUp={saveAliasOnEnter(v)} onBlur={saveAliasOnBlur(v)} autoFocus={true} /></li>);
352 }
353 return (
354 <li key={v.id} onClick={editAlias(v)}>
355 <TooltipProvider>
356 <Tooltip>
357 <TooltipTrigger>
giob41ecae2025-04-24 08:46:50 +0000358 <Button size={"icon"} variant={"ghost"}><PencilIcon /></Button>
gio5f2f1002025-03-20 18:38:48 +0400359 {value}
360 </TooltipTrigger>
361 <TooltipContent>
362 {v.name}
363 </TooltipContent>
364 </Tooltip>
365 </TooltipProvider>
366 </li>
367 );
368 }
369 })}
370 </ul>
371 </>);
372}