Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found
Select Git revision
  • main
1 result

Target

Select target project
  • aolda/proxy-manager-frontend
1 result
Select Git revision
  • main
1 result
Show changes
Commits on Source (2)
......@@ -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-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':
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)
......@@ -575,6 +578,19 @@ packages:
'@types/react-dom':
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':
resolution: {integrity: sha512-Rvqc3nOpwseCyj/rgjlJDYAgyfw7OC1tTkKn2ivhaMGcYt8FSBlahHOZak2i3QwkRXUXgGgzeEe2RuqeEHuHgA==}
peerDependencies:
......@@ -2169,6 +2185,29 @@ snapshots:
'@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)':
dependencies:
'@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, useState } from 'react';
import { Link } from 'react-router';
import { Split, ShieldCheck, Server, ArrowRight } from 'lucide-react';
import { toast } from 'sonner';
import { useAuthStore } from '@/stores/authStore';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { Badge } from '@/components/ui/badge';
import { Card, CardHeader, CardTitle, CardContent, CardFooter, CardDescription } from '@/components/ui/card';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Log, LogListResponse } from '@/types/log';
const logs = [
{
id: 1,
timestamp: '2021-09-01 12:00:00',
action: '웹 라우팅 설정 변경 (console.ajou.app)',
username: 'admin',
},
{
id: 2,
timestamp: '2021-09-01 12:01:00',
action: 'SSL 인증서 발급 (*.ajou.app)',
username: 'admin',
},
{
id: 3,
timestamp: '2021-09-01 13:00:00',
action: 'SSH 포트포워딩 설정 변경 (10.16.1.10)',
username: 'admin',
},
{
id: 4,
timestamp: '2021-09-02 12:00:00',
action: '웹 라우팅 설정 변경 (blog.ajou.app)',
username: 'hando1220',
},
];
interface ProjectInfo {
routing: number;
forwarding: number;
certificate: number;
}
const ACTION = {
CREATE: '생성',
UPDATE: '수정',
DELETE: '삭제',
};
const TYPE = {
ROUTING: '라우팅',
CERTIFICATE: '인증서',
FORWARDING: '포워딩',
};
export default function Home() {
const { authFetch, selectedProject } = useAuthStore();
const [logs, setLogs] = useState<Log[] | null>(null);
const [projectInfo, setProjectInfo] = useState<ProjectInfo | null>(null);
useEffect(() => {
authFetch(`/api/main?projectId=${selectedProject?.id}`)
.then((response) => {
if (!response.ok) throw new Error(`프로젝트 정보 조회 실패: (${response.status})`);
return response.json();
})
.then((data) => {
setProjectInfo(data);
})
.catch((error) => {
console.error(error);
toast.error('프로젝트 정보를 조회할 수 없습니다.');
});
}, [authFetch, selectedProject]);
useEffect(() => {
authFetch(`/api/logs?projectId=${selectedProject?.id}&size=5`)
.then((response): Promise<LogListResponse> => {
if (!response.ok) throw new Error(`로그 목록 조회 실패: (${response.status})`);
return response.json();
})
.then(({ contents }) => {
setLogs(contents);
})
.catch((error) => {
console.error(error);
toast.error('로그 정보를 조회할 수 없습니다.');
});
}, [authFetch, selectedProject]);
return (
<div className="flex flex-1 flex-col gap-4 p-6">
<h1 className="scroll-m-20 text-3xl font-semibold first:mt-0 mb-2">Aolda Proxy Manager</h1>
......@@ -40,14 +74,22 @@ export default function Home() {
<CardTitle className="text-xl">웹 라우팅</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-row justify-center items-center gap-2 font-bold text-4xl">
<Split className="size-8" /> 0
<div className="flex flex-row justify-center items-center gap-4 font-bold text-4xl">
{projectInfo === null ? (
<Skeleton className="w-22 h-10 rounded-full" />
) : (
<>
<Split className="size-8" /> {projectInfo.routing}
</>
)}
</div>
</CardContent>
<CardFooter>
<Button variant="ghost" className="ml-auto">
관리하기 <ArrowRight />
</Button>
<Link className="ml-auto" to="/routing">
<Button variant="ghost">
관리하기 <ArrowRight />
</Button>
</Link>
</CardFooter>
</Card>
<Card>
......@@ -55,14 +97,22 @@ export default function Home() {
<CardTitle className="text-xl">SSL 인증서</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-row justify-center items-center gap-2 font-bold text-4xl">
<ShieldCheck className="size-8" /> 0
<div className="flex flex-row justify-center items-center gap-4 font-bold text-4xl">
{projectInfo === null ? (
<Skeleton className="w-22 h-10 rounded-full" />
) : (
<>
<ShieldCheck className="size-8" /> {projectInfo.certificate}
</>
)}
</div>
</CardContent>
<CardFooter>
<Button variant="ghost" className="ml-auto">
관리하기 <ArrowRight />
</Button>
<Link className="ml-auto" to="/certificate">
<Button variant="ghost">
관리하기 <ArrowRight />
</Button>
</Link>
</CardFooter>
</Card>
<Card>
......@@ -70,14 +120,22 @@ export default function Home() {
<CardTitle className="text-xl">SSH 포트포워딩</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-row justify-center items-center gap-2 font-bold text-4xl">
<Server className="size-8" /> 0
<div className="flex flex-row justify-center items-center gap-4 font-bold text-4xl">
{projectInfo === null ? (
<Skeleton className="w-22 h-10 rounded-full" />
) : (
<>
<Server className="size-8" /> {projectInfo.certificate}
</>
)}
</div>
</CardContent>
<CardFooter>
<Button variant="ghost" className="ml-auto">
관리하기 <ArrowRight />
</Button>
<Link className="ml-auto" to="/forwarding">
<Button variant="ghost">
관리하기 <ArrowRight />
</Button>
</Link>
</CardFooter>
</Card>
</div>
......@@ -96,20 +154,54 @@ export default function Home() {
</TableRow>
</TableHeader>
<TableBody>
{logs.map((log) => (
<TableRow key={log.id}>
<TableCell className="font-medium">{log.timestamp}</TableCell>
<TableCell>{log.action}</TableCell>
<TableCell className="text-center">{log.username}</TableCell>
{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>
<CardFooter>
<Button variant="ghost" className="ml-auto">
모든 기록 보기 <ArrowRight />
</Button>
<Link className="ml-auto" to="/log">
<Button variant="ghost">
모든 기록 보기 <ArrowRight />
</Button>
</Link>
</CardFooter>
</Card>
</div>
......
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';
import ForwardingList from '@/pages/forwarding/List';
import ForwardingCreate from './pages/forwarding/Create';
import ForwardingEdit from './pages/forwarding/Edit';
import LogList from './pages/log/List';
export default function AppRoutes() {
return (
......@@ -32,6 +33,9 @@ export default function AppRoutes() {
<Route path="create" element={<ForwardingCreate />} />
<Route path="edit/:id" element={<ForwardingEdit />} />
</Route>
<Route path="log">
<Route index element={<LogList />} />
</Route>
<Route path="*" element={<NotFound />} />
</Route>
</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;
}