Compare commits
No commits in common. "70c6bbb07d337d2d2a1c1d141ffed9c172bb794d" and "43c28eeccd2c4d8292e3cdbb6e59fc322ae4eebb" have entirely different histories.
70c6bbb07d
...
43c28eeccd
3
.gitignore
vendored
3
.gitignore
vendored
@ -34,9 +34,6 @@ dependency-reduced-pom.xml
|
|||||||
buildNumber.properties
|
buildNumber.properties
|
||||||
.mvn/timing.properties
|
.mvn/timing.properties
|
||||||
.mvn/wrapper/maven-wrapper.jar
|
.mvn/wrapper/maven-wrapper.jar
|
||||||
.mvn/wrapper/maven-wrapper.properties
|
|
||||||
mvnw
|
|
||||||
mvnw.cmd
|
|
||||||
|
|
||||||
# Gradle
|
# Gradle
|
||||||
.gradle/
|
.gradle/
|
||||||
|
|||||||
@ -287,7 +287,6 @@ export interface RecollectionSearchResponse {
|
|||||||
number: number;
|
number: number;
|
||||||
size: number;
|
size: number;
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
failedRecordCounts: Record<number, number>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RecollectionStatsResponse {
|
export interface RecollectionStatsResponse {
|
||||||
|
|||||||
@ -1,145 +0,0 @@
|
|||||||
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,7 +6,6 @@ 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;
|
||||||
|
|
||||||
@ -93,7 +92,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: 10, status: s });
|
const data = await batchApi.getStepApiLogs(stepExecutionId, { page: p, size: 50, status: s });
|
||||||
setLogData(data);
|
setLogData(data);
|
||||||
} catch {
|
} catch {
|
||||||
setLogData(null);
|
setLogData(null);
|
||||||
@ -164,7 +163,7 @@ function ApiLogSection({ stepExecutionId, summary }: ApiLogSectionProps) {
|
|||||||
</div>
|
</div>
|
||||||
) : logData && logData.content.length > 0 ? (
|
) : logData && logData.content.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto max-h-80 overflow-y-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>
|
||||||
@ -186,7 +185,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 * 10 + idx + 1}</td>
|
<td className="px-2 py-1.5 text-blue-500">{page * 50 + 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}>
|
||||||
@ -226,13 +225,33 @@ function ApiLogSection({ stepExecutionId, summary }: ApiLogSectionProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 페이지네이션 */}
|
{/* 페이지네이션 */}
|
||||||
<Pagination
|
{logData.totalPages > 1 && (
|
||||||
page={page}
|
<div className="flex items-center justify-between mt-2 text-xs text-wing-muted">
|
||||||
totalPages={logData.totalPages}
|
<span>
|
||||||
totalElements={logData.totalElements}
|
{logData.totalElements.toLocaleString()}건 중{' '}
|
||||||
pageSize={10}
|
{(page * 50 + 1).toLocaleString()}~{Math.min((page + 1) * 50, logData.totalElements).toLocaleString()}
|
||||||
onPageChange={setPage}
|
</span>
|
||||||
/>
|
<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>
|
||||||
@ -575,18 +594,13 @@ export default function ExecutionDetail() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const FAILED_PAGE_SIZE = 10;
|
|
||||||
|
|
||||||
function FailedRecordsToggle({ records, jobName, stepExecutionId }: { records: FailedRecordDto[]; jobName: string; stepExecutionId: number }) {
|
function FailedRecordsToggle({ records, jobName, stepExecutionId }: { records: FailedRecordDto[]; jobName: string; stepExecutionId: number }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [showConfirm, setShowConfirm] = useState(false);
|
const [showConfirm, setShowConfirm] = useState(false);
|
||||||
const [retrying, setRetrying] = useState(false);
|
const [retrying, setRetrying] = useState(false);
|
||||||
const [page, setPage] = useState(0);
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const failedRecords = records.filter((r) => r.status === 'FAILED');
|
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) => {
|
const statusColor = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
@ -642,10 +656,9 @@ function FailedRecordsToggle({ records, jobName, stepExecutionId }: { records: F
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{open && (
|
{open && (
|
||||||
<div className="mt-2">
|
<div className="mt-2 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-red-100 text-red-700">
|
<thead className="bg-red-100 text-red-700 sticky top-0">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-2 py-1.5 font-medium">Record Key</th>
|
<th className="px-2 py-1.5 font-medium">Record Key</th>
|
||||||
<th className="px-2 py-1.5 font-medium">에러 메시지</th>
|
<th className="px-2 py-1.5 font-medium">에러 메시지</th>
|
||||||
@ -655,7 +668,7 @@ function FailedRecordsToggle({ records, jobName, stepExecutionId }: { records: F
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-red-100">
|
<tbody className="divide-y divide-red-100">
|
||||||
{pagedRecords.map((record) => (
|
{records.map((record) => (
|
||||||
<tr
|
<tr
|
||||||
key={record.id}
|
key={record.id}
|
||||||
className="bg-white hover:bg-red-50"
|
className="bg-white hover:bg-red-50"
|
||||||
@ -682,14 +695,6 @@ function FailedRecordsToggle({ records, jobName, stepExecutionId }: { records: F
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<Pagination
|
|
||||||
page={page}
|
|
||||||
totalPages={totalPages}
|
|
||||||
totalElements={records.length}
|
|
||||||
pageSize={FAILED_PAGE_SIZE}
|
|
||||||
onPageChange={setPage}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 확인 다이얼로그 */}
|
{/* 확인 다이얼로그 */}
|
||||||
|
|||||||
@ -13,7 +13,6 @@ 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;
|
||||||
|
|
||||||
@ -98,7 +97,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: 10, status: s });
|
const data = await batchApi.getStepApiLogs(stepExecutionId, { page: p, size: 50, status: s });
|
||||||
setLogData(data);
|
setLogData(data);
|
||||||
} catch {
|
} catch {
|
||||||
setLogData(null);
|
setLogData(null);
|
||||||
@ -169,7 +168,7 @@ function ApiLogSection({ stepExecutionId, summary }: ApiLogSectionProps) {
|
|||||||
</div>
|
</div>
|
||||||
) : logData && logData.content.length > 0 ? (
|
) : logData && logData.content.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto max-h-80 overflow-y-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>
|
||||||
@ -191,7 +190,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 * 10 + idx + 1}</td>
|
<td className="px-2 py-1.5 text-blue-500">{page * 50 + 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}>
|
||||||
@ -231,13 +230,33 @@ function ApiLogSection({ stepExecutionId, summary }: ApiLogSectionProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 페이지네이션 */}
|
{/* 페이지네이션 */}
|
||||||
<Pagination
|
{logData.totalPages > 1 && (
|
||||||
page={page}
|
<div className="flex items-center justify-between mt-2 text-xs text-wing-muted">
|
||||||
totalPages={logData.totalPages}
|
<span>
|
||||||
totalElements={logData.totalElements}
|
{logData.totalElements.toLocaleString()}건 중{' '}
|
||||||
pageSize={10}
|
{(page * 50 + 1).toLocaleString()}~{Math.min((page + 1) * 50, logData.totalElements).toLocaleString()}
|
||||||
onPageChange={setPage}
|
</span>
|
||||||
/>
|
<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>
|
||||||
@ -626,18 +645,13 @@ export default function RecollectDetail() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const FAILED_PAGE_SIZE = 10;
|
|
||||||
|
|
||||||
function FailedRecordsToggle({ records, jobName, stepExecutionId }: { records: FailedRecordDto[]; jobName: string; stepExecutionId: number }) {
|
function FailedRecordsToggle({ records, jobName, stepExecutionId }: { records: FailedRecordDto[]; jobName: string; stepExecutionId: number }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [showConfirm, setShowConfirm] = useState(false);
|
const [showConfirm, setShowConfirm] = useState(false);
|
||||||
const [retrying, setRetrying] = useState(false);
|
const [retrying, setRetrying] = useState(false);
|
||||||
const [page, setPage] = useState(0);
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const failedRecords = records.filter((r) => r.status === 'FAILED');
|
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) => {
|
const statusColor = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
@ -693,10 +707,9 @@ function FailedRecordsToggle({ records, jobName, stepExecutionId }: { records: F
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{open && (
|
{open && (
|
||||||
<div className="mt-2">
|
<div className="mt-2 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-red-100 text-red-700">
|
<thead className="bg-red-100 text-red-700 sticky top-0">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-2 py-1.5 font-medium">Record Key</th>
|
<th className="px-2 py-1.5 font-medium">Record Key</th>
|
||||||
<th className="px-2 py-1.5 font-medium">에러 메시지</th>
|
<th className="px-2 py-1.5 font-medium">에러 메시지</th>
|
||||||
@ -706,7 +719,7 @@ function FailedRecordsToggle({ records, jobName, stepExecutionId }: { records: F
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-red-100">
|
<tbody className="divide-y divide-red-100">
|
||||||
{pagedRecords.map((record) => (
|
{records.map((record) => (
|
||||||
<tr
|
<tr
|
||||||
key={record.id}
|
key={record.id}
|
||||||
className="bg-white hover:bg-red-50"
|
className="bg-white hover:bg-red-50"
|
||||||
@ -733,14 +746,6 @@ function FailedRecordsToggle({ records, jobName, stepExecutionId }: { records: F
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<Pagination
|
|
||||||
page={page}
|
|
||||||
totalPages={totalPages}
|
|
||||||
totalElements={records.length}
|
|
||||||
pageSize={FAILED_PAGE_SIZE}
|
|
||||||
onPageChange={setPage}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 확인 다이얼로그 */}
|
{/* 확인 다이얼로그 */}
|
||||||
|
|||||||
@ -94,9 +94,6 @@ export default function Recollects() {
|
|||||||
const [totalCount, setTotalCount] = useState(0);
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
const [useSearch, setUseSearch] = useState(false);
|
const [useSearch, setUseSearch] = useState(false);
|
||||||
|
|
||||||
// 실패건 수 (jobExecutionId → count)
|
|
||||||
const [failedRecordCounts, setFailedRecordCounts] = useState<Record<number, number>>({});
|
|
||||||
|
|
||||||
// 실패 로그 모달
|
// 실패 로그 모달
|
||||||
const [failLogTarget, setFailLogTarget] = useState<RecollectionHistoryDto | null>(null);
|
const [failLogTarget, setFailLogTarget] = useState<RecollectionHistoryDto | null>(null);
|
||||||
|
|
||||||
@ -259,7 +256,6 @@ export default function Recollects() {
|
|||||||
setHistories(data.content);
|
setHistories(data.content);
|
||||||
setTotalPages(data.totalPages);
|
setTotalPages(data.totalPages);
|
||||||
setTotalCount(data.totalElements);
|
setTotalCount(data.totalElements);
|
||||||
setFailedRecordCounts(data.failedRecordCounts ?? {});
|
|
||||||
if (!useSearch) setPage(data.number);
|
if (!useSearch) setPage(data.number);
|
||||||
} catch {
|
} catch {
|
||||||
setHistories([]);
|
setHistories([]);
|
||||||
@ -688,7 +684,6 @@ export default function Recollects() {
|
|||||||
<th className="px-4 py-3 font-medium">재수집 시작일시</th>
|
<th className="px-4 py-3 font-medium">재수집 시작일시</th>
|
||||||
<th className="px-4 py-3 font-medium">재수집 종료일시</th>
|
<th className="px-4 py-3 font-medium">재수집 종료일시</th>
|
||||||
<th className="px-4 py-3 font-medium">소요시간</th>
|
<th className="px-4 py-3 font-medium">소요시간</th>
|
||||||
<th className="px-4 py-3 font-medium text-center">실패건</th>
|
|
||||||
<th className="px-4 py-3 font-medium text-right">액션</th>
|
<th className="px-4 py-3 font-medium text-right">액션</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@ -732,23 +727,6 @@ export default function Recollects() {
|
|||||||
<td className="px-4 py-4 text-wing-muted whitespace-nowrap">
|
<td className="px-4 py-4 text-wing-muted whitespace-nowrap">
|
||||||
{formatDuration(hist.durationMs)}
|
{formatDuration(hist.durationMs)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-4 text-center">
|
|
||||||
{(() => {
|
|
||||||
const count = hist.jobExecutionId
|
|
||||||
? (failedRecordCounts[hist.jobExecutionId] ?? 0)
|
|
||||||
: 0;
|
|
||||||
if (hist.executionStatus === 'STARTED') {
|
|
||||||
return <span className="text-xs text-wing-muted">-</span>;
|
|
||||||
}
|
|
||||||
return count > 0 ? (
|
|
||||||
<span className="inline-flex items-center px-2 py-0.5 text-xs font-semibold rounded-full bg-red-100 text-red-700">
|
|
||||||
{count}건
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-xs text-wing-muted">0</span>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-4 text-right">
|
<td className="px-4 py-4 text-right">
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate(`/recollects/${hist.historyId}`)}
|
onClick={() => navigate(`/recollects/${hist.historyId}`)}
|
||||||
|
|||||||
@ -28,7 +28,6 @@ import java.time.format.DateTimeFormatter;
|
|||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
|
||||||
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@RestController
|
@RestController
|
||||||
@ -498,21 +497,12 @@ public class BatchController {
|
|||||||
Page<BatchRecollectionHistory> histories = recollectionHistoryService
|
Page<BatchRecollectionHistory> histories = recollectionHistoryService
|
||||||
.getHistories(apiKey, jobName, status, from, to, PageRequest.of(page, size));
|
.getHistories(apiKey, jobName, status, from, to, PageRequest.of(page, size));
|
||||||
|
|
||||||
// 목록의 jobExecutionId들로 실패건수 한번에 조회
|
|
||||||
List<Long> jobExecutionIds = histories.getContent().stream()
|
|
||||||
.map(BatchRecollectionHistory::getJobExecutionId)
|
|
||||||
.filter(Objects::nonNull)
|
|
||||||
.toList();
|
|
||||||
Map<Long, Long> failedRecordCounts = recollectionHistoryService
|
|
||||||
.getFailedRecordCounts(jobExecutionIds);
|
|
||||||
|
|
||||||
Map<String, Object> response = new HashMap<>();
|
Map<String, Object> response = new HashMap<>();
|
||||||
response.put("content", histories.getContent());
|
response.put("content", histories.getContent());
|
||||||
response.put("totalElements", histories.getTotalElements());
|
response.put("totalElements", histories.getTotalElements());
|
||||||
response.put("totalPages", histories.getTotalPages());
|
response.put("totalPages", histories.getTotalPages());
|
||||||
response.put("number", histories.getNumber());
|
response.put("number", histories.getNumber());
|
||||||
response.put("size", histories.getSize());
|
response.put("size", histories.getSize());
|
||||||
response.put("failedRecordCounts", failedRecordCounts);
|
|
||||||
return ResponseEntity.ok(response);
|
return ResponseEntity.ok(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -33,14 +33,6 @@ public interface BatchFailedRecordRepository extends JpaRepository<BatchFailedRe
|
|||||||
*/
|
*/
|
||||||
long countByJobExecutionId(Long jobExecutionId);
|
long countByJobExecutionId(Long jobExecutionId);
|
||||||
|
|
||||||
/**
|
|
||||||
* 여러 jobExecutionId에 대해 FAILED 상태 건수를 한번에 조회 (N+1 방지)
|
|
||||||
*/
|
|
||||||
@Query("SELECT r.jobExecutionId, COUNT(r) FROM BatchFailedRecord r " +
|
|
||||||
"WHERE r.jobExecutionId IN :jobExecutionIds AND r.status = 'FAILED' " +
|
|
||||||
"GROUP BY r.jobExecutionId")
|
|
||||||
List<Object[]> countFailedByJobExecutionIds(@Param("jobExecutionIds") List<Long> jobExecutionIds);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 특정 Step 실행의 실패 레코드를 RESOLVED로 벌크 업데이트
|
* 특정 Step 실행의 실패 레코드를 RESOLVED로 벌크 업데이트
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -330,21 +330,6 @@ public class RecollectionHistoryService {
|
|||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 재수집 이력 목록의 jobExecutionId별 FAILED 상태 실패건수 조회
|
|
||||||
*/
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public Map<Long, Long> getFailedRecordCounts(List<Long> jobExecutionIds) {
|
|
||||||
if (jobExecutionIds.isEmpty()) {
|
|
||||||
return Collections.emptyMap();
|
|
||||||
}
|
|
||||||
return failedRecordRepository.countFailedByJobExecutionIds(jobExecutionIds).stream()
|
|
||||||
.collect(Collectors.toMap(
|
|
||||||
row -> ((Number) row[0]).longValue(),
|
|
||||||
row -> ((Number) row[1]).longValue()
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 대시보드용 최근 10건
|
* 대시보드용 최근 10건
|
||||||
*/
|
*/
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user