diff --git a/.gitignore b/.gitignore index 3b66953..6993c75 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,9 @@ dependency-reduced-pom.xml buildNumber.properties .mvn/timing.properties .mvn/wrapper/maven-wrapper.jar +.mvn/wrapper/maven-wrapper.properties +mvnw +mvnw.cmd # Gradle .gradle/ diff --git a/frontend/src/api/batchApi.ts b/frontend/src/api/batchApi.ts index 18a186b..846b51f 100644 --- a/frontend/src/api/batchApi.ts +++ b/frontend/src/api/batchApi.ts @@ -287,6 +287,7 @@ export interface RecollectionSearchResponse { number: number; size: number; totalPages: number; + failedRecordCounts: Record; } export interface RecollectionStatsResponse { diff --git a/frontend/src/components/Pagination.tsx b/frontend/src/components/Pagination.tsx new file mode 100644 index 0000000..b735e4f --- /dev/null +++ b/frontend/src/components/Pagination.tsx @@ -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 ( +
+ + {totalElements.toLocaleString()}건 중 {start.toLocaleString()}~ + {end.toLocaleString()} + +
+ {/* First */} + + {/* Prev */} + + + {/* Page Numbers */} + {pages.map((p, idx) => + p === 'ellipsis' ? ( + + … + + ) : ( + + ), + )} + + {/* Next */} + + {/* Last */} + +
+
+ ); +} diff --git a/frontend/src/pages/ExecutionDetail.tsx b/frontend/src/pages/ExecutionDetail.tsx index 34fd036..f5f5482 100644 --- a/frontend/src/pages/ExecutionDetail.tsx +++ b/frontend/src/pages/ExecutionDetail.tsx @@ -6,6 +6,7 @@ import { usePoller } from '../hooks/usePoller'; import StatusBadge from '../components/StatusBadge'; import EmptyState from '../components/EmptyState'; import LoadingSpinner from '../components/LoadingSpinner'; +import Pagination from '../components/Pagination'; const POLLING_INTERVAL_MS = 5000; @@ -92,7 +93,7 @@ function ApiLogSection({ stepExecutionId, summary }: ApiLogSectionProps) { const fetchLogs = useCallback(async (p: number, s: ApiLogStatus) => { setLoading(true); 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); } catch { setLogData(null); @@ -163,7 +164,7 @@ function ApiLogSection({ stepExecutionId, summary }: ApiLogSectionProps) { ) : logData && logData.content.length > 0 ? ( <> -
+
@@ -185,7 +186,7 @@ function ApiLogSection({ stepExecutionId, summary }: ApiLogSectionProps) { key={log.logId} className={isError ? 'bg-red-50' : 'bg-white hover:bg-blue-50'} > - + + + {pagedRecords.map((record) => ( + + + + + + + + ))} + +
{page * 50 + idx + 1}{page * 10 + idx + 1}
@@ -225,33 +226,13 @@ function ApiLogSection({ stepExecutionId, summary }: ApiLogSectionProps) {
{/* 페이지네이션 */} - {logData.totalPages > 1 && ( -
- - {logData.totalElements.toLocaleString()}건 중{' '} - {(page * 50 + 1).toLocaleString()}~{Math.min((page + 1) * 50, logData.totalElements).toLocaleString()} - -
- - - {page + 1} / {logData.totalPages} - - -
-
- )} + ) : (

조회된 로그가 없습니다.

@@ -594,13 +575,18 @@ export default function ExecutionDetail() { ); } +const FAILED_PAGE_SIZE = 10; + function FailedRecordsToggle({ records, jobName, stepExecutionId }: { records: FailedRecordDto[]; jobName: string; stepExecutionId: number }) { const [open, setOpen] = useState(false); const [showConfirm, setShowConfirm] = useState(false); const [retrying, setRetrying] = useState(false); + const [page, setPage] = useState(0); const navigate = useNavigate(); const failedRecords = records.filter((r) => r.status === 'FAILED'); + const totalPages = Math.ceil(records.length / FAILED_PAGE_SIZE); + const pagedRecords = records.slice(page * FAILED_PAGE_SIZE, (page + 1) * FAILED_PAGE_SIZE); const statusColor = (status: string) => { switch (status) { @@ -656,44 +642,53 @@ function FailedRecordsToggle({ records, jobName, stepExecutionId }: { records: F {open && ( -
- - - - - - - - - - - - {records.map((record) => ( - - - - - - +
+
+
Record Key에러 메시지재시도상태생성 시간
- {record.recordKey} - - {record.errorMessage || '-'} - - {record.retryCount} - - - {record.status} - - - {formatDateTime(record.createdAt)} -
+ + + + + + + - ))} - -
Record Key에러 메시지재시도상태생성 시간
+
+ {record.recordKey} + + {record.errorMessage || '-'} + + {record.retryCount} + + + {record.status} + + + {formatDateTime(record.createdAt)} +
+
+
)} diff --git a/frontend/src/pages/RecollectDetail.tsx b/frontend/src/pages/RecollectDetail.tsx index 0524c19..eb6235a 100644 --- a/frontend/src/pages/RecollectDetail.tsx +++ b/frontend/src/pages/RecollectDetail.tsx @@ -13,6 +13,7 @@ import { usePoller } from '../hooks/usePoller'; import StatusBadge from '../components/StatusBadge'; import EmptyState from '../components/EmptyState'; import LoadingSpinner from '../components/LoadingSpinner'; +import Pagination from '../components/Pagination'; const POLLING_INTERVAL_MS = 10_000; @@ -97,7 +98,7 @@ function ApiLogSection({ stepExecutionId, summary }: ApiLogSectionProps) { const fetchLogs = useCallback(async (p: number, s: ApiLogStatus) => { setLoading(true); 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); } catch { setLogData(null); @@ -168,7 +169,7 @@ function ApiLogSection({ stepExecutionId, summary }: ApiLogSectionProps) { ) : logData && logData.content.length > 0 ? ( <> -
+
@@ -190,7 +191,7 @@ function ApiLogSection({ stepExecutionId, summary }: ApiLogSectionProps) { key={log.logId} className={isError ? 'bg-red-50' : 'bg-white hover:bg-blue-50'} > - + + + {pagedRecords.map((record) => ( + + + + + + + + ))} + +
{page * 50 + idx + 1}{page * 10 + idx + 1}
@@ -230,33 +231,13 @@ function ApiLogSection({ stepExecutionId, summary }: ApiLogSectionProps) {
{/* 페이지네이션 */} - {logData.totalPages > 1 && ( -
- - {logData.totalElements.toLocaleString()}건 중{' '} - {(page * 50 + 1).toLocaleString()}~{Math.min((page + 1) * 50, logData.totalElements).toLocaleString()} - -
- - - {page + 1} / {logData.totalPages} - - -
-
- )} + ) : (

조회된 로그가 없습니다.

@@ -645,13 +626,18 @@ export default function RecollectDetail() { ); } +const FAILED_PAGE_SIZE = 10; + function FailedRecordsToggle({ records, jobName, stepExecutionId }: { records: FailedRecordDto[]; jobName: string; stepExecutionId: number }) { const [open, setOpen] = useState(false); const [showConfirm, setShowConfirm] = useState(false); const [retrying, setRetrying] = useState(false); + const [page, setPage] = useState(0); const navigate = useNavigate(); const failedRecords = records.filter((r) => r.status === 'FAILED'); + const totalPages = Math.ceil(records.length / FAILED_PAGE_SIZE); + const pagedRecords = records.slice(page * FAILED_PAGE_SIZE, (page + 1) * FAILED_PAGE_SIZE); const statusColor = (status: string) => { switch (status) { @@ -707,44 +693,53 @@ function FailedRecordsToggle({ records, jobName, stepExecutionId }: { records: F {open && ( -
- - - - - - - - - - - - {records.map((record) => ( - - - - - - +
+
+
Record Key에러 메시지재시도상태생성 시간
- {record.recordKey} - - {record.errorMessage || '-'} - - {record.retryCount} - - - {record.status} - - - {formatDateTime(record.createdAt)} -
+ + + + + + + - ))} - -
Record Key에러 메시지재시도상태생성 시간
+
+ {record.recordKey} + + {record.errorMessage || '-'} + + {record.retryCount} + + + {record.status} + + + {formatDateTime(record.createdAt)} +
+
+
)} diff --git a/frontend/src/pages/Recollects.tsx b/frontend/src/pages/Recollects.tsx index 6b2b898..e6bbc09 100644 --- a/frontend/src/pages/Recollects.tsx +++ b/frontend/src/pages/Recollects.tsx @@ -94,6 +94,9 @@ export default function Recollects() { const [totalCount, setTotalCount] = useState(0); const [useSearch, setUseSearch] = useState(false); + // 실패건 수 (jobExecutionId → count) + const [failedRecordCounts, setFailedRecordCounts] = useState>({}); + // 실패 로그 모달 const [failLogTarget, setFailLogTarget] = useState(null); @@ -256,6 +259,7 @@ export default function Recollects() { setHistories(data.content); setTotalPages(data.totalPages); setTotalCount(data.totalElements); + setFailedRecordCounts(data.failedRecordCounts ?? {}); if (!useSearch) setPage(data.number); } catch { setHistories([]); @@ -684,6 +688,7 @@ export default function Recollects() { 재수집 시작일시 재수집 종료일시 소요시간 + 실패건 액션 @@ -727,6 +732,23 @@ export default function Recollects() { {formatDuration(hist.durationMs)} + + {(() => { + const count = hist.jobExecutionId + ? (failedRecordCounts[hist.jobExecutionId] ?? 0) + : 0; + if (hist.executionStatus === 'STARTED') { + return -; + } + return count > 0 ? ( + + {count}건 + + ) : ( + 0 + ); + })()} +