From c67b69aac0523b1b1a27819d7b9f1e623ffcfba3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=95=9C=EB=8F=99=ED=98=84?= <hando1220@ajou.ac.kr> Date: Tue, 18 Mar 2025 00:15:39 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=9D=BC=EC=9A=B0=ED=8C=85=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=20=ED=8E=98=EC=9D=B4=EC=A7=80=20API=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 1 + pnpm-lock.yaml | 50 ++++++++++ src/components/ui/select.tsx | 183 +++++++++++++++++++++++++++++++++++ src/hooks/useDebounce.ts | 19 ++++ src/pages/routing/Create.tsx | 175 +++++++++++++++++++++++++++------ src/types/certificate.ts | 8 ++ 6 files changed, 405 insertions(+), 31 deletions(-) create mode 100644 src/components/ui/select.tsx create mode 100644 src/hooks/useDebounce.ts create mode 100644 src/types/certificate.ts diff --git a/package.json b/package.json index 7ba3674..9c3c323 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "@radix-ui/react-dropdown-menu": "^2.1.6", "@radix-ui/react-hover-card": "^1.1.6", "@radix-ui/react-label": "^2.1.2", + "@radix-ui/react-select": "^2.1.6", "@radix-ui/react-separator": "^1.1.2", "@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-switch": "^1.1.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ffbe253..810a9ae 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: '@radix-ui/react-label': specifier: ^2.1.2 version: 2.1.2(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-select': + specifier: ^2.1.6 + version: 2.1.6(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) '@radix-ui/react-separator': specifier: ^1.1.2 version: 1.1.2(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) @@ -365,6 +368,9 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@radix-ui/number@1.1.0': + resolution: {integrity: sha512-V3gRzhVNU1ldS5XhAPTom1fOIo4ccrjjJgmE+LI2h/WaFpHmx0MQApT+KZHnx8abG6Avtfcz4WoEciMnpFT3HQ==} + '@radix-ui/primitive@1.1.1': resolution: {integrity: sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==} @@ -634,6 +640,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-select@2.1.6': + resolution: {integrity: sha512-T6ajELxRvTuAMWH0YmRJ1qez+x4/7Nq7QIx7zJ0VK3qaEWdnWpNbEDnmWldG1zBDwqrLy5aLMUWcoGirVj5kMg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-separator@1.1.2': resolution: {integrity: sha512-oZfHcaAp2Y6KFBX6I5P1u7CQoy4lheCGiYj+pGFrHy8E/VNRb5E39TkTr3JrV520csPBTZjkuKFdEsjS5EUNKQ==} peerDependencies: @@ -1939,6 +1958,8 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.19.1 + '@radix-ui/number@1.1.0': {} + '@radix-ui/primitive@1.1.1': {} '@radix-ui/react-alert-dialog@1.1.6(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': @@ -2212,6 +2233,35 @@ snapshots: '@types/react': 19.0.10 '@types/react-dom': 19.0.4(@types/react@19.0.10) + '@radix-ui/react-select@2.1.6(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': + dependencies: + '@radix-ui/number': 1.1.0 + '@radix-ui/primitive': 1.1.1 + '@radix-ui/react-collection': 1.1.2(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-compose-refs': 1.1.1(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-context': 1.1.1(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-direction': 1.1.0(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-dismissable-layer': 1.1.5(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-focus-guards': 1.1.1(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-focus-scope': 1.1.2(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-id': 1.1.0(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-popper': 1.2.2(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-portal': 1.1.4(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-slot': 1.1.2(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-use-previous': 1.1.0(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-visually-hidden': 1.1.2(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + aria-hidden: 1.2.4 + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + react-remove-scroll: 2.6.3(@types/react@19.0.10)(react@19.0.0) + optionalDependencies: + '@types/react': 19.0.10 + '@types/react-dom': 19.0.4(@types/react@19.0.10) + '@radix-ui/react-separator@1.1.2(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': dependencies: '@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx new file mode 100644 index 0000000..cd879e9 --- /dev/null +++ b/src/components/ui/select.tsx @@ -0,0 +1,183 @@ +import * as React from "react" +import * as SelectPrimitive from "@radix-ui/react-select" +import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react" + +import { cn } from "@/lib/utils" + +function Select({ + ...props +}: React.ComponentProps<typeof SelectPrimitive.Root>) { + return <SelectPrimitive.Root data-slot="select" {...props} /> +} + +function SelectGroup({ + ...props +}: React.ComponentProps<typeof SelectPrimitive.Group>) { + return <SelectPrimitive.Group data-slot="select-group" {...props} /> +} + +function SelectValue({ + ...props +}: React.ComponentProps<typeof SelectPrimitive.Value>) { + return <SelectPrimitive.Value data-slot="select-value" {...props} /> +} + +function SelectTrigger({ + className, + size = "default", + children, + ...props +}: React.ComponentProps<typeof SelectPrimitive.Trigger> & { + size?: "sm" | "default" +}) { + return ( + <SelectPrimitive.Trigger + data-slot="select-trigger" + data-size={size} + className={cn( + "border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + className + )} + {...props} + > + {children} + <SelectPrimitive.Icon asChild> + <ChevronDownIcon className="size-4 opacity-50" /> + </SelectPrimitive.Icon> + </SelectPrimitive.Trigger> + ) +} + +function SelectContent({ + className, + children, + position = "popper", + ...props +}: React.ComponentProps<typeof SelectPrimitive.Content>) { + return ( + <SelectPrimitive.Portal> + <SelectPrimitive.Content + data-slot="select-content" + className={cn( + "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md", + position === "popper" && + "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", + className + )} + position={position} + {...props} + > + <SelectScrollUpButton /> + <SelectPrimitive.Viewport + className={cn( + "p-1", + position === "popper" && + "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1" + )} + > + {children} + </SelectPrimitive.Viewport> + <SelectScrollDownButton /> + </SelectPrimitive.Content> + </SelectPrimitive.Portal> + ) +} + +function SelectLabel({ + className, + ...props +}: React.ComponentProps<typeof SelectPrimitive.Label>) { + return ( + <SelectPrimitive.Label + data-slot="select-label" + className={cn("px-2 py-1.5 text-sm font-medium", className)} + {...props} + /> + ) +} + +function SelectItem({ + className, + children, + ...props +}: React.ComponentProps<typeof SelectPrimitive.Item>) { + return ( + <SelectPrimitive.Item + data-slot="select-item" + className={cn( + "focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2", + className + )} + {...props} + > + <span className="absolute right-2 flex size-3.5 items-center justify-center"> + <SelectPrimitive.ItemIndicator> + <CheckIcon className="size-4" /> + </SelectPrimitive.ItemIndicator> + </span> + <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText> + </SelectPrimitive.Item> + ) +} + +function SelectSeparator({ + className, + ...props +}: React.ComponentProps<typeof SelectPrimitive.Separator>) { + return ( + <SelectPrimitive.Separator + data-slot="select-separator" + className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)} + {...props} + /> + ) +} + +function SelectScrollUpButton({ + className, + ...props +}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) { + return ( + <SelectPrimitive.ScrollUpButton + data-slot="select-scroll-up-button" + className={cn( + "flex cursor-default items-center justify-center py-1", + className + )} + {...props} + > + <ChevronUpIcon className="size-4" /> + </SelectPrimitive.ScrollUpButton> + ) +} + +function SelectScrollDownButton({ + className, + ...props +}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) { + return ( + <SelectPrimitive.ScrollDownButton + data-slot="select-scroll-down-button" + className={cn( + "flex cursor-default items-center justify-center py-1", + className + )} + {...props} + > + <ChevronDownIcon className="size-4" /> + </SelectPrimitive.ScrollDownButton> + ) +} + +export { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectScrollDownButton, + SelectScrollUpButton, + SelectSeparator, + SelectTrigger, + SelectValue, +} diff --git a/src/hooks/useDebounce.ts b/src/hooks/useDebounce.ts new file mode 100644 index 0000000..3825381 --- /dev/null +++ b/src/hooks/useDebounce.ts @@ -0,0 +1,19 @@ +import { useState, useEffect } from 'react'; + +function useDebounce<T>(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState<T>(value); + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => { + clearTimeout(handler); + }; + }, [value, delay]); + + return debouncedValue; +} + +export default useDebounce; diff --git a/src/pages/routing/Create.tsx b/src/pages/routing/Create.tsx index 7bb5de3..afadf5d 100644 --- a/src/pages/routing/Create.tsx +++ b/src/pages/routing/Create.tsx @@ -1,48 +1,114 @@ +import { useEffect, useState } from 'react'; +import { Link, useNavigate } 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 { Link } from 'react-router'; -import { useForm } from 'react-hook-form'; -import { Check, X } from 'lucide-react'; +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 { toast } from 'sonner'; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; +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: '올바른 도메인 주소를 입력해주세요', - }), - instance_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 대역을 사용해야 합니다' }), - enable_ssl: z.boolean(), - enable_cache: z.boolean(), -}); +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 RoutingCreate() { + const navigate = useNavigate(); + const { authFetch, selectedProject } = useAuthStore(); const form = useForm<z.infer<typeof formSchema>>({ resolver: zodResolver(formSchema), defaultValues: { - name: '', - domain: '', - instance_ip: '', - enable_ssl: false, - enable_cache: false, + 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[]>([]); + + useEffect(() => { + if (enableSSL) { + authFetch(`/api/certificates?projectId=${selectedProject?.id}&domain=${debouncedDomain}`) + .then((response) => { + if (!response.ok) { + toast.error('SSL 인증서 목록을 불러올 수 없습니다'); + return { contents: [] }; + } + return response.json(); + }) + .then(({ contents }) => { + setCertificates(contents); + }); + } + }, [authFetch, enableSSL, selectedProject, debouncedDomain]); + + async function onSubmit(values: z.infer<typeof formSchema>) { + const { name, domain, ip, port, enableSSL, certificateId, caching } = values; + const response = await authFetch(`/api/routing?projectId=${selectedProject?.id}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name, + domain, + ip, + port, + certificateId: enableSSL ? certificateId : -1, + caching, + }), + }); - function onSubmit(values: z.infer<typeof formSchema>) { - console.log('제출된 데이터:', values); - toast.warning('라우팅 설정을 등록합니다'); + if (!response.ok) { + console.error(response); + toast.error('라우팅 설정을 등록할 수 없습니다'); + } else { + toast.success('라우팅 설정을 등록합니다'); + navigate('/routing'); + } } return ( @@ -91,7 +157,7 @@ export default function RoutingCreate() { <FormField control={form.control} - name="instance_ip" + name="ip" render={({ field }) => ( <FormItem> <FormLabel required>인스턴스 IP</FormLabel> @@ -103,6 +169,21 @@ export default function RoutingCreate() { </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> @@ -114,7 +195,7 @@ export default function RoutingCreate() { <CardContent className="space-y-4"> <FormField control={form.control} - name="enable_ssl" + name="enableSSL" render={({ field }) => ( <FormItem className="flex flex-row items-center justify-between"> <div className="space-y-0.5"> @@ -134,11 +215,43 @@ export default function RoutingCreate() { )} /> + {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 인증서가 없습니다. + </SelectLabel> + ) : ( + certificates.map((certificate) => ( + <SelectItem value={certificate.id.toString()}>{certificate.domain}</SelectItem> + )) + )} + </SelectGroup> + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + )} + <Separator /> <FormField control={form.control} - name="enable_cache" + name="caching" render={({ field }) => ( <FormItem className="flex flex-row items-center justify-between"> <div className="space-y-0.5"> diff --git a/src/types/certificate.ts b/src/types/certificate.ts new file mode 100644 index 0000000..ccd5908 --- /dev/null +++ b/src/types/certificate.ts @@ -0,0 +1,8 @@ +export interface Certificate { + id: number; + email: string; + domain: string; + createdAt: string; + updatedAt: string; + expiresAt: string; +} -- GitLab