import { useEffect, useRef, useState } from 'react'; import { Link, useNavigate, useParams } from 'react-router'; import { useForm, useWatch } from 'react-hook-form'; import { Check, X } from 'lucide-react'; import { z } from 'zod'; import { zodResolver } from '@hookform/resolvers/zod'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import { Switch } from '@/components/ui/switch'; import { Separator } from '@/components/ui/separator'; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; import { Skeleton } from '@/components/ui/skeleton'; import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { Certificate } from '@/types/certificate'; import { useAuthStore } from '@/stores/authStore'; import useDebounce from '@/hooks/useDebounce'; const formSchema = z .object({ name: z.string({ required_error: '서버 이름을 입력해주세요' }).min(1, { message: '서버 이름을 입력해주세요' }), domain: z .string({ required_error: '도메인 주소를 입력해주세요' }) .regex(/^(?=.{1,253}$)(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/, { message: '올바른 도메인 주소를 입력해주세요', }), ip: z .string({ required_error: '인스턴스 IP를 입력해주세요' }) .regex(/^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)\.?\b){4}$/, { message: '올바른 IP 주소를 입력해주세요', }) .startsWith('10.16.', { message: '인스턴스 IP는 10.16.0.0/16 대역을 사용해야 합니다' }), port: z.coerce .number({ invalid_type_error: '포트 번호를 입력해주세요' }) .min(1, { message: '포트 번호를 입력해주세요' }) .max(65535, { message: '올바른 포트 번호를 입력해주세요' }), enableSSL: z.boolean(), certificateId: z.coerce.number().optional(), caching: z.boolean(), }) .refine((data) => !data.enableSSL || (data.enableSSL && data.certificateId !== undefined), { message: 'SSL 인증서를 선택해주세요', path: ['certificateId'], }); export default function RoutingEdit() { const navigate = useNavigate(); const { id } = useParams(); const { authFetch, selectedProject } = useAuthStore(); const form = useForm<z.infer<typeof formSchema>>({ resolver: zodResolver(formSchema), defaultValues: { name: undefined, domain: undefined, ip: undefined, port: undefined, enableSSL: false, certificateId: undefined, caching: false, }, }); const domain = useWatch({ control: form.control, name: 'domain' }); const enableSSL = useWatch({ control: form.control, name: 'enableSSL' }); const debouncedDomain = useDebounce(domain, 500); const [certificates, setCertificates] = useState<Certificate[]>([]); const initData = useRef<z.infer<typeof formSchema> | null>(null); const [isLoading, setIsLoading] = useState(true); useEffect(() => { authFetch(`/api/routing?routingId=${id}`) .then((response) => { if (!response.ok) throw Error(`라우팅 정보 조회 실패: ${response.status}`); return response.json(); }) .then(({ name, domain, ip, port, certificateId, caching }) => { initData.current = { name, domain, ip, port: parseInt(port), enableSSL: certificateId ? true : false, certificateId: certificateId || -1, caching, }; form.setValue('name', initData.current.name); form.setValue('domain', initData.current.domain); form.setValue('ip', initData.current.ip); form.setValue('port', initData.current.port); form.setValue('enableSSL', initData.current.enableSSL); form.setValue('certificateId', initData.current.certificateId); form.setValue('caching', initData.current.caching); setIsLoading(false); }) .catch((error) => { console.error(error); toast.error('라우팅 설정 정보를 조회할 수 없습니다.'); }); }, []); useEffect(() => { if (enableSSL) { authFetch(`/api/certificates?projectId=${selectedProject?.id}&domain=${debouncedDomain}`) .then((response) => { if (!response.ok) throw Error(`SSL 인증서 목록 조회 실패: ${response.status}`); return response.json(); }) .then(({ contents }) => { setCertificates(contents); }) .catch((error) => { console.error(error); toast.error('SSL 인증서 목록을 불러올 수 없습니다.'); }); } }, [authFetch, enableSSL, selectedProject, debouncedDomain]); async function onSubmit(values: z.infer<typeof formSchema>) { if (!initData.current) return; const payload: Partial<z.infer<typeof formSchema>> = {}; if (values.name !== initData.current.name) payload.name = values.name; if (values.domain !== initData.current.domain) payload.domain = values.domain; if (values.ip !== initData.current.ip) payload.ip = values.ip; if (values.port !== initData.current.port) payload.port = values.port; if (values.caching !== initData.current.caching) payload.caching = values.caching; if (!values.enableSSL && values.enableSSL !== initData.current.enableSSL) payload.certificateId = -1; if (values.enableSSL && values.certificateId !== initData.current.certificateId) payload.certificateId = values.certificateId; const response = await authFetch(`/api/routing?routingId=${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); if (!response.ok) { const { code } = await response.json(); if (code == 'DUPLICATED_DOMAIN_NAME') { form.setError('domain', { type: 'custom' }); toast.error('이미 사용중인 도메인입니다'); } else { toast.error('라우팅 설정 수정에 실패하였습니다'); } } else { toast.success('라우팅 설정이 수정되었습니다'); navigate('/routing'); } } return ( <div className="flex flex-1 flex-col gap-4 p-6"> <div className="mb-2"> <h1 className="scroll-m-20 text-3xl font-semibold first:mt-0">라우팅 설정 수정</h1> <p className="mt-1 text-base text-gray-500">웹 프록시 서버의 기존 라우팅 설정을 수정합니다.</p> </div> <Form {...form}> <form onSubmit={form.handleSubmit(onSubmit)}> <Card className="mb-4"> <CardHeader> <CardTitle className="text-xl">라우팅 기본 설정</CardTitle> </CardHeader> <CardContent className="space-y-4"> {isLoading ? ( <Skeleton className="h-[24rem] w-full" /> ) : ( <div className="grid grid-cols-1 gap-4"> <FormField control={form.control} name="name" render={({ field }) => ( <FormItem> <FormLabel required>서버 이름</FormLabel> <FormControl> <Input placeholder="웹 서버 이름" {...field} /> </FormControl> <FormMessage /> </FormItem> )} /> <FormField control={form.control} name="domain" render={({ field }) => ( <FormItem> <FormLabel required>도메인</FormLabel> <FormControl> <Input placeholder="example.ajou.app" {...field} /> </FormControl> <FormDescription> *.ajou.app 도메인은 별도 설정 없이 자유롭게 사용할 수 있습니다 </FormDescription> <FormMessage /> </FormItem> )} /> <FormField control={form.control} name="ip" render={({ field }) => ( <FormItem> <FormLabel required>인스턴스 IP</FormLabel> <FormControl> <Input placeholder="10.16.x.x" {...field} /> </FormControl> <FormDescription>인스턴스 IP는 10.16.0.0/16 대역을 사용합니다</FormDescription> <FormMessage /> </FormItem> )} /> <FormField control={form.control} name="port" render={({ field }) => ( <FormItem> <FormLabel required>인스턴스 포트</FormLabel> <FormControl> <Input type="number" placeholder="8080" {...field} /> </FormControl> <FormDescription>연결할 웹 서비스의 포트 번호를 입력해주세요</FormDescription> <FormMessage /> </FormItem> )} /> </div> )} </CardContent> </Card> <Card className="mb-6"> <CardHeader> <CardTitle className="text-xl">추가 설정</CardTitle> </CardHeader> <CardContent className="space-y-4"> {isLoading ? ( <Skeleton className="h-[8rem] w-full" /> ) : ( <> <FormField control={form.control} name="enableSSL" render={({ field }) => ( <FormItem className="flex flex-row items-center justify-between"> <div className="space-y-0.5"> <FormLabel className="text-base font-medium">SSL 보안 연결</FormLabel> <FormDescription className="text-sm text-gray-500"> HTTPS 프로토콜을 사용하여 보안 연결을 활성화합니다 </FormDescription> </div> <FormControl> <div className="flex items-center space-x-2"> {!field.value && <X className="h-4 w-4 text-gray-500" />} {field.value && <Check className="h-4 w-4" />} <Switch checked={field.value} onCheckedChange={field.onChange} /> </div> </FormControl> </FormItem> )} /> {form.watch('enableSSL') && ( <FormField control={form.control} name="certificateId" render={({ field }) => ( <FormItem> <Select onValueChange={field.onChange}> <FormControl> <SelectTrigger className="w-full"> <SelectValue placeholder="SSL 인증서 선택" /> </SelectTrigger> </FormControl> <SelectContent> <SelectGroup> {certificates.length === 0 ? ( <SelectLabel className="text-muted-foreground"> 사용 가능한 SSL 인증서가 없습니다. <Link to="/certificate/create" className="font-medium underline underline-offset-4"> 추가하기 </Link> </SelectLabel> ) : ( certificates.map((certificate) => ( <SelectItem value={certificate.id.toString()}>{certificate.domain}</SelectItem> )) )} </SelectGroup> </SelectContent> </Select> <FormMessage /> </FormItem> )} /> )} <Separator /> <FormField control={form.control} name="caching" render={({ field }) => ( <FormItem className="flex flex-row items-center justify-between"> <div className="space-y-0.5"> <FormLabel className="text-base font-medium">정적 파일 캐싱 활성화</FormLabel> <FormDescription className="text-sm text-gray-500"> 빠른 응답을 위해 정적 파일에 대한 캐싱을 활성화합니다 </FormDescription> </div> <FormControl> <div className="flex items-center space-x-2"> {!field.value && <X className="h-4 w-4 text-gray-500" />} {field.value && <Check className="h-4 w-4" />} <Switch checked={field.value} onCheckedChange={field.onChange} /> </div> </FormControl> </FormItem> )} /> </> )} </CardContent> </Card> <div className="flex justify-end gap-2"> <Button variant="outline" asChild> <Link to="..">취소</Link> </Button> <Button type="submit">저장</Button> </div> </form> </Form> </div> ); }