Skip to content
Snippets Groups Projects
Commit 95266fab authored by 한동현's avatar 한동현
Browse files

feat: 로그 조회 페이지 추가

parent 39d4b2f1
No related branches found
No related tags found
No related merge requests found
...@@ -32,6 +32,9 @@ importers: ...@@ -32,6 +32,9 @@ importers:
'@radix-ui/react-label': '@radix-ui/react-label':
specifier: ^2.1.2 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) 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-popover':
specifier: ^1.1.6
version: 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)
'@radix-ui/react-select': '@radix-ui/react-select':
specifier: ^2.1.6 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) 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)
...@@ -575,6 +578,19 @@ packages: ...@@ -575,6 +578,19 @@ packages:
'@types/react-dom': '@types/react-dom':
optional: true optional: true
'@radix-ui/react-popover@1.1.6':
resolution: {integrity: sha512-NQouW0x4/GnkFJ/pRqsIS3rM/k97VzKnVb2jB7Gq7VEGPy5g7uNV1ykySFt7eWSp3i2uSGFwaJcvIRJBAHmmFg==}
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-popper@1.2.2': '@radix-ui/react-popper@1.2.2':
resolution: {integrity: sha512-Rvqc3nOpwseCyj/rgjlJDYAgyfw7OC1tTkKn2ivhaMGcYt8FSBlahHOZak2i3QwkRXUXgGgzeEe2RuqeEHuHgA==} resolution: {integrity: sha512-Rvqc3nOpwseCyj/rgjlJDYAgyfw7OC1tTkKn2ivhaMGcYt8FSBlahHOZak2i3QwkRXUXgGgzeEe2RuqeEHuHgA==}
peerDependencies: peerDependencies:
...@@ -2169,6 +2185,29 @@ snapshots: ...@@ -2169,6 +2185,29 @@ snapshots:
'@types/react': 19.0.10 '@types/react': 19.0.10
'@types/react-dom': 19.0.4(@types/react@19.0.10) '@types/react-dom': 19.0.4(@types/react@19.0.10)
'@radix-ui/react-popover@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)':
dependencies:
'@radix-ui/primitive': 1.1.1
'@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-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-presence': 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-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-controllable-state': 1.1.0(@types/react@19.0.10)(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-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-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)':
dependencies: dependencies:
'@floating-ui/react-dom': 2.1.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0) '@floating-ui/react-dom': 2.1.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
......
import * as React from 'react';
import { Link } from 'react-router';
import { ChevronLeftIcon, ChevronRightIcon, MoreHorizontalIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button, buttonVariants } from '@/components/ui/button';
function Pagination({ className, ...props }: React.ComponentProps<'nav'>) {
return (
<nav
role="navigation"
aria-label="pagination"
data-slot="pagination"
className={cn('mx-auto flex w-full justify-center', className)}
{...props}
/>
);
}
function PaginationContent({ className, ...props }: React.ComponentProps<'ul'>) {
return <ul data-slot="pagination-content" className={cn('flex flex-row items-center gap-1', className)} {...props} />;
}
function PaginationItem({ ...props }: React.ComponentProps<'li'>) {
return <li data-slot="pagination-item" {...props} />;
}
type PaginationLinkProps = {
isActive?: boolean;
} & Pick<React.ComponentProps<typeof Button>, 'size'> &
React.ComponentProps<typeof Link>;
function PaginationLink({ className, isActive, size = 'icon', ...props }: PaginationLinkProps) {
return (
<Link
aria-current={isActive ? 'page' : undefined}
data-slot="pagination-link"
data-active={isActive}
className={cn(
buttonVariants({
variant: isActive ? 'outline' : 'ghost',
size,
}),
className
)}
{...props}
/>
);
}
function PaginationPrevious({ className, ...props }: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="이전 페이지"
size="default"
className={cn('gap-1 px-2.5 sm:pl-2.5', className)}
{...props}
>
<ChevronLeftIcon />
<span className="hidden sm:block">이전</span>
</PaginationLink>
);
}
function PaginationNext({ className, ...props }: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="다음 페이지"
size="default"
className={cn('gap-1 px-2.5 sm:pr-2.5', className)}
{...props}
>
<span className="hidden sm:block">다음</span>
<ChevronRightIcon />
</PaginationLink>
);
}
function PaginationEllipsis({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
aria-hidden
data-slot="pagination-ellipsis"
className={cn('flex size-9 items-center justify-center', className)}
{...props}
>
<MoreHorizontalIcon className="size-4" />
<span className="sr-only">More pages</span>
</span>
);
}
export {
Pagination,
PaginationContent,
PaginationLink,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationEllipsis,
};
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
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 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
import { useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router';
import { Filter, X } from 'lucide-react';
import { toast } from 'sonner';
import { useAuthStore } from '@/stores/authStore';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardFooter } from '@/components/ui/card';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Input } from '@/components/ui/input';
import { Skeleton } from '@/components/ui/skeleton';
import { Badge } from '@/components/ui/badge';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from '@/components/ui/pagination';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { cn } from '@/lib/utils';
import { Log, LogListResponse, Action, Type } from '@/types/log';
interface PageInfo {
first: boolean;
last: boolean;
totalElements: number;
totalPages: number;
}
const ACTION = {
CREATE: '생성',
UPDATE: '수정',
DELETE: '삭제',
};
const TYPE = {
ROUTING: '라우팅',
CERTIFICATE: '인증서',
FORWARDING: '포워딩',
};
export default function LogList() {
const { authFetch, selectedProject } = useAuthStore();
const [searchParams, setSearchParams] = useSearchParams();
const [logs, setLogs] = useState<Log[] | null>(null);
const [pageInfo, setPageInfo] = useState<PageInfo | null>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const page = parseInt(searchParams.get('page') || '1');
const action = searchParams.get('action') as Action | null;
const username = searchParams.get('username');
const type = searchParams.get('type') as Type | null;
useEffect(() => {
setLogs(null);
authFetch(`/api/logs?projectId=${selectedProject?.id}&page=${page - 1}&${searchParams.toString().toLowerCase()}`)
.then((response): Promise<LogListResponse> => {
if (!response.ok) throw new Error(`로그 목록 조회 실패: (${response.status})`);
return response.json();
})
.then(({ totalPages, totalElements, first, last, contents }) => {
setLogs(contents);
setPageInfo({ totalPages, totalElements, first, last });
})
.catch((error) => {
console.error(error);
toast.error('로그 정보를 조회할 수 없습니다.');
});
}, [authFetch, selectedProject, page, searchParams]);
return (
<div className="flex flex-1 flex-col gap-4 p-6">
<div className="flex flex-col sm:flex-row gap-4 justify-between mb-2">
<div>
<h1 className="scroll-m-20 text-3xl font-semibold first:mt-0">설정 변경 로그 조회</h1>
{pageInfo === null ? (
<Skeleton className="w-[18rem] h-[1rem] mt-2 rounded-full" />
) : (
<p className="mt-1 text-base text-gray-500">{pageInfo.totalElements}개의 로그가 조회되었습니다.</p>
)}
</div>
</div>
<Card>
<CardContent>
<div className="flex w-full items-center space-x-2 mb-4">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="secondary">
<Filter />
필터
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel>설정 타입</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup
value={type || undefined}
onValueChange={(value) =>
setSearchParams((prev) => {
prev.delete('page');
prev.set('type', value);
return prev;
})
}
>
<DropdownMenuRadioItem value="ROUTING">라우팅</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="FORWARDING">포워딩</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="CERTIFICATE">인증서</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuLabel>설정 내용</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup
value={action || undefined}
onValueChange={(value) =>
setSearchParams((prev) => {
prev.delete('page');
prev.set('action', value);
return prev;
})
}
>
<DropdownMenuRadioItem value="CREATE">생성</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="UPDATE">수정</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="DELETE">삭제</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<form
className="flex flex-1 items-center gap-2"
onSubmit={(e) => {
e.preventDefault();
const value = searchInputRef.current?.value;
setSearchParams((prev) => {
prev.delete('page');
if (value) prev.set('username', value);
else prev.delete('username');
return prev;
});
}}
>
<div className="flex flex-1 items-center border rounded-md px-2 gap-1 focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2">
{type && (
<Badge
variant="secondary"
className="cursor-pointer"
onClick={() =>
setSearchParams((prev) => {
prev.delete('page');
prev.delete('type');
return prev;
})
}
>
{TYPE[type]}
<X className="cursor-pointer" />
</Badge>
)}
{action && (
<Badge
variant="secondary"
className="cursor-pointer"
onClick={() =>
setSearchParams((prev) => {
prev.delete('page');
prev.delete('action');
return prev;
})
}
>
{ACTION[action]}
<X className="cursor-pointer" />
</Badge>
)}
<Input
placeholder="사용자명으로 검색..."
className="pl-2 border-0 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0"
ref={searchInputRef}
defaultValue={username || ''}
/>
</div>
<Button type="submit" variant="secondary">
검색
</Button>
</form>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-40">시간</TableHead>
<TableHead>설정 내용</TableHead>
<TableHead className="w-32 text-center">사용자명</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{logs === null ? (
<>
<TableRow>
<TableCell colSpan={3}>
<Skeleton className="w-full h-[1rem] my-2 rounded-full" />
</TableCell>
</TableRow>
<TableRow>
<TableCell colSpan={3}>
<Skeleton className="w-full h-[1rem] my-2 rounded-full" />
</TableCell>
</TableRow>
<TableRow>
<TableCell colSpan={3}>
<Skeleton className="w-full h-[1rem] my-2 rounded-full" />
</TableCell>
</TableRow>
</>
) : logs.length === 0 ? (
<TableRow>
<TableCell colSpan={3} className="text-center text-muted-foreground">
조회된 로그가 없습니다.
</TableCell>
</TableRow>
) : (
logs.map((log) => (
<TableRow key={log.id}>
<TableCell>{log.createdAt}</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Badge variant="default">{TYPE[log.type]}</Badge>
<Badge variant="secondary">{ACTION[log.action]}</Badge>
<div>{log.description.split('\n').join(' / ')}</div>
</div>
</TableCell>
<TableCell className="truncate max-w-32 text-center">{log.user.name}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
{pageInfo && pageInfo.totalPages !== 0 && (
<CardFooter>
<div className="mx-auto flex w-full justify-center gap-1">
<PaginationPrevious
to={`?page=${page - 1}`}
className={cn({ 'pointer-events-none opacity-50': pageInfo.first })}
/>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline">{`${page} / ${pageInfo.totalPages} 페이지`}</Button>
</PopoverTrigger>
<PopoverContent className="p-1">
<Pagination>
<PaginationContent className="grid gap-1 grid-cols-10">
{Array.from({ length: pageInfo.totalPages }).map((_, idx) => (
<PaginationItem key={idx}>
<PaginationLink to={`?page=${idx + 1}`} isActive={page === idx + 1} className="w-8 h-8">
{idx + 1}
</PaginationLink>
</PaginationItem>
))}
</PaginationContent>
</Pagination>
</PopoverContent>
</Popover>
<PaginationNext
to={`?page=${page + 1}`}
className={cn({ 'pointer-events-none opacity-50': pageInfo.last })}
/>
</div>
</CardFooter>
)}
</Card>
</div>
);
}
...@@ -11,6 +11,7 @@ import CertificateCreate from './pages/certificate/Create'; ...@@ -11,6 +11,7 @@ import CertificateCreate from './pages/certificate/Create';
import ForwardingList from '@/pages/forwarding/List'; import ForwardingList from '@/pages/forwarding/List';
import ForwardingCreate from './pages/forwarding/Create'; import ForwardingCreate from './pages/forwarding/Create';
import ForwardingEdit from './pages/forwarding/Edit'; import ForwardingEdit from './pages/forwarding/Edit';
import LogList from './pages/log/List';
export default function AppRoutes() { export default function AppRoutes() {
return ( return (
...@@ -32,6 +33,9 @@ export default function AppRoutes() { ...@@ -32,6 +33,9 @@ export default function AppRoutes() {
<Route path="create" element={<ForwardingCreate />} /> <Route path="create" element={<ForwardingCreate />} />
<Route path="edit/:id" element={<ForwardingEdit />} /> <Route path="edit/:id" element={<ForwardingEdit />} />
</Route> </Route>
<Route path="log">
<Route index element={<LogList />} />
</Route>
<Route path="*" element={<NotFound />} /> <Route path="*" element={<NotFound />} />
</Route> </Route>
</Routes> </Routes>
......
export type Action = 'CREATE' | 'UPDATE' | 'DELETE';
export type Type = 'ROUTING' | 'CERTIFICATE' | 'FORWARDING';
export interface Log {
id: number;
user: {
id: string;
name: string;
};
action: Action;
type: Type;
description: string;
createdAt: string;
}
export interface LogListResponse {
totalPages: number;
totalElements: number;
size: number;
contents: Log[];
first: boolean;
last: boolean;
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment