feat: 개별 호출 로그 페이징 기능 상세화
This commit is contained in:
부모
eb8ed22139
커밋
f559b3959b
145
frontend/src/components/Pagination.tsx
Normal file
145
frontend/src/components/Pagination.tsx
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
interface PaginationProps {
|
||||||
|
page: number;
|
||||||
|
totalPages: number;
|
||||||
|
totalElements: number;
|
||||||
|
pageSize: number;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 표시할 페이지 번호 목록 생성 (Truncated Page Number)
|
||||||
|
* - 총 7슬롯 이하면 전부 표시
|
||||||
|
* - 7슬롯 초과면 현재 페이지 기준 양쪽 1개 + 처음/끝 + ellipsis
|
||||||
|
*/
|
||||||
|
function getPageNumbers(current: number, total: number): (number | 'ellipsis')[] {
|
||||||
|
if (total <= 7) {
|
||||||
|
return Array.from({ length: total }, (_, i) => i);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pages: (number | 'ellipsis')[] = [];
|
||||||
|
const SIBLING = 1;
|
||||||
|
|
||||||
|
const leftSibling = Math.max(current - SIBLING, 0);
|
||||||
|
const rightSibling = Math.min(current + SIBLING, total - 1);
|
||||||
|
|
||||||
|
const showLeftEllipsis = leftSibling > 1;
|
||||||
|
const showRightEllipsis = rightSibling < total - 2;
|
||||||
|
|
||||||
|
pages.push(0);
|
||||||
|
|
||||||
|
if (showLeftEllipsis) {
|
||||||
|
pages.push('ellipsis');
|
||||||
|
} else {
|
||||||
|
for (let i = 1; i < leftSibling; i++) {
|
||||||
|
pages.push(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = leftSibling; i <= rightSibling; i++) {
|
||||||
|
if (i !== 0 && i !== total - 1) {
|
||||||
|
pages.push(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showRightEllipsis) {
|
||||||
|
pages.push('ellipsis');
|
||||||
|
} else {
|
||||||
|
for (let i = rightSibling + 1; i < total - 1; i++) {
|
||||||
|
pages.push(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (total > 1) {
|
||||||
|
pages.push(total - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return pages;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Pagination({
|
||||||
|
page,
|
||||||
|
totalPages,
|
||||||
|
totalElements,
|
||||||
|
pageSize,
|
||||||
|
onPageChange,
|
||||||
|
}: PaginationProps) {
|
||||||
|
if (totalPages <= 1) return null;
|
||||||
|
|
||||||
|
const start = page * pageSize + 1;
|
||||||
|
const end = Math.min((page + 1) * pageSize, totalElements);
|
||||||
|
const pages = getPageNumbers(page, totalPages);
|
||||||
|
|
||||||
|
const btnBase =
|
||||||
|
'inline-flex items-center justify-center w-7 h-7 text-xs rounded transition-colors';
|
||||||
|
const btnEnabled = 'hover:bg-wing-hover text-wing-muted';
|
||||||
|
const btnDisabled = 'opacity-30 cursor-not-allowed text-wing-muted';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between mt-2 text-xs text-wing-muted">
|
||||||
|
<span>
|
||||||
|
{totalElements.toLocaleString()}건 중 {start.toLocaleString()}~
|
||||||
|
{end.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-0.5">
|
||||||
|
{/* First */}
|
||||||
|
<button
|
||||||
|
onClick={() => onPageChange(0)}
|
||||||
|
disabled={page === 0}
|
||||||
|
className={`${btnBase} ${page === 0 ? btnDisabled : btnEnabled}`}
|
||||||
|
title="처음"
|
||||||
|
>
|
||||||
|
«
|
||||||
|
</button>
|
||||||
|
{/* Prev */}
|
||||||
|
<button
|
||||||
|
onClick={() => onPageChange(page - 1)}
|
||||||
|
disabled={page === 0}
|
||||||
|
className={`${btnBase} ${page === 0 ? btnDisabled : btnEnabled}`}
|
||||||
|
title="이전"
|
||||||
|
>
|
||||||
|
‹
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Page Numbers */}
|
||||||
|
{pages.map((p, idx) =>
|
||||||
|
p === 'ellipsis' ? (
|
||||||
|
<span key={`e-${idx}`} className="w-7 h-7 inline-flex items-center justify-center text-wing-muted">
|
||||||
|
…
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
key={p}
|
||||||
|
onClick={() => onPageChange(p)}
|
||||||
|
className={`${btnBase} ${
|
||||||
|
p === page
|
||||||
|
? 'bg-wing-accent text-white font-semibold'
|
||||||
|
: btnEnabled
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{p + 1}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Next */}
|
||||||
|
<button
|
||||||
|
onClick={() => onPageChange(page + 1)}
|
||||||
|
disabled={page >= totalPages - 1}
|
||||||
|
className={`${btnBase} ${page >= totalPages - 1 ? btnDisabled : btnEnabled}`}
|
||||||
|
title="다음"
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</button>
|
||||||
|
{/* Last */}
|
||||||
|
<button
|
||||||
|
onClick={() => onPageChange(totalPages - 1)}
|
||||||
|
disabled={page >= totalPages - 1}
|
||||||
|
className={`${btnBase} ${page >= totalPages - 1 ? btnDisabled : btnEnabled}`}
|
||||||
|
title="마지막"
|
||||||
|
>
|
||||||
|
»
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -6,6 +6,7 @@ import { usePoller } from '../hooks/usePoller';
|
|||||||
import StatusBadge from '../components/StatusBadge';
|
import StatusBadge from '../components/StatusBadge';
|
||||||
import EmptyState from '../components/EmptyState';
|
import EmptyState from '../components/EmptyState';
|
||||||
import LoadingSpinner from '../components/LoadingSpinner';
|
import LoadingSpinner from '../components/LoadingSpinner';
|
||||||
|
import Pagination from '../components/Pagination';
|
||||||
|
|
||||||
const POLLING_INTERVAL_MS = 5000;
|
const POLLING_INTERVAL_MS = 5000;
|
||||||
|
|
||||||
@ -92,7 +93,7 @@ function ApiLogSection({ stepExecutionId, summary }: ApiLogSectionProps) {
|
|||||||
const fetchLogs = useCallback(async (p: number, s: ApiLogStatus) => {
|
const fetchLogs = useCallback(async (p: number, s: ApiLogStatus) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const data = await batchApi.getStepApiLogs(stepExecutionId, { page: p, size: 50, status: s });
|
const data = await batchApi.getStepApiLogs(stepExecutionId, { page: p, size: 10, status: s });
|
||||||
setLogData(data);
|
setLogData(data);
|
||||||
} catch {
|
} catch {
|
||||||
setLogData(null);
|
setLogData(null);
|
||||||
@ -163,7 +164,7 @@ function ApiLogSection({ stepExecutionId, summary }: ApiLogSectionProps) {
|
|||||||
</div>
|
</div>
|
||||||
) : logData && logData.content.length > 0 ? (
|
) : logData && logData.content.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<div className="overflow-x-auto max-h-80 overflow-y-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-xs text-left">
|
<table className="w-full text-xs text-left">
|
||||||
<thead className="bg-blue-100 text-blue-700 sticky top-0">
|
<thead className="bg-blue-100 text-blue-700 sticky top-0">
|
||||||
<tr>
|
<tr>
|
||||||
@ -185,7 +186,7 @@ function ApiLogSection({ stepExecutionId, summary }: ApiLogSectionProps) {
|
|||||||
key={log.logId}
|
key={log.logId}
|
||||||
className={isError ? 'bg-red-50' : 'bg-white hover:bg-blue-50'}
|
className={isError ? 'bg-red-50' : 'bg-white hover:bg-blue-50'}
|
||||||
>
|
>
|
||||||
<td className="px-2 py-1.5 text-blue-500">{page * 50 + idx + 1}</td>
|
<td className="px-2 py-1.5 text-blue-500">{page * 10 + idx + 1}</td>
|
||||||
<td className="px-2 py-1.5 max-w-[200px]">
|
<td className="px-2 py-1.5 max-w-[200px]">
|
||||||
<div className="flex items-center gap-0.5">
|
<div className="flex items-center gap-0.5">
|
||||||
<span className="font-mono text-blue-900 truncate" title={log.requestUri}>
|
<span className="font-mono text-blue-900 truncate" title={log.requestUri}>
|
||||||
@ -225,33 +226,13 @@ function ApiLogSection({ stepExecutionId, summary }: ApiLogSectionProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 페이지네이션 */}
|
{/* 페이지네이션 */}
|
||||||
{logData.totalPages > 1 && (
|
<Pagination
|
||||||
<div className="flex items-center justify-between mt-2 text-xs text-wing-muted">
|
page={page}
|
||||||
<span>
|
totalPages={logData.totalPages}
|
||||||
{logData.totalElements.toLocaleString()}건 중{' '}
|
totalElements={logData.totalElements}
|
||||||
{(page * 50 + 1).toLocaleString()}~{Math.min((page + 1) * 50, logData.totalElements).toLocaleString()}
|
pageSize={10}
|
||||||
</span>
|
onPageChange={setPage}
|
||||||
<div className="flex items-center gap-1">
|
/>
|
||||||
<button
|
|
||||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
|
||||||
disabled={page === 0}
|
|
||||||
className="px-2 py-1 rounded bg-gray-100 hover:bg-gray-200 disabled:opacity-40 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
이전
|
|
||||||
</button>
|
|
||||||
<span className="px-2">
|
|
||||||
{page + 1} / {logData.totalPages}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={() => setPage((p) => Math.min(logData.totalPages - 1, p + 1))}
|
|
||||||
disabled={page >= logData.totalPages - 1}
|
|
||||||
className="px-2 py-1 rounded bg-gray-100 hover:bg-gray-200 disabled:opacity-40 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
다음
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-xs text-wing-muted py-3 text-center">조회된 로그가 없습니다.</p>
|
<p className="text-xs text-wing-muted py-3 text-center">조회된 로그가 없습니다.</p>
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import { usePoller } from '../hooks/usePoller';
|
|||||||
import StatusBadge from '../components/StatusBadge';
|
import StatusBadge from '../components/StatusBadge';
|
||||||
import EmptyState from '../components/EmptyState';
|
import EmptyState from '../components/EmptyState';
|
||||||
import LoadingSpinner from '../components/LoadingSpinner';
|
import LoadingSpinner from '../components/LoadingSpinner';
|
||||||
|
import Pagination from '../components/Pagination';
|
||||||
|
|
||||||
const POLLING_INTERVAL_MS = 10_000;
|
const POLLING_INTERVAL_MS = 10_000;
|
||||||
|
|
||||||
@ -97,7 +98,7 @@ function ApiLogSection({ stepExecutionId, summary }: ApiLogSectionProps) {
|
|||||||
const fetchLogs = useCallback(async (p: number, s: ApiLogStatus) => {
|
const fetchLogs = useCallback(async (p: number, s: ApiLogStatus) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const data = await batchApi.getStepApiLogs(stepExecutionId, { page: p, size: 50, status: s });
|
const data = await batchApi.getStepApiLogs(stepExecutionId, { page: p, size: 10, status: s });
|
||||||
setLogData(data);
|
setLogData(data);
|
||||||
} catch {
|
} catch {
|
||||||
setLogData(null);
|
setLogData(null);
|
||||||
@ -168,7 +169,7 @@ function ApiLogSection({ stepExecutionId, summary }: ApiLogSectionProps) {
|
|||||||
</div>
|
</div>
|
||||||
) : logData && logData.content.length > 0 ? (
|
) : logData && logData.content.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<div className="overflow-x-auto max-h-80 overflow-y-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-xs text-left">
|
<table className="w-full text-xs text-left">
|
||||||
<thead className="bg-blue-100 text-blue-700 sticky top-0">
|
<thead className="bg-blue-100 text-blue-700 sticky top-0">
|
||||||
<tr>
|
<tr>
|
||||||
@ -190,7 +191,7 @@ function ApiLogSection({ stepExecutionId, summary }: ApiLogSectionProps) {
|
|||||||
key={log.logId}
|
key={log.logId}
|
||||||
className={isError ? 'bg-red-50' : 'bg-white hover:bg-blue-50'}
|
className={isError ? 'bg-red-50' : 'bg-white hover:bg-blue-50'}
|
||||||
>
|
>
|
||||||
<td className="px-2 py-1.5 text-blue-500">{page * 50 + idx + 1}</td>
|
<td className="px-2 py-1.5 text-blue-500">{page * 10 + idx + 1}</td>
|
||||||
<td className="px-2 py-1.5 max-w-[200px]">
|
<td className="px-2 py-1.5 max-w-[200px]">
|
||||||
<div className="flex items-center gap-0.5">
|
<div className="flex items-center gap-0.5">
|
||||||
<span className="font-mono text-blue-900 truncate" title={log.requestUri}>
|
<span className="font-mono text-blue-900 truncate" title={log.requestUri}>
|
||||||
@ -230,33 +231,13 @@ function ApiLogSection({ stepExecutionId, summary }: ApiLogSectionProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 페이지네이션 */}
|
{/* 페이지네이션 */}
|
||||||
{logData.totalPages > 1 && (
|
<Pagination
|
||||||
<div className="flex items-center justify-between mt-2 text-xs text-wing-muted">
|
page={page}
|
||||||
<span>
|
totalPages={logData.totalPages}
|
||||||
{logData.totalElements.toLocaleString()}건 중{' '}
|
totalElements={logData.totalElements}
|
||||||
{(page * 50 + 1).toLocaleString()}~{Math.min((page + 1) * 50, logData.totalElements).toLocaleString()}
|
pageSize={10}
|
||||||
</span>
|
onPageChange={setPage}
|
||||||
<div className="flex items-center gap-1">
|
/>
|
||||||
<button
|
|
||||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
|
||||||
disabled={page === 0}
|
|
||||||
className="px-2 py-1 rounded bg-gray-100 hover:bg-gray-200 disabled:opacity-40 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
이전
|
|
||||||
</button>
|
|
||||||
<span className="px-2">
|
|
||||||
{page + 1} / {logData.totalPages}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={() => setPage((p) => Math.min(logData.totalPages - 1, p + 1))}
|
|
||||||
disabled={page >= logData.totalPages - 1}
|
|
||||||
className="px-2 py-1 rounded bg-gray-100 hover:bg-gray-200 disabled:opacity-40 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
다음
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-xs text-wing-muted py-3 text-center">조회된 로그가 없습니다.</p>
|
<p className="text-xs text-wing-muted py-3 text-center">조회된 로그가 없습니다.</p>
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user