blob: 6effb26a2c48680937060f87101a5e465e45121f [file] [log] [blame]
gioaba9a962025-04-25 14:19:40 +00001import { v4 as uuidv4 } from "uuid";
gio5f2f1002025-03-20 18:38:48 +04002import { useStateStore, AppNode, GatewayHttpsNode, ServiceNode, nodeLabel, useEnv, nodeIsConnectable } from '@/lib/state';
3import { Handle, Position, useNodes } from '@xyflow/react';
4import { NodeRect } from './node-rect';
gio9b2d4962025-05-07 04:59:39 +00005import { useCallback, useEffect, useMemo } from 'react';
gio5f2f1002025-03-20 18:38:48 +04006import { z } from "zod";
7import { zodResolver } from "@hookform/resolvers/zod";
8import { 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 { Checkbox } from "./ui/checkbox";
13import { Label } from "./ui/label";
14import { Button } from "./ui/button";
15import { XIcon } from "lucide-react";
gio5f2f1002025-03-20 18:38:48 +040016
17const schema = z.object({
18 network: z.string().min(1, "reqired"),
19 subdomain: z.string().min(1, "required"),
20});
21
22const connectedToSchema = z.object({
23 id: z.string(),
24 portId: z.string(),
25});
26
gio9b2d4962025-05-07 04:59:39 +000027const authEnabledSchema = z.object({
28 enabled: z.boolean(),
29});
30
31const authGroupSchema = z.object({
32 group: z.string(),
33});
34
35const authNoAuthPatternSchema = z.object({
36 noAuthPathPattern: z.string(),
37});
38
gio5f2f1002025-03-20 18:38:48 +040039export function NodeGatewayHttps(node: GatewayHttpsNode) {
40 const { id, selected } = node;
gioaba9a962025-04-25 14:19:40 +000041 const isConnectableNetwork = useMemo(() => nodeIsConnectable(node, "subdomain"), [node]);
gio5f2f1002025-03-20 18:38:48 +040042 const isConnectable = useMemo(() => nodeIsConnectable(node, "https"), [node]);
43 return (
gio1dc800a2025-04-24 17:15:43 +000044 <NodeRect id={id} selected={selected} type={node.type} state={node.data.state}>
gio5f2f1002025-03-20 18:38:48 +040045 {nodeLabel(node)}
46 <Handle
gioaba9a962025-04-25 14:19:40 +000047 type={"source"}
gio9b2d4962025-05-07 04:59:39 +000048 id="subdomain"
gioaba9a962025-04-25 14:19:40 +000049 position={Position.Top}
50 isConnectable={isConnectableNetwork}
gio9b2d4962025-05-07 04:59:39 +000051 isConnectableStart={isConnectableNetwork}
52 isConnectableEnd={isConnectableNetwork}
gioaba9a962025-04-25 14:19:40 +000053 />
54 <Handle
gio5f2f1002025-03-20 18:38:48 +040055 type={"target"}
gio9b2d4962025-05-07 04:59:39 +000056 id="https"
gio5f2f1002025-03-20 18:38:48 +040057 position={Position.Bottom}
58 isConnectable={isConnectable}
gio9b2d4962025-05-07 04:59:39 +000059 isConnectableStart={isConnectable}
60 isConnectableEnd={isConnectable}
gio5f2f1002025-03-20 18:38:48 +040061 />
62 </NodeRect>
63 );
64}
65
66export function NodeGatewayHttpsDetails({ id, data }: GatewayHttpsNode) {
67 const store = useStateStore();
68 const env = useEnv();
69 const form = useForm<z.infer<typeof schema>>({
70 resolver: zodResolver(schema),
71 mode: "onChange",
72 defaultValues: {
giof96ffb82025-04-24 09:31:05 +000073 network: data.network,
74 subdomain: data.subdomain,
gio5f2f1002025-03-20 18:38:48 +040075 },
76 });
77 useEffect(() => {
78 const sub = form.watch((value: DeepPartial<z.infer<typeof schema>>, { name }: { name?: keyof z.infer<typeof schema> | undefined, type?: EventType | undefined }) => {
79 if (name === "network") {
gioaba9a962025-04-25 14:19:40 +000080 let edges = store.edges;
81 if (data.network !== undefined) {
82 edges = edges.filter((e) => {
gioaba9a962025-04-25 14:19:40 +000083 if (e.source === id && e.sourceHandle === "subdomain" && e.target === data.network && e.targetHandle === "subdomain") {
84 return false;
85 } else {
86 return true;
87 }
88 });
gio5f2f1002025-03-20 18:38:48 +040089 }
gioaba9a962025-04-25 14:19:40 +000090 if (value.network !== undefined) {
91 edges = edges.concat({
92 id: uuidv4(),
93 source: id,
94 sourceHandle: "subdomain",
95 target: value.network,
96 targetHandle: "subdomain",
97 });
98 }
99 store.setEdges(edges);
100 store.updateNodeData<"gateway-https">(id, { network: value.network });
gio5f2f1002025-03-20 18:38:48 +0400101 } else if (name === "subdomain") {
102 store.updateNodeData<"gateway-https">(id, { subdomain: value.subdomain });
103 }
104 });
105 return () => sub.unsubscribe();
gioaba9a962025-04-25 14:19:40 +0000106 }, [id, data, form, store]);
gio5f2f1002025-03-20 18:38:48 +0400107 const connectedToForm = useForm<z.infer<typeof connectedToSchema>>({
108 resolver: zodResolver(connectedToSchema),
109 mode: "onChange",
110 defaultValues: {
111 id: data.https?.serviceId,
112 portId: data.https?.portId,
113 },
114 });
115 useEffect(() => {
116 connectedToForm.reset({
117 id: data.https?.serviceId,
118 portId: data.https?.portId,
119 });
120 }, [connectedToForm, data]);
121 const nodes = useNodes<AppNode>();
122 const selected = useMemo(() => {
123 if (data !== undefined && data.https !== undefined) {
124 const https = data.https;
125 return nodes.find((n) => n.id === https.serviceId)! as ServiceNode;
126 }
127 return null;
gio6cf8c272025-05-08 09:01:38 +0000128 }, [data, nodes]);
gio5f2f1002025-03-20 18:38:48 +0400129 const selectable = useMemo(() => {
130 return nodes.filter((n) => {
131 if (n.id === id) {
132 return false;
133 }
134 if (selected !== null && selected.id === id) {
135 return true;
136 }
137 if (n.type !== "app") {
138 return false;
139 }
140 return n.data && n.data.ports && n.data.ports.length > 0;
141 })
gio6cf8c272025-05-08 09:01:38 +0000142 }, [id, nodes, selected]);
gio5f2f1002025-03-20 18:38:48 +0400143 useEffect(() => {
144 const sub = connectedToForm.watch((value: DeepPartial<z.infer<typeof connectedToSchema>>, { name, type }: { name?: keyof z.infer<typeof connectedToSchema> | undefined, type?: EventType | undefined }) => {
gio5f2f1002025-03-20 18:38:48 +0400145 if (type !== "change") {
146 return;
147 }
148 switch (name) {
gio6cf8c272025-05-08 09:01:38 +0000149 case "id": {
gio5f2f1002025-03-20 18:38:48 +0400150 if (!value.id) {
151 break;
152 }
153 const current = store.edges.filter((e) => e.target === id);
154 const cid = current[0] ? current[0].id : undefined;
155 store.replaceEdge({
156 source: value.id,
157 sourceHandle: "ports",
158 target: id,
159 targetHandle: "https",
160 }, cid);
161 break;
gio6cf8c272025-05-08 09:01:38 +0000162 }
gio5f2f1002025-03-20 18:38:48 +0400163 case "portId":
164 store.updateNodeData<"gateway-https">(id, {
165 https: {
166 serviceId: value.id,
167 portId: value.portId,
168 }
169 });
170 break;
171 }
172 });
173 return () => sub.unsubscribe();
gio6cf8c272025-05-08 09:01:38 +0000174 }, [id, connectedToForm, store, selectable]);
gio9b2d4962025-05-07 04:59:39 +0000175 const authEnabledForm = useForm<z.infer<typeof authEnabledSchema>>({
176 resolver: zodResolver(authEnabledSchema),
177 mode: "onChange",
178 defaultValues: {
179 enabled: data.auth ? data.auth.enabled : false,
180 },
181 });
182 const authGroupForm = useForm<z.infer<typeof authGroupSchema>>({
183 resolver: zodResolver(authGroupSchema),
184 mode: "onSubmit",
185 defaultValues: {
186 group: "",
187 },
188 }); const authNoAuthPatternFrom = useForm<z.infer<typeof authNoAuthPatternSchema>>({
189 resolver: zodResolver(authNoAuthPatternSchema),
190 mode: "onChange",
191 defaultValues: {
192 noAuthPathPattern: "",
193 },
194 });
195 useEffect(() => {
196 const sub = authEnabledForm.watch((value, { name }) => {
197 if (name === "enabled") {
198 store.updateNodeData<"gateway-https">(id, {
199 auth: {
200 ...data.auth,
201 enabled: value.enabled,
202 }
203 })
204 }
205 });
206 return () => sub.unsubscribe();
207 }, [id, data, authEnabledForm, store]);
208 const removeGroup = useCallback((group: string) => {
209 const groups = data?.auth?.groups || [];
210 store.updateNodeData<"gateway-https">(id, {
211 auth: {
212 ...data.auth,
213 groups: groups.filter((g) => g !== group),
214 },
215 });
216 return true;
217 }, [id, data, store]);
218 const onGroupSubmit = useCallback((values: z.infer<typeof authGroupSchema>) => {
219 const groups = data.auth?.groups || [];
220 groups.push(values.group)
221 store.updateNodeData<"gateway-https">(id, {
222 auth: {
223 ...data.auth,
224 groups,
225 },
226 });
227 authGroupForm.reset();
gio6cf8c272025-05-08 09:01:38 +0000228 }, [id, data, store, authGroupForm]);
gio9b2d4962025-05-07 04:59:39 +0000229 const removeNoAuthPathPattern = useCallback((path: string) => {
230 const noAuthPathPatterns = data?.auth?.noAuthPathPatterns || [];
231 store.updateNodeData<"gateway-https">(id, {
232 auth: {
233 ...data.auth,
234 noAuthPathPatterns: noAuthPathPatterns.filter((p) => p !== path),
235 },
236 });
237 return true;
238 }, [id, data, store]);
239 const onNoAuthPathPatternSubmit = useCallback((values: z.infer<typeof authNoAuthPatternSchema>) => {
240 const noAuthPathPatterns = data.auth?.noAuthPathPatterns || [];
241 noAuthPathPatterns.push(values.noAuthPathPattern)
242 store.updateNodeData<"gateway-https">(id, {
243 auth: {
244 ...data.auth,
245 noAuthPathPatterns,
246 },
247 });
248 authNoAuthPatternFrom.reset();
gio6cf8c272025-05-08 09:01:38 +0000249 }, [id, data, store, authNoAuthPatternFrom]);
gio5f2f1002025-03-20 18:38:48 +0400250 return (
251 <>
252 <Form {...form}>
253 <form className="space-y-2">
gio9b2d4962025-05-07 04:59:39 +0000254 <FormField
gio5f2f1002025-03-20 18:38:48 +0400255 control={form.control}
256 name="network"
257 render={({ field }) => (
258 <FormItem>
259 <Select onValueChange={field.onChange} defaultValue={field.value}>
260 <FormControl>
261 <SelectTrigger>
262 <SelectValue placeholder="Network" />
263 </SelectTrigger>
264 </FormControl>
265 <SelectContent>
266 {env.networks.map((n) => (
267 <SelectItem key={n.name} value={n.domain}>{`${n.name} - ${n.domain}`}</SelectItem>
268 ))}
269 </SelectContent>
270 </Select>
271 <FormMessage />
272 </FormItem>
273 )}
274 />
gio9b2d4962025-05-07 04:59:39 +0000275 <FormField
gio5f2f1002025-03-20 18:38:48 +0400276 control={form.control}
277 name="subdomain"
278 render={({ field }) => (
279 <FormItem>
280 <FormControl>
281 <Input placeholder="subdomain" className="border border-black" {...field} />
282 </FormControl>
283 <FormMessage />
284 </FormItem>
285 )}
286 />
287 </form>
288 </Form>
289 <Form {...connectedToForm}>
290 <form className="space-y-2">
gio9b2d4962025-05-07 04:59:39 +0000291 <FormField
gio5f2f1002025-03-20 18:38:48 +0400292 control={connectedToForm.control}
293 name="id"
294 render={({ field }) => (
295 <FormItem>
296 <Select onValueChange={field.onChange} defaultValue={field.value}>
297 <FormControl>
298 <SelectTrigger>
299 <SelectValue placeholder="Service" />
300 </SelectTrigger>
301 </FormControl>
302 <SelectContent>
303 {selectable.map((n) => (
304 <SelectItem key={n.id} value={n.id}>{nodeLabel(n)}</SelectItem>
305 ))}
306 </SelectContent>
307 </Select>
308 <FormMessage />
309 </FormItem>
310 )}
311 />
gio9b2d4962025-05-07 04:59:39 +0000312 <FormField
gio5f2f1002025-03-20 18:38:48 +0400313 control={connectedToForm.control}
314 name="portId"
315 render={({ field }) => (
316 <FormItem>
317 <Select onValueChange={field.onChange} defaultValue={field.value}>
318 <FormControl>
319 <SelectTrigger>
320 <SelectValue placeholder="Port" />
321 </SelectTrigger>
322 </FormControl>
323 <SelectContent>
324 {selected && selected.data.ports.map((p) => (
325 <SelectItem key={p.id} value={p.id}>{p.name} - {p.value}</SelectItem>
326 ))}
327 </SelectContent>
328 </Select>
329 <FormMessage />
gio9b2d4962025-05-07 04:59:39 +0000330 </FormItem>
gio5f2f1002025-03-20 18:38:48 +0400331 )}
332 />
333 </form>
334 </Form>
gio9b2d4962025-05-07 04:59:39 +0000335 Auth
336 <Form {...authEnabledForm}>
337 <form className="space-y-2">
338 <FormField
339 control={authEnabledForm.control}
340 name="enabled"
341 render={({ field }) => (
342 <FormItem>
343 <Checkbox id="authEnabled" onCheckedChange={field.onChange} checked={field.value} />
344 <Label htmlFor="authEnabled">Enabled</Label>
345 <FormMessage />
346 </FormItem>
347 )}
348 />
349 </form>
350 </Form>
351 {data && data.auth && data.auth.enabled ? (
352 <>
353 Authorized Groups
354 <ul>
355 {(data.auth.groups || []).map((p) => (<li key={p}><Button size={"icon"} variant={"ghost"} onClick={() => removeGroup(p)}><XIcon /></Button> {p}</li>))}
356 </ul>
357 <Form {...authGroupForm}>
358 <form className="flex flex-row space-x-1" onSubmit={authGroupForm.handleSubmit(onGroupSubmit)}>
359 <FormField
360 control={authGroupForm.control}
361 name="group"
362 render={({ field }) => (
363 <FormItem>
364 <FormControl>
365 <Input placeholder="group" className="border border-black" {...field} />
366 </FormControl>
367 <FormMessage />
368 </FormItem>
369 )}
370 />
371 <Button type="submit">Add Group</Button>
372 </form>
373 </Form>
374 Auth optional path patterns
375 <ul>
376 {(data.auth.noAuthPathPatterns || []).map((p) => (<li key={p}><Button size={"icon"} variant={"ghost"} onClick={() => removeNoAuthPathPattern(p)}><XIcon /></Button> {p}</li>))}
377 </ul>
378 <Form {...authNoAuthPatternFrom}>
379 <form className="flex flex-row space-x-1" onSubmit={authNoAuthPatternFrom.handleSubmit(onNoAuthPathPatternSubmit)}>
380 <FormField
381 control={authNoAuthPatternFrom.control}
382 name="noAuthPathPattern"
383 render={({ field }) => (
384 <FormItem>
385 <FormControl>
386 <Input placeholder="group" className="border border-black" {...field} />
387 </FormControl>
388 <FormMessage />
389 </FormItem>
390 )}
391 />
392 <Button type="submit">Add</Button>
393 </form>
394 </Form>
395 </>
396 ) : (<></>)}
gio5f2f1002025-03-20 18:38:48 +0400397 </>
398 );
399}