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

{jobParams.map(([key, value]) => ( ))}
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 && (
{pagedRecords.map((record) => ( ))}
Record Key 에러 메시지 재시도 상태 생성 시간
{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으로 초기화합니다. 초기화 후 다음 배치 실행 시 자동 재수집 대상에 포함됩니다.

)}
); }