import { useState, useCallback } from 'react';
import { useParams, useSearchParams, useNavigate } from 'react-router-dom';
import { batchApi, type JobExecutionDetailDto, type StepExecutionDto, type FailedRecordDto } from '../api/batchApi';
import { formatDateTime, formatDuration, calculateDuration } from '../utils/formatters';
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';
import DetailStatCard from '../components/DetailStatCard';
import ApiLogSection from '../components/ApiLogSection';
import InfoItem from '../components/InfoItem';
import GuideModal, { HelpButton } from '../components/GuideModal';
const POLLING_INTERVAL_MS = 5000;
const EXECUTION_DETAIL_GUIDE = [
{
title: '실행 기본 정보',
content: '실행의 시작/종료 시간, 소요 시간, 종료 코드, 에러 메시지 등 기본 정보를 보여줍니다.\n실행 중인 경우 5초마다 자동으로 갱신됩니다.',
},
{
title: '처리 통계',
content: '4개의 통계 카드로 전체 처리 현황을 요약합니다.\n• 읽기(Read): 외부 API에서 조회한 건수\n• 쓰기(Write): DB에 저장된 건수\n• 건너뜀(Skip): 처리하지 않은 건수\n• 필터(Filter): 조건에 의해 제외된 건수',
},
{
title: 'Step 실행 정보',
content: '배치 작업은 하나 이상의 Step으로 구성됩니다.\n각 Step의 상태, 처리 건수, 커밋/롤백 횟수를 확인할 수 있습니다.\nAPI 호출 정보에서는 총 호출 수, 성공/에러 수, 평균 응답 시간을 보여줍니다.',
},
{
title: 'API 호출 로그',
content: '각 Step에서 호출한 외부 API의 상세 로그를 확인할 수 있습니다.\n요청 URL, 응답 코드, 응답 시간 등을 페이지 단위로 조회합니다.',
},
{
title: '실패 건 관리',
content: '처리 중 실패한 레코드가 있으면 목록으로 표시됩니다.\n• 실패 건 재수집: 실패한 데이터를 다시 수집합니다\n• 일괄 RESOLVED: 모든 실패 건을 해결됨으로 처리합니다\n• 재시도 초기화: 재시도 횟수를 초기화하여 자동 재수집 대상에 포함시킵니다',
},
];
interface StepCardProps {
step: StepExecutionDto;
jobName: string;
jobExecutionId: number;
}
function StepCard({ step, jobName, jobExecutionId }: StepCardProps) {
const stats = [
{ label: '읽기', value: step.readCount },
{ label: '쓰기', value: step.writeCount },
{ label: '커밋', value: step.commitCount },
{ label: '롤백', value: step.rollbackCount },
{ label: '읽기 건너뜀', value: step.readSkipCount },
{ label: '처리 건너뜀', value: step.processSkipCount },
{ label: '쓰기 건너뜀', value: step.writeSkipCount },
{ label: '필터', value: step.filterCount },
];
return (
{step.stepName}
{step.duration != null
? formatDuration(step.duration)
: calculateDuration(step.startTime, step.endTime)}
시작: {formatDateTime(step.startTime)}
종료: {formatDateTime(step.endTime)}
{stats.map(({ label, value }) => (
{value.toLocaleString()}
{label}
))}
{/* API 호출 정보: apiLogSummary가 있으면 개별 로그 리스트, 없으면 기존 apiCallInfo 요약 */}
{step.apiLogSummary ? (
API 호출 정보
{step.apiLogSummary.totalCalls.toLocaleString()}
총 호출
{step.apiLogSummary.successCount.toLocaleString()}
성공
0 ? 'text-red-500' : 'text-wing-text'}`}>
{step.apiLogSummary.errorCount.toLocaleString()}
에러
{Math.round(step.apiLogSummary.avgResponseMs).toLocaleString()}
평균(ms)
{step.apiLogSummary.maxResponseMs.toLocaleString()}
최대(ms)
{step.apiLogSummary.minResponseMs.toLocaleString()}
최소(ms)
{step.apiLogSummary.totalCalls > 0 && (
)}
) : step.apiCallInfo && (
API 호출 정보
URL:{' '}
{step.apiCallInfo.apiUrl}
Method:{' '}
{step.apiCallInfo.method}
호출:{' '}
{step.apiCallInfo.completedCalls} / {step.apiCallInfo.totalCalls}
{step.apiCallInfo.lastCallTime && (
최종:{' '}
{step.apiCallInfo.lastCallTime}
)}
)}
{/* 호출 실패 데이터 토글 */}
{step.failedRecords && step.failedRecords.length > 0 && (
)}
{step.exitMessage && (
Exit Message
{step.exitMessage}
)}
);
}
export default function ExecutionDetail() {
const { id: paramId } = useParams<{ id: string }>();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const executionId = paramId
? Number(paramId)
: Number(searchParams.get('id'));
const [detail, setDetail] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [guideOpen, setGuideOpen] = useState(false);
const isRunning = detail
? detail.status === 'STARTED' || detail.status === 'STARTING'
: false;
const loadDetail = useCallback(async () => {
if (!executionId || isNaN(executionId)) {
setError('유효하지 않은 실행 ID입니다.');
setLoading(false);
return;
}
try {
const data = await batchApi.getExecutionDetail(executionId);
setDetail(data);
setError(null);
} catch (err) {
setError(
err instanceof Error
? err.message
: '실행 상세 정보를 불러오지 못했습니다.',
);
} finally {
setLoading(false);
}
}, [executionId]);
/* 실행중인 경우 5초 폴링, 완료 후에는 1회 로드로 충분하지만 폴링 유지 */
usePoller(loadDetail, isRunning ? POLLING_INTERVAL_MS : 30_000, [
executionId,
]);
if (loading) return ;
if (error || !detail) {
return (
);
}
const jobParams = Object.entries(detail.jobParameters);
return (
{/* 상단 내비게이션 */}
{/* Job 기본 정보 */}
실행 #{detail.executionId}
setGuideOpen(true)} />
{detail.jobName}
{detail.exitMessage && (
)}
{/* 실행 통계 카드 4개 */}
{/* Job Parameters */}
{jobParams.length > 0 && (
Job Parameters
| Key |
Value |
{jobParams.map(([key, value]) => (
|
{key}
|
{value}
|
))}
)}
{/* Step 실행 정보 */}
Step 실행 정보
({detail.stepExecutions.length}개)
{detail.stepExecutions.length === 0 ? (
) : (
{detail.stepExecutions.map((step) => (
))}
)}
setGuideOpen(false)}
pageTitle="실행 상세"
sections={EXECUTION_DETAIL_GUIDE}
/>
);
}
const FAILED_PAGE_SIZE = 10;
function FailedRecordsToggle({ records, jobName, jobExecutionId }: { records: FailedRecordDto[]; jobName: string; jobExecutionId: number }) {
const [open, setOpen] = useState(false);
const [showConfirm, setShowConfirm] = useState(false);
const [showResolveConfirm, setShowResolveConfirm] = useState(false);
const [retrying, setRetrying] = useState(false);
const [resolving, setResolving] = useState(false);
const [showResetConfirm, setShowResetConfirm] = useState(false);
const [resetting, setResetting] = 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) {
case 'RESOLVED': return 'text-emerald-600 bg-emerald-50';
case 'RETRY_PENDING': return 'text-amber-600 bg-amber-50';
default: return 'text-red-600 bg-red-50';
}
};
const MAX_RETRY_COUNT = 3;
const retryStatusLabel = (record: FailedRecordDto) => {
if (record.status !== 'FAILED') return null;
if (record.retryCount >= MAX_RETRY_COUNT) return { label: '재시도 초과', color: 'text-red-600 bg-red-100' };
if (record.retryCount > 0) return { label: `재시도 ${record.retryCount}/${MAX_RETRY_COUNT}`, color: 'text-amber-600 bg-amber-100' };
return { label: '대기', color: 'text-blue-600 bg-blue-100' };
};
const exceededRecords = failedRecords.filter((r) => r.retryCount >= MAX_RETRY_COUNT);
const handleRetry = async () => {
setRetrying(true);
try {
const result = await batchApi.retryFailedRecords(jobName, failedRecords.length, jobExecutionId);
if (result.success) {
setShowConfirm(false);
if (result.executionId) {
navigate(`/executions/${result.executionId}`);
} else {
alert(result.message || '재수집이 요청되었습니다.');
}
} else {
alert(result.message || '재수집 실행에 실패했습니다.');
}
} catch {
alert('재수집 실행에 실패했습니다.');
} finally {
setRetrying(false);
}
};
const handleResolve = async () => {
setResolving(true);
try {
const ids = failedRecords.map((r) => r.id);
await batchApi.resolveFailedRecords(ids);
setShowResolveConfirm(false);
navigate(0);
} catch {
alert('일괄 RESOLVED 처리에 실패했습니다.');
} finally {
setResolving(false);
}
};
const handleResetRetry = async () => {
setResetting(true);
try {
const ids = exceededRecords.map((r) => r.id);
await batchApi.resetRetryCount(ids);
setShowResetConfirm(false);
navigate(0);
} catch {
alert('재시도 초기화에 실패했습니다.');
} finally {
setResetting(false);
}
};
return (
{failedRecords.length > 0 && (
{exceededRecords.length > 0 && (
)}
)}
{open && (
| Record Key |
에러 메시지 |
재시도 |
상태 |
생성 시간 |
{pagedRecords.map((record) => (
|
{record.recordKey}
|
{record.errorMessage || '-'}
|
{(() => {
const info = retryStatusLabel(record);
return info ? (
{info.label}
) : (
-
);
})()}
|
{record.status}
|
{formatDateTime(record.createdAt)}
|
))}
)}
{/* 재수집 확인 다이얼로그 */}
{showConfirm && (
실패 건 재수집 확인
다음 {failedRecords.length}건의 IMO에 대해 재수집을 실행합니다.
{failedRecords.map((r) => (
{r.recordKey}
))}
)}
{/* 일괄 RESOLVED 확인 다이얼로그 */}
{showResolveConfirm && (
일괄 RESOLVED 확인
FAILED 상태의 {failedRecords.length}건을 RESOLVED로 변경합니다.
이 작업은 되돌릴 수 없습니다.
)}
{/* 재시도 초기화 확인 다이얼로그 */}
{showResetConfirm && (
재시도 초기화 확인
재시도 횟수를 초과한 {exceededRecords.length}건의 retryCount를 0으로 초기화합니다.
초기화 후 다음 배치 실행 시 자동 재수집 대상에 포함됩니다.
)}
);
}