blob: 3eea9396f715aa240351908045875641a84aeb2d [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";
gio91165612025-05-03 17:07:38 +000015import { Textarea } from "./ui/textarea";
gio5f2f1002025-03-20 18:38:48 +040016
17export function NodeApp(node: ServiceNode) {
18 const { id, selected } = node;
19 const isConnectablePorts = useMemo(() => nodeIsConnectable(node, "ports"), [node]);
20 const isConnectableRepository = useMemo(() => nodeIsConnectable(node, "repository"), [node]);
21 return (
gio1dc800a2025-04-24 17:15:43 +000022 <NodeRect id={id} selected={selected} type={node.type} state={node.data.state}>
gio5f2f1002025-03-20 18:38:48 +040023 <div style={{ padding: '10px 20px' }}>
24 {nodeLabel(node)}
25 <Handle
26 id="repository"
27 type={"target"}
28 position={Position.Left}
29 isConnectableStart={isConnectableRepository}
30 isConnectableEnd={isConnectableRepository}
31 isConnectable={isConnectableRepository}
32 />
33 <Handle
34 id="ports"
35 type={"source"}
36 position={Position.Top}
37 isConnectableStart={isConnectablePorts}
38 isConnectableEnd={isConnectablePorts}
39 isConnectable={isConnectablePorts}
40 />
41 <Handle
42 id="env_var"
43 type={"target"}
44 position={Position.Bottom}
45 isConnectableStart={true}
46 isConnectableEnd={true}
47 isConnectable={true}
48 />
49 </div>
50 </NodeRect>
51 );
52}
53
54const schema = z.object({
55 name: z.string().min(1, "requried"),
56 type: z.enum(ServiceTypes),
57});
58
59const portSchema = z.object({
60 name: z.string().min(1, "required"),
61 value: z.coerce.number().gt(0, "can not be negative"),
62});
63
64export function NodeAppDetails({ id, data }: ServiceNode) {
65 const store = useStateStore();
66 const form = useForm<z.infer<typeof schema>>({
67 resolver: zodResolver(schema),
68 mode: "onChange",
69 defaultValues: {
70 name: data.label,
71 type: data.type,
72 }
73 });
74 const portForm = useForm<z.infer<typeof portSchema>>({
75 resolver: zodResolver(portSchema),
76 mode: "onSubmit",
77 defaultValues: {
78 name: "",
79 value: 0,
80 }
81 });
82 const onSubmit = useCallback((values: z.infer<typeof portSchema>) => {
giob41ecae2025-04-24 08:46:50 +000083 const portId = uuidv4();
gio5f2f1002025-03-20 18:38:48 +040084 store.updateNodeData<"app">(id, {
85 ports: (data.ports || []).concat({
giob41ecae2025-04-24 08:46:50 +000086 id: portId,
gio5f2f1002025-03-20 18:38:48 +040087 name: values.name,
88 value: values.value,
gio355883e2025-04-23 14:10:51 +000089 }),
90 envVars: (data.envVars || []).concat({
91 id: uuidv4(),
92 source: null,
giob41ecae2025-04-24 08:46:50 +000093 portId,
gio355883e2025-04-23 14:10:51 +000094 name: `DODO_PORT_${values.name.toUpperCase()}`,
95 }),
gio5f2f1002025-03-20 18:38:48 +040096 });
97 portForm.reset();
98 }, [data, portForm]);
99 useEffect(() => {
100 const sub = form.watch((value: DeepPartial<z.infer<typeof schema>>, { name, type }: { name?: keyof z.infer<typeof schema> | undefined, type?: EventType | undefined }) => {
101 console.log({ name, type });
102 if (type !== "change") {
103 return;
104 }
105 switch (name) {
106 case "name":
107 if (!value.name) {
108 break;
109 }
110 store.updateNodeData<"app">(id, {
111 label: value.name,
112 });
113 break;
114 case "type":
115 if (!value.type) {
116 break;
117 }
118 store.updateNodeData<"app">(id, {
119 type: value.type,
120 })
121 break;
122 }
123 });
124 return () => sub.unsubscribe();
125 }, [form, store]);
126 const focus = useCallback((field: any, name: string) => {
127 return (e: HTMLElement | null) => {
128 field.ref(e);
129 if (e != null && name === data.activeField) {
130 console.log(e);
131 e.focus();
132 store.updateNodeData(id, {
133 activeField: undefined,
134 });
135 }
136 }
137 }, [data, store]);
138 const [typeProps, setTypeProps] = useState({});
139 useEffect(() => {
140 if (data.activeField === "type") {
141 setTypeProps({
142 open: true,
143 onOpenChange: () => store.updateNodeData(id, { activeField: undefined }),
144 });
145 } else {
146 setTypeProps({});
147 }
148 }, [store, data, setTypeProps]);
149 const editAlias = useCallback((e: BoundEnvVar) => {
150 return () => {
151 store.updateNodeData(id, {
152 ...data,
153 envVars: data.envVars!.map((o) => {
154 if (o.id !== e.id) {
155 return o;
156 } else return {
157 ...o,
158 isEditting: true,
159 }
160 }),
161 });
162 };
163 }, [id, data, store]);
164 const saveAlias = (e: BoundEnvVar, value: string, store: AppState) => {
165 store.updateNodeData(id, {
166 ...data,
167 envVars: data.envVars!.map((o) => {
168 if (o.id !== e.id) {
169 return o;
170 }
171 if (value) {
172 return {
173 ...o,
174 isEditting: false,
175 alias: value.toUpperCase(),
176 }
177 }
178 console.log(o);
179 if ("alias" in o) {
180 const { alias: tmp, ...rest } = o;
181 console.log(rest);
182 return {
183 ...rest,
184 isEditting: false,
185 };
186 }
187 return {
188 ...o,
189 isEditting: false,
190 };
191 }),
192 });
193 };
194 const saveAliasOnEnter = useCallback((e: BoundEnvVar) => {
195 return (event: KeyboardEvent<HTMLInputElement>) => {
196 if (event.key === "Enter") {
197 event.preventDefault();
198 saveAlias(e, event.currentTarget.value, store);
199 }
200 }
201 }, [id, data, store]);
202 const saveAliasOnBlur = useCallback((e: BoundEnvVar) => {
203 return (event: FocusEvent<HTMLInputElement>) => {
204 saveAlias(e, event.currentTarget.value, store);
205 }
206 }, [id, data, store]);
giob41ecae2025-04-24 08:46:50 +0000207 const removePort = useCallback((portId: string) => {
gioaba9a962025-04-25 14:19:40 +0000208 // TODO(gio): this is ugly
209 const tcpRemoved = new Set<string>();
210 console.log(store.edges);
giob41ecae2025-04-24 08:46:50 +0000211 store.setEdges(store.edges.filter((e) => {
212 if (e.source !== id || e.sourceHandle !== "ports") {
213 return true;
214 }
gioaba9a962025-04-25 14:19:40 +0000215 const tn = store.nodes.find((n) => n.id == e.target)!;
giob41ecae2025-04-24 08:46:50 +0000216 if (e.targetHandle === "https") {
gioaba9a962025-04-25 14:19:40 +0000217 const t = tn as GatewayHttpsNode;
218 if (t.data.https?.serviceId === id && t.data.https.portId === portId) {
219 return false;
220 }
221 }
222 if (e.targetHandle === "tcp") {
223 const t = tn as GatewayTCPNode;
224 if (tcpRemoved.has(t.id)) {
225 return true;
226 }
227 if (t.data.exposed.find((e) => e.serviceId === id && e.portId === portId)) {
228 console.log(11111, e);
229 tcpRemoved.add(t.id);
230 return false;
231 }
giob41ecae2025-04-24 08:46:50 +0000232 }
233 if (e.targetHandle === "env_var") {
giob41ecae2025-04-24 08:46:50 +0000234 if (tn && (tn.data.envVars || []).find((ev) => ev.source === id && "portId" in ev && ev.portId === portId)) {
235 return false;
236 }
237 }
238 return true;
239 }));
240 store.nodes.filter((n) => n.type === "gateway-https" && n.data.https && n.data.https.serviceId === id && n.data.https.portId === portId).forEach((n) => {
241 store.updateNodeData<"gateway-https">(n.id, {
242 https: undefined,
243 });
244 });
gioaba9a962025-04-25 14:19:40 +0000245 store.nodes.filter((n) => n.type === "gateway-tcp").forEach((n) => {
246 const filtered = n.data.exposed.filter((e) => {
247 if (e.serviceId === id && e.portId === portId) {
248 return false;
249 } else {
250 return true;
251 }
252 })
253 if (filtered.length != n.data.exposed.length) {
254 store.updateNodeData<"gateway-tcp">(n.id, {
255 exposed: filtered,
256 });
257 }
258 });
giob41ecae2025-04-24 08:46:50 +0000259 store.nodes.filter((n) => n.type === "app" && n.data.envVars).forEach((n) => {
260 store.updateNodeData<"app">(n.id, {
261 envVars: n.data.envVars.filter((ev) => {
262 if (ev.source === id && "portId" in ev && ev.portId === portId) {
263 return false;
264 }
265 return true;
266 })
267 });
gioaba9a962025-04-25 14:19:40 +0000268 });
giob41ecae2025-04-24 08:46:50 +0000269 store.updateNodeData<"app">(id, {
270 ports: (data.ports || []).filter((p) => p.id !== portId),
271 envVars: (data.envVars || []).filter((ev) => !(ev.source === null && "portId" in ev && ev.portId === portId)),
272 });
273 }, [id, data, store]);
gio91165612025-05-03 17:07:38 +0000274 const setPreBuildCommands = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
275 store.updateNodeData<"app">(id, {
276 preBuildCommands: e.currentTarget.value,
277 });
278 }, [id, store]);
gio5f2f1002025-03-20 18:38:48 +0400279 return (
280 <>
281 <Form {...form}>
282 <form>
283 <FormField
284 control={form.control}
285 name="name"
286 render={({ field }) => (
287 <FormItem>
288 <FormControl>
289 <Input placeholder="name" className="border border-black" {...field} ref={focus(field, "name")} />
290 </FormControl>
291 <FormMessage />
292 </FormItem>
293 )}
294 />
295 <FormField
296 control={form.control}
297 name="type"
298 render={({ field }) => (
299 <FormItem>
300 <Select onValueChange={field.onChange} defaultValue={field.value} {...typeProps}>
301 <FormControl>
302 <SelectTrigger>
303 <SelectValue placeholder="Runtime" />
304 </SelectTrigger>
305 </FormControl>
306 <SelectContent>
307 {ServiceTypes.map((t) => (
308 <SelectItem key={t} value={t}>{t}</SelectItem>
309 ))}
310 </SelectContent>
311 </Select>
312 <FormMessage />
313 </FormItem>
314 )}
315 />
316 </form>
317 </Form>
318 Ports
319 <ul>
giob41ecae2025-04-24 08:46:50 +0000320 {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 +0400321 </ul>
322 <Form {...portForm}>
323 <form className="flex flex-row space-x-1" onSubmit={portForm.handleSubmit(onSubmit)}>
324 <FormField
325 control={portForm.control}
326 name="name"
327 render={({ field }) => (
328 <FormItem>
329 <FormControl>
330 <Input placeholder="name" className="border border-black" {...field} />
331 </FormControl>
332 <FormMessage />
333 </FormItem>
334 )}
335 />
336 <FormField
337 control={portForm.control}
338 name="value"
339 render={({ field }) => (
340 <FormItem>
341 <FormControl>
342 <Input placeholder="value" className="border border-black" {...field} />
343 </FormControl>
344 <FormMessage />
345 </FormItem>
346 )}
347 />
348 <Button type="submit">Add Port</Button>
349 </form>
350 </Form>
351 Env Vars
352 <ul>
353 {data && data.envVars && data.envVars.map((v) => {
354 if ("name" in v) {
355 const value = "alias" in v ? v.alias : v.name;
356 if (v.isEditting) {
357 return (<li key={v.id}><Input type="text" className="border border-black" defaultValue={value} onKeyUp={saveAliasOnEnter(v)} onBlur={saveAliasOnBlur(v)} autoFocus={true} /></li>);
358 }
359 return (
360 <li key={v.id} onClick={editAlias(v)}>
361 <TooltipProvider>
362 <Tooltip>
363 <TooltipTrigger>
giob41ecae2025-04-24 08:46:50 +0000364 <Button size={"icon"} variant={"ghost"}><PencilIcon /></Button>
gio5f2f1002025-03-20 18:38:48 +0400365 {value}
366 </TooltipTrigger>
367 <TooltipContent>
368 {v.name}
369 </TooltipContent>
370 </Tooltip>
371 </TooltipProvider>
372 </li>
373 );
374 }
375 })}
376 </ul>
gio91165612025-05-03 17:07:38 +0000377 Pre-Build Commands
378 <Textarea placeholder="new line separated list of commands to run before running the service" value={data.preBuildCommands} onChange={setPreBuildCommands} />
gio5f2f1002025-03-20 18:38:48 +0400379 </>);
380}