blob: ebd1f6175a7902242b60c154dd1b3aa75a0362e2 [file] [log] [blame]
gioaba9a962025-04-25 14:19:40 +00001import { v4 as uuidv4 } from "uuid";
gio69148322025-06-19 23:16:12 +04002import { useStateStore, nodeLabel, useEnv, nodeIsConnectable } from "@/lib/state";
giod0026612025-05-08 13:00:36 +00003import { Handle, Position, useNodes } from "@xyflow/react";
4import { NodeRect } from "./node-rect";
5import { useCallback, useEffect, useMemo } from "react";
gio5f2f1002025-03-20 18:38:48 +04006import { z } from "zod";
7import { zodResolver } from "@hookform/resolvers/zod";
giod0026612025-05-08 13:00:36 +00008import { useForm, EventType, DeepPartial } from "react-hook-form";
9import { Form, FormControl, FormField, FormItem, FormMessage } from "./ui/form";
10import { Input } from "./ui/input";
11import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
gio9b2d4962025-05-07 04:59:39 +000012import { Label } from "./ui/label";
13import { Button } from "./ui/button";
14import { XIcon } from "lucide-react";
gio3d0bf032025-06-05 06:57:26 +000015import { Switch } from "./ui/switch";
gio3fb133d2025-06-13 07:20:24 +000016import { NodeDetailsProps } from "@/lib/types";
gio69148322025-06-19 23:16:12 +040017import { AppNode, GatewayHttpsNode, ServiceNode } from "config";
gio5f2f1002025-03-20 18:38:48 +040018
19const schema = z.object({
giod0026612025-05-08 13:00:36 +000020 network: z.string().min(1, "reqired"),
21 subdomain: z.string().min(1, "required"),
gio5f2f1002025-03-20 18:38:48 +040022});
23
24const connectedToSchema = z.object({
giod0026612025-05-08 13:00:36 +000025 id: z.string(),
26 portId: z.string(),
gio5f2f1002025-03-20 18:38:48 +040027});
28
gio9b2d4962025-05-07 04:59:39 +000029const authEnabledSchema = z.object({
giod0026612025-05-08 13:00:36 +000030 enabled: z.boolean(),
gio9b2d4962025-05-07 04:59:39 +000031});
32
33const authGroupSchema = z.object({
giod0026612025-05-08 13:00:36 +000034 group: z.string(),
gio9b2d4962025-05-07 04:59:39 +000035});
36
37const authNoAuthPatternSchema = z.object({
giod0026612025-05-08 13:00:36 +000038 noAuthPathPattern: z.string(),
gio9b2d4962025-05-07 04:59:39 +000039});
40
gio5f2f1002025-03-20 18:38:48 +040041export function NodeGatewayHttps(node: GatewayHttpsNode) {
giod0026612025-05-08 13:00:36 +000042 const { id, selected } = node;
43 const isConnectableNetwork = useMemo(() => nodeIsConnectable(node, "subdomain"), [node]);
44 const isConnectable = useMemo(() => nodeIsConnectable(node, "https"), [node]);
45 return (
gio69148322025-06-19 23:16:12 +040046 <NodeRect id={id} selected={selected} node={node} state={node.data.state}>
giod0026612025-05-08 13:00:36 +000047 {nodeLabel(node)}
48 <Handle
49 type={"source"}
50 id="subdomain"
51 position={Position.Top}
52 isConnectable={isConnectableNetwork}
53 isConnectableStart={isConnectableNetwork}
54 isConnectableEnd={isConnectableNetwork}
55 />
56 <Handle
57 type={"target"}
58 id="https"
59 position={Position.Bottom}
60 isConnectable={isConnectable}
61 isConnectableStart={isConnectable}
62 isConnectableEnd={isConnectable}
63 />
64 </NodeRect>
65 );
gio5f2f1002025-03-20 18:38:48 +040066}
67
gio3fb133d2025-06-13 07:20:24 +000068export function NodeGatewayHttpsDetails({ node, disabled }: NodeDetailsProps<GatewayHttpsNode>) {
gio08acd3a2025-06-12 12:15:30 +000069 const { id, data } = node;
giod0026612025-05-08 13:00:36 +000070 const store = useStateStore();
71 const env = useEnv();
72 const form = useForm<z.infer<typeof schema>>({
73 resolver: zodResolver(schema),
74 mode: "onChange",
75 defaultValues: {
76 network: data.network,
77 subdomain: data.subdomain,
78 },
79 });
80 useEffect(() => {
81 const sub = form.watch(
82 (
83 value: DeepPartial<z.infer<typeof schema>>,
84 { name }: { name?: keyof z.infer<typeof schema> | undefined; type?: EventType | undefined },
85 ) => {
86 if (name === "network") {
87 let edges = store.edges;
88 if (data.network !== undefined) {
89 edges = edges.filter((e) => {
90 if (
91 e.source === id &&
92 e.sourceHandle === "subdomain" &&
93 e.target === data.network &&
94 e.targetHandle === "subdomain"
95 ) {
96 return false;
97 } else {
98 return true;
99 }
100 });
101 }
102 if (value.network !== undefined) {
103 edges = edges.concat({
104 id: uuidv4(),
105 source: id,
106 sourceHandle: "subdomain",
107 target: value.network,
108 targetHandle: "subdomain",
109 });
110 }
111 store.setEdges(edges);
112 store.updateNodeData<"gateway-https">(id, { network: value.network });
113 } else if (name === "subdomain") {
114 store.updateNodeData<"gateway-https">(id, { subdomain: value.subdomain });
115 }
116 },
117 );
118 return () => sub.unsubscribe();
119 }, [id, data, form, store]);
gio6d8b71c2025-05-19 12:57:35 +0000120 const network = useMemo(() => {
121 if (data.network === undefined) {
122 return null;
123 }
124 return env.networks.find((n) => n.domain === data.network)!;
125 }, [data, env]);
giod0026612025-05-08 13:00:36 +0000126 const connectedToForm = useForm<z.infer<typeof connectedToSchema>>({
127 resolver: zodResolver(connectedToSchema),
128 mode: "onChange",
129 defaultValues: {
130 id: data.https?.serviceId,
131 portId: data.https?.portId,
132 },
133 });
134 useEffect(() => {
135 connectedToForm.reset({
136 id: data.https?.serviceId,
137 portId: data.https?.portId,
138 });
139 }, [connectedToForm, data]);
140 const nodes = useNodes<AppNode>();
141 const selected = useMemo(() => {
142 if (data !== undefined && data.https !== undefined) {
143 const https = data.https;
144 return nodes.find((n) => n.id === https.serviceId)! as ServiceNode;
145 }
146 return null;
147 }, [data, nodes]);
148 const selectable = useMemo(() => {
149 return nodes.filter((n) => {
150 if (n.id === id) {
151 return false;
152 }
153 if (selected !== null && selected.id === id) {
154 return true;
155 }
156 if (n.type !== "app") {
157 return false;
158 }
159 return n.data && n.data.ports && n.data.ports.length > 0;
160 });
161 }, [id, nodes, selected]);
162 useEffect(() => {
163 const sub = connectedToForm.watch(
164 (
165 value: DeepPartial<z.infer<typeof connectedToSchema>>,
166 {
167 name,
168 type,
169 }: { name?: keyof z.infer<typeof connectedToSchema> | undefined; type?: EventType | undefined },
170 ) => {
171 if (type !== "change") {
172 return;
173 }
174 switch (name) {
175 case "id": {
176 if (!value.id) {
177 break;
178 }
179 const current = store.edges.filter((e) => e.target === id);
180 const cid = current[0] ? current[0].id : undefined;
181 store.replaceEdge(
182 {
183 source: value.id,
184 sourceHandle: "ports",
185 target: id,
186 targetHandle: "https",
187 },
188 cid,
189 );
190 break;
191 }
192 case "portId":
193 store.updateNodeData<"gateway-https">(id, {
194 https: {
195 serviceId: value.id,
196 portId: value.portId,
197 },
198 });
199 break;
200 }
201 },
202 );
203 return () => sub.unsubscribe();
204 }, [id, connectedToForm, store, selectable]);
205 const authEnabledForm = useForm<z.infer<typeof authEnabledSchema>>({
206 resolver: zodResolver(authEnabledSchema),
207 mode: "onChange",
208 defaultValues: {
209 enabled: data.auth ? data.auth.enabled : false,
210 },
211 });
212 const authGroupForm = useForm<z.infer<typeof authGroupSchema>>({
213 resolver: zodResolver(authGroupSchema),
214 mode: "onSubmit",
215 defaultValues: {
216 group: "",
217 },
218 });
219 const authNoAuthPatternFrom = useForm<z.infer<typeof authNoAuthPatternSchema>>({
220 resolver: zodResolver(authNoAuthPatternSchema),
221 mode: "onChange",
222 defaultValues: {
223 noAuthPathPattern: "",
224 },
225 });
226 useEffect(() => {
227 const sub = authEnabledForm.watch((value, { name }) => {
228 if (name === "enabled") {
229 store.updateNodeData<"gateway-https">(id, {
230 auth: {
231 ...data.auth,
232 enabled: value.enabled,
233 },
234 });
235 }
236 });
237 return () => sub.unsubscribe();
238 }, [id, data, authEnabledForm, store]);
239 const removeGroup = useCallback(
240 (group: string) => {
241 const groups = data?.auth?.groups || [];
242 store.updateNodeData<"gateway-https">(id, {
243 auth: {
244 ...data.auth,
245 groups: groups.filter((g) => g !== group),
246 },
247 });
248 return true;
249 },
250 [id, data, store],
251 );
252 const onGroupSubmit = useCallback(
253 (values: z.infer<typeof authGroupSchema>) => {
254 const groups = data.auth?.groups || [];
255 groups.push(values.group);
256 store.updateNodeData<"gateway-https">(id, {
257 auth: {
258 ...data.auth,
259 groups,
260 },
261 });
262 authGroupForm.reset();
263 },
264 [id, data, store, authGroupForm],
265 );
266 const removeNoAuthPathPattern = useCallback(
267 (path: string) => {
268 const noAuthPathPatterns = data?.auth?.noAuthPathPatterns || [];
269 store.updateNodeData<"gateway-https">(id, {
270 auth: {
271 ...data.auth,
272 noAuthPathPatterns: noAuthPathPatterns.filter((p) => p !== path),
273 },
274 });
275 return true;
276 },
277 [id, data, store],
278 );
279 const onNoAuthPathPatternSubmit = useCallback(
280 (values: z.infer<typeof authNoAuthPatternSchema>) => {
281 const noAuthPathPatterns = data.auth?.noAuthPathPatterns || [];
282 noAuthPathPatterns.push(values.noAuthPathPattern);
283 store.updateNodeData<"gateway-https">(id, {
284 auth: {
285 ...data.auth,
286 noAuthPathPatterns,
287 },
288 });
289 authNoAuthPatternFrom.reset();
290 },
291 [id, data, store, authNoAuthPatternFrom],
292 );
293 return (
294 <>
295 <Form {...form}>
296 <form className="space-y-2">
297 <FormField
298 control={form.control}
299 name="network"
300 render={({ field }) => (
301 <FormItem>
gio48fde052025-05-14 09:48:08 +0000302 <Select
303 onValueChange={field.onChange}
304 defaultValue={field.value}
gio3ec94242025-05-16 12:46:57 +0000305 disabled={data.readonly || disabled}
gio48fde052025-05-14 09:48:08 +0000306 >
giod0026612025-05-08 13:00:36 +0000307 <FormControl>
308 <SelectTrigger>
309 <SelectValue placeholder="Network" />
310 </SelectTrigger>
311 </FormControl>
312 <SelectContent>
313 {env.networks.map((n) => (
314 <SelectItem
315 key={n.name}
316 value={n.domain}
317 >{`${n.name} - ${n.domain}`}</SelectItem>
318 ))}
319 </SelectContent>
320 </Select>
321 <FormMessage />
322 </FormItem>
323 )}
324 />
325 <FormField
326 control={form.control}
327 name="subdomain"
328 render={({ field }) => (
329 <FormItem>
330 <FormControl>
gio3ec94242025-05-16 12:46:57 +0000331 <Input placeholder="subdomain" {...field} disabled={data.readonly || disabled} />
giod0026612025-05-08 13:00:36 +0000332 </FormControl>
333 <FormMessage />
334 </FormItem>
335 )}
336 />
337 </form>
338 </Form>
339 <Form {...connectedToForm}>
340 <form className="space-y-2">
341 <FormField
342 control={connectedToForm.control}
343 name="id"
344 render={({ field }) => (
345 <FormItem>
gio48fde052025-05-14 09:48:08 +0000346 <Select
347 onValueChange={field.onChange}
348 defaultValue={field.value}
gio3ec94242025-05-16 12:46:57 +0000349 disabled={data.readonly || disabled}
gio48fde052025-05-14 09:48:08 +0000350 >
giod0026612025-05-08 13:00:36 +0000351 <FormControl>
352 <SelectTrigger>
353 <SelectValue placeholder="Service" />
354 </SelectTrigger>
355 </FormControl>
356 <SelectContent>
357 {selectable.map((n) => (
358 <SelectItem key={n.id} value={n.id}>
359 {nodeLabel(n)}
360 </SelectItem>
361 ))}
362 </SelectContent>
363 </Select>
364 <FormMessage />
365 </FormItem>
366 )}
367 />
368 <FormField
369 control={connectedToForm.control}
370 name="portId"
371 render={({ field }) => (
372 <FormItem>
gio48fde052025-05-14 09:48:08 +0000373 <Select
374 onValueChange={field.onChange}
375 defaultValue={field.value}
gio3ec94242025-05-16 12:46:57 +0000376 disabled={data.readonly || disabled}
gio48fde052025-05-14 09:48:08 +0000377 >
giod0026612025-05-08 13:00:36 +0000378 <FormControl>
379 <SelectTrigger>
380 <SelectValue placeholder="Port" />
381 </SelectTrigger>
382 </FormControl>
383 <SelectContent>
384 {selected &&
385 selected.data.ports.map((p) => (
386 <SelectItem key={p.id} value={p.id}>
387 {p.name} - {p.value}
388 </SelectItem>
389 ))}
390 </SelectContent>
391 </Select>
392 <FormMessage />
393 </FormItem>
394 )}
395 />
396 </form>
397 </Form>
gio6d8b71c2025-05-19 12:57:35 +0000398 {network?.hasAuth && (
giod0026612025-05-08 13:00:36 +0000399 <>
gio6d8b71c2025-05-19 12:57:35 +0000400 Auth
401 <Form {...authEnabledForm}>
402 <form className="space-y-2">
giod0026612025-05-08 13:00:36 +0000403 <FormField
gio6d8b71c2025-05-19 12:57:35 +0000404 control={authEnabledForm.control}
405 name="enabled"
giod0026612025-05-08 13:00:36 +0000406 render={({ field }) => (
407 <FormItem>
gio6d8b71c2025-05-19 12:57:35 +0000408 <div className="flex flex-row gap-1 items-center">
gio3d0bf032025-06-05 06:57:26 +0000409 <Switch
gio6d8b71c2025-05-19 12:57:35 +0000410 id="authEnabled"
411 onCheckedChange={field.onChange}
412 checked={field.value}
413 disabled={disabled}
414 />
415 <Label htmlFor="authEnabled">Enabled</Label>
416 </div>
giod0026612025-05-08 13:00:36 +0000417 <FormMessage />
418 </FormItem>
419 )}
420 />
giod0026612025-05-08 13:00:36 +0000421 </form>
422 </Form>
gio6d8b71c2025-05-19 12:57:35 +0000423 {data && data.auth && data.auth.enabled ? (
424 <>
425 Authorized Groups
426 <ul>
427 {(data.auth.groups || []).map((p) => (
428 <li key={p} className="flex flex-row gap-1 items-center">
429 <Button
430 size={"icon"}
431 variant={"ghost"}
432 onClick={() => removeGroup(p)}
433 disabled={disabled}
434 >
435 <XIcon />
436 </Button>
437 <div>{p}</div>
438 </li>
439 ))}
440 </ul>
441 <Form {...authGroupForm}>
442 <form
443 className="flex flex-row space-x-1"
444 onSubmit={authGroupForm.handleSubmit(onGroupSubmit)}
gio3ec94242025-05-16 12:46:57 +0000445 >
gio6d8b71c2025-05-19 12:57:35 +0000446 <FormField
447 control={authGroupForm.control}
448 name="group"
449 render={({ field }) => (
450 <FormItem>
451 <FormControl>
452 <Input placeholder="group" {...field} disabled={disabled} />
453 </FormControl>
454 <FormMessage />
455 </FormItem>
456 )}
457 />
458 <Button type="submit" disabled={disabled}>
459 Add Group
460 </Button>
461 </form>
462 </Form>
463 Auth optional path patterns
464 <ul>
465 {(data.auth.noAuthPathPatterns || []).map((p) => (
466 <li key={p} className="flex flex-row gap-1 items-center">
467 <Button
468 size={"icon"}
469 variant={"ghost"}
470 onClick={() => removeNoAuthPathPattern(p)}
471 disabled={disabled}
472 >
473 <XIcon />
474 </Button>
475 <div>{p}</div>
476 </li>
477 ))}
478 </ul>
479 <Form {...authNoAuthPatternFrom}>
480 <form
481 className="flex flex-row space-x-1"
482 onSubmit={authNoAuthPatternFrom.handleSubmit(onNoAuthPathPatternSubmit)}
483 >
484 <FormField
485 control={authNoAuthPatternFrom.control}
486 name="noAuthPathPattern"
487 render={({ field }) => (
488 <FormItem>
489 <FormControl>
490 <Input placeholder="group" {...field} disabled={disabled} />
491 </FormControl>
492 <FormMessage />
493 </FormItem>
494 )}
495 />
496 <Button type="submit" disabled={disabled}>
497 Add
498 </Button>
499 </form>
500 </Form>
501 </>
502 ) : (
503 <></>
504 )}
giod0026612025-05-08 13:00:36 +0000505 </>
giod0026612025-05-08 13:00:36 +0000506 )}
507 </>
508 );
509}