feat: 개별 호출 로그 페이징 기능 상세화

This commit is contained in:
hyojin kim 2026-02-27 11:07:27 +09:00
부모 eb8ed22139
커밋 f559b3959b
3개의 변경된 파일167개의 추가작업 그리고 60개의 파일을 삭제

파일 보기

@ -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="처음"
>
&laquo;
</button>
{/* Prev */}
<button
onClick={() => onPageChange(page - 1)}
disabled={page === 0}
className={`${btnBase} ${page === 0 ? btnDisabled : btnEnabled}`}
title="이전"
>
&lsaquo;
</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">
&hellip;
</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="다음"
>
&rsaquo;
</button>
{/* Last */}
<button
onClick={() => onPageChange(totalPages - 1)}
disabled={page >= totalPages - 1}
className={`${btnBase} ${page >= totalPages - 1 ? btnDisabled : btnEnabled}`}
title="마지막"
>
&raquo;
</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>