- retryRecordKeys URL 파라미터 제거 (서버에서 DB 조회로 대체) - sourceStepExecutionId → sourceJobExecutionId로 변경 - FailedRecordsToggle에 jobExecutionId 전달
709 lines
35 KiB
TypeScript
709 lines
35 KiB
TypeScript
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 (
|
|
<div className="bg-wing-surface rounded-xl shadow-md p-6">
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between mb-4">
|
|
<div className="flex items-center gap-3">
|
|
<h3 className="text-base font-semibold text-wing-text">
|
|
{step.stepName}
|
|
</h3>
|
|
<StatusBadge status={step.status} />
|
|
</div>
|
|
<span className="text-sm text-wing-muted">
|
|
{step.duration != null
|
|
? formatDuration(step.duration)
|
|
: calculateDuration(step.startTime, step.endTime)}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-2 text-sm mb-3">
|
|
<div className="text-wing-muted">
|
|
시작: <span className="text-wing-text">{formatDateTime(step.startTime)}</span>
|
|
</div>
|
|
<div className="text-wing-muted">
|
|
종료: <span className="text-wing-text">{formatDateTime(step.endTime)}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
|
{stats.map(({ label, value }) => (
|
|
<div
|
|
key={label}
|
|
className="rounded-lg bg-wing-card px-3 py-2 text-center"
|
|
>
|
|
<p className="text-lg font-bold text-wing-text">
|
|
{value.toLocaleString()}
|
|
</p>
|
|
<p className="text-xs text-wing-muted">{label}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* API 호출 정보: apiLogSummary가 있으면 개별 로그 리스트, 없으면 기존 apiCallInfo 요약 */}
|
|
{step.apiLogSummary ? (
|
|
<div className="mt-4 rounded-lg bg-blue-50 border border-blue-200 p-3">
|
|
<p className="text-xs font-medium text-blue-700 mb-2">API 호출 정보</p>
|
|
<div className="grid grid-cols-3 sm:grid-cols-6 gap-2">
|
|
<div className="rounded bg-white px-2 py-1.5 text-center">
|
|
<p className="text-sm font-bold text-wing-text">{step.apiLogSummary.totalCalls.toLocaleString()}</p>
|
|
<p className="text-[10px] text-wing-muted">총 호출</p>
|
|
</div>
|
|
<div className="rounded bg-white px-2 py-1.5 text-center">
|
|
<p className="text-sm font-bold text-emerald-600">{step.apiLogSummary.successCount.toLocaleString()}</p>
|
|
<p className="text-[10px] text-wing-muted">성공</p>
|
|
</div>
|
|
<div className="rounded bg-white px-2 py-1.5 text-center">
|
|
<p className={`text-sm font-bold ${step.apiLogSummary.errorCount > 0 ? 'text-red-500' : 'text-wing-text'}`}>
|
|
{step.apiLogSummary.errorCount.toLocaleString()}
|
|
</p>
|
|
<p className="text-[10px] text-wing-muted">에러</p>
|
|
</div>
|
|
<div className="rounded bg-white px-2 py-1.5 text-center">
|
|
<p className="text-sm font-bold text-blue-600">{Math.round(step.apiLogSummary.avgResponseMs).toLocaleString()}</p>
|
|
<p className="text-[10px] text-wing-muted">평균(ms)</p>
|
|
</div>
|
|
<div className="rounded bg-white px-2 py-1.5 text-center">
|
|
<p className="text-sm font-bold text-red-500">{step.apiLogSummary.maxResponseMs.toLocaleString()}</p>
|
|
<p className="text-[10px] text-wing-muted">최대(ms)</p>
|
|
</div>
|
|
<div className="rounded bg-white px-2 py-1.5 text-center">
|
|
<p className="text-sm font-bold text-emerald-500">{step.apiLogSummary.minResponseMs.toLocaleString()}</p>
|
|
<p className="text-[10px] text-wing-muted">최소(ms)</p>
|
|
</div>
|
|
</div>
|
|
|
|
{step.apiLogSummary.totalCalls > 0 && (
|
|
<ApiLogSection stepExecutionId={step.stepExecutionId} summary={step.apiLogSummary} />
|
|
)}
|
|
</div>
|
|
) : step.apiCallInfo && (
|
|
<div className="mt-4 rounded-lg bg-blue-50 border border-blue-200 p-3">
|
|
<p className="text-xs font-medium text-blue-700 mb-2">API 호출 정보</p>
|
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 text-xs">
|
|
<div>
|
|
<span className="text-blue-500">URL:</span>{' '}
|
|
<span className="text-blue-900 font-mono break-all">{step.apiCallInfo.apiUrl}</span>
|
|
</div>
|
|
<div>
|
|
<span className="text-blue-500">Method:</span>{' '}
|
|
<span className="text-blue-900 font-semibold">{step.apiCallInfo.method}</span>
|
|
</div>
|
|
<div>
|
|
<span className="text-blue-500">호출:</span>{' '}
|
|
<span className="text-blue-900">{step.apiCallInfo.completedCalls} / {step.apiCallInfo.totalCalls}</span>
|
|
</div>
|
|
{step.apiCallInfo.lastCallTime && (
|
|
<div>
|
|
<span className="text-blue-500">최종:</span>{' '}
|
|
<span className="text-blue-900">{step.apiCallInfo.lastCallTime}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 호출 실패 데이터 토글 */}
|
|
{step.failedRecords && step.failedRecords.length > 0 && (
|
|
<FailedRecordsToggle records={step.failedRecords} jobName={jobName} jobExecutionId={jobExecutionId} />
|
|
)}
|
|
|
|
{step.exitMessage && (
|
|
<div className="mt-4 rounded-lg bg-red-50 border border-red-200 p-3">
|
|
<p className="text-xs font-medium text-red-700 mb-1">Exit Message</p>
|
|
<p className="text-xs text-red-600 whitespace-pre-wrap break-words">
|
|
{step.exitMessage}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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<JobExecutionDetailDto | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(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 <LoadingSpinner />;
|
|
|
|
if (error || !detail) {
|
|
return (
|
|
<div className="space-y-4">
|
|
<button
|
|
onClick={() => navigate('/executions')}
|
|
className="inline-flex items-center gap-1 text-sm text-wing-muted hover:text-wing-text transition-colors"
|
|
>
|
|
<span>←</span> 목록으로
|
|
</button>
|
|
<EmptyState
|
|
icon="⚠"
|
|
message={error || '실행 정보를 찾을 수 없습니다.'}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const jobParams = Object.entries(detail.jobParameters);
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* 상단 내비게이션 */}
|
|
<button
|
|
onClick={() => navigate(-1)}
|
|
className="inline-flex items-center gap-1 text-sm text-wing-muted hover:text-wing-text transition-colors"
|
|
>
|
|
<span>←</span> 목록으로
|
|
</button>
|
|
|
|
{/* Job 기본 정보 */}
|
|
<div className="bg-wing-surface rounded-xl shadow-md p-6">
|
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
|
<div>
|
|
<div className="flex items-center gap-2">
|
|
<h1 className="text-2xl font-bold text-wing-text">
|
|
실행 #{detail.executionId}
|
|
</h1>
|
|
<HelpButton onClick={() => setGuideOpen(true)} />
|
|
</div>
|
|
<p className="mt-1 text-sm text-wing-muted">
|
|
{detail.jobName}
|
|
</p>
|
|
</div>
|
|
<StatusBadge status={detail.status} className="text-sm" />
|
|
</div>
|
|
|
|
<div className="mt-6 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 text-sm">
|
|
<InfoItem label="시작시간" value={formatDateTime(detail.startTime)} />
|
|
<InfoItem label="종료시간" value={formatDateTime(detail.endTime)} />
|
|
<InfoItem
|
|
label="소요시간"
|
|
value={
|
|
detail.duration != null
|
|
? formatDuration(detail.duration)
|
|
: calculateDuration(detail.startTime, detail.endTime)
|
|
}
|
|
/>
|
|
<InfoItem label="Exit Code" value={detail.exitCode} />
|
|
{detail.exitMessage && (
|
|
<div className="sm:col-span-2 lg:col-span-3">
|
|
<InfoItem label="Exit Message" value={detail.exitMessage} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 실행 통계 카드 4개 */}
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
<DetailStatCard
|
|
label="읽기 (Read)"
|
|
value={detail.readCount}
|
|
gradient="bg-gradient-to-br from-blue-500 to-blue-600"
|
|
icon="📥"
|
|
/>
|
|
<DetailStatCard
|
|
label="쓰기 (Write)"
|
|
value={detail.writeCount}
|
|
gradient="bg-gradient-to-br from-emerald-500 to-emerald-600"
|
|
icon="📤"
|
|
/>
|
|
<DetailStatCard
|
|
label="건너뜀 (Skip)"
|
|
value={detail.skipCount}
|
|
gradient="bg-gradient-to-br from-amber-500 to-amber-600"
|
|
icon="⏭"
|
|
/>
|
|
<DetailStatCard
|
|
label="필터 (Filter)"
|
|
value={detail.filterCount}
|
|
gradient="bg-gradient-to-br from-purple-500 to-purple-600"
|
|
icon="🔍"
|
|
/>
|
|
</div>
|
|
|
|
{/* Job Parameters */}
|
|
{jobParams.length > 0 && (
|
|
<div className="bg-wing-surface rounded-xl shadow-md p-6">
|
|
<h2 className="text-lg font-semibold text-wing-text mb-4">
|
|
Job Parameters
|
|
</h2>
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm text-left">
|
|
<thead className="bg-wing-card text-xs uppercase text-wing-muted">
|
|
<tr>
|
|
<th className="px-6 py-3 font-medium">Key</th>
|
|
<th className="px-6 py-3 font-medium">Value</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-wing-border/50">
|
|
{jobParams.map(([key, value]) => (
|
|
<tr
|
|
key={key}
|
|
className="hover:bg-wing-hover transition-colors"
|
|
>
|
|
<td className="px-6 py-3 font-mono text-wing-text">
|
|
{key}
|
|
</td>
|
|
<td className="px-6 py-3 text-wing-muted break-all">
|
|
{value}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Step 실행 정보 */}
|
|
<div>
|
|
<h2 className="text-lg font-semibold text-wing-text mb-4">
|
|
Step 실행 정보
|
|
<span className="ml-2 text-sm font-normal text-wing-muted">
|
|
({detail.stepExecutions.length}개)
|
|
</span>
|
|
</h2>
|
|
{detail.stepExecutions.length === 0 ? (
|
|
<EmptyState message="Step 실행 정보가 없습니다." />
|
|
) : (
|
|
<div className="space-y-4">
|
|
{detail.stepExecutions.map((step) => (
|
|
<StepCard
|
|
key={step.stepExecutionId}
|
|
step={step}
|
|
jobName={detail.jobName}
|
|
jobExecutionId={executionId}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<GuideModal
|
|
open={guideOpen}
|
|
onClose={() => setGuideOpen(false)}
|
|
pageTitle="실행 상세"
|
|
sections={EXECUTION_DETAIL_GUIDE}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div className="mt-4 rounded-lg bg-red-50 border border-red-200 p-3">
|
|
<div className="flex items-center justify-between">
|
|
<button
|
|
onClick={() => setOpen((v) => !v)}
|
|
className="inline-flex items-center gap-1 text-xs font-medium text-red-600 hover:text-red-800 transition-colors"
|
|
>
|
|
<svg
|
|
className={`w-3 h-3 transition-transform ${open ? 'rotate-90' : ''}`}
|
|
fill="none" viewBox="0 0 24 24" stroke="currentColor"
|
|
>
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
|
</svg>
|
|
호출 실패 데이터 ({records.length.toLocaleString()}건, FAILED {failedRecords.length}건)
|
|
</button>
|
|
|
|
{failedRecords.length > 0 && (
|
|
<div className="flex items-center gap-1.5">
|
|
{exceededRecords.length > 0 && (
|
|
<button
|
|
onClick={() => setShowResetConfirm(true)}
|
|
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-medium text-amber-700 bg-amber-50 hover:bg-amber-100 border border-amber-200 rounded-md transition-colors"
|
|
>
|
|
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
|
</svg>
|
|
재시도 초기화 ({exceededRecords.length}건)
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={() => setShowResolveConfirm(true)}
|
|
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-medium text-emerald-700 bg-emerald-50 hover:bg-emerald-100 border border-emerald-200 rounded-md transition-colors"
|
|
>
|
|
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
|
</svg>
|
|
일괄 RESOLVED ({failedRecords.length}건)
|
|
</button>
|
|
<button
|
|
onClick={() => setShowConfirm(true)}
|
|
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-medium text-white bg-red-500 hover:bg-red-600 rounded-md transition-colors"
|
|
>
|
|
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
|
</svg>
|
|
실패 건 재수집 ({failedRecords.length}건)
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{open && (
|
|
<div className="mt-2">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-xs text-left">
|
|
<thead className="bg-red-100 text-red-700">
|
|
<tr>
|
|
<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 text-center">재시도</th>
|
|
<th className="px-2 py-1.5 font-medium text-center">상태</th>
|
|
<th className="px-2 py-1.5 font-medium">생성 시간</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-red-100">
|
|
{pagedRecords.map((record) => (
|
|
<tr
|
|
key={record.id}
|
|
className="bg-white hover:bg-red-50"
|
|
>
|
|
<td className="px-2 py-1.5 font-mono text-red-900">
|
|
{record.recordKey}
|
|
</td>
|
|
<td className="px-2 py-1.5 text-red-600 max-w-[200px] truncate" title={record.errorMessage || ''}>
|
|
{record.errorMessage || '-'}
|
|
</td>
|
|
<td className="px-2 py-1.5 text-center">
|
|
{(() => {
|
|
const info = retryStatusLabel(record);
|
|
return info ? (
|
|
<span className={`inline-flex px-1.5 py-0.5 text-[10px] font-medium rounded-full ${info.color}`}>
|
|
{info.label}
|
|
</span>
|
|
) : (
|
|
<span className="text-wing-muted">-</span>
|
|
);
|
|
})()}
|
|
</td>
|
|
<td className="px-2 py-1.5 text-center">
|
|
<span className={`inline-flex px-1.5 py-0.5 text-[10px] font-medium rounded-full ${statusColor(record.status)}`}>
|
|
{record.status}
|
|
</span>
|
|
</td>
|
|
<td className="px-2 py-1.5 text-red-500 whitespace-nowrap">
|
|
{formatDateTime(record.createdAt)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<Pagination
|
|
page={page}
|
|
totalPages={totalPages}
|
|
totalElements={records.length}
|
|
pageSize={FAILED_PAGE_SIZE}
|
|
onPageChange={setPage}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* 재수집 확인 다이얼로그 */}
|
|
{showConfirm && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
|
<div className="bg-white rounded-xl shadow-2xl p-6 w-full max-w-md mx-4">
|
|
<h3 className="text-lg font-semibold text-wing-text mb-2">
|
|
실패 건 재수집 확인
|
|
</h3>
|
|
<p className="text-sm text-wing-muted mb-3">
|
|
다음 {failedRecords.length}건의 IMO에 대해 재수집을 실행합니다.
|
|
</p>
|
|
<div className="bg-gray-50 rounded-lg p-3 mb-4 max-h-40 overflow-y-auto">
|
|
<div className="flex flex-wrap gap-1">
|
|
{failedRecords.map((r) => (
|
|
<span
|
|
key={r.id}
|
|
className="inline-flex px-2 py-0.5 text-xs font-mono bg-red-100 text-red-700 rounded"
|
|
>
|
|
{r.recordKey}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="flex justify-end gap-2">
|
|
<button
|
|
onClick={() => setShowConfirm(false)}
|
|
disabled={retrying}
|
|
className="px-4 py-2 text-sm font-medium text-wing-muted bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors disabled:opacity-50"
|
|
>
|
|
취소
|
|
</button>
|
|
<button
|
|
onClick={handleRetry}
|
|
disabled={retrying}
|
|
className="px-4 py-2 text-sm font-medium text-white bg-red-500 hover:bg-red-600 rounded-lg transition-colors disabled:opacity-50 inline-flex items-center gap-1"
|
|
>
|
|
{retrying ? (
|
|
<>
|
|
<div className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
|
실행 중...
|
|
</>
|
|
) : (
|
|
'재수집 실행'
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 일괄 RESOLVED 확인 다이얼로그 */}
|
|
{showResolveConfirm && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
|
<div className="bg-white rounded-xl shadow-2xl p-6 w-full max-w-md mx-4">
|
|
<h3 className="text-lg font-semibold text-wing-text mb-2">
|
|
일괄 RESOLVED 확인
|
|
</h3>
|
|
<p className="text-sm text-wing-muted mb-4">
|
|
FAILED 상태의 {failedRecords.length}건을 RESOLVED로 변경합니다.
|
|
이 작업은 되돌릴 수 없습니다.
|
|
</p>
|
|
<div className="flex justify-end gap-2">
|
|
<button
|
|
onClick={() => setShowResolveConfirm(false)}
|
|
disabled={resolving}
|
|
className="px-4 py-2 text-sm font-medium text-wing-muted bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors disabled:opacity-50"
|
|
>
|
|
취소
|
|
</button>
|
|
<button
|
|
onClick={handleResolve}
|
|
disabled={resolving}
|
|
className="px-4 py-2 text-sm font-medium text-white bg-emerald-500 hover:bg-emerald-600 rounded-lg transition-colors disabled:opacity-50 inline-flex items-center gap-1"
|
|
>
|
|
{resolving ? (
|
|
<>
|
|
<div className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
|
처리 중...
|
|
</>
|
|
) : (
|
|
'RESOLVED 처리'
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 재시도 초기화 확인 다이얼로그 */}
|
|
{showResetConfirm && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
|
<div className="bg-white rounded-xl shadow-2xl p-6 w-full max-w-md mx-4">
|
|
<h3 className="text-lg font-semibold text-wing-text mb-2">
|
|
재시도 초기화 확인
|
|
</h3>
|
|
<p className="text-sm text-wing-muted mb-4">
|
|
재시도 횟수를 초과한 {exceededRecords.length}건의 retryCount를 0으로 초기화합니다.
|
|
초기화 후 다음 배치 실행 시 자동 재수집 대상에 포함됩니다.
|
|
</p>
|
|
<div className="flex justify-end gap-2">
|
|
<button
|
|
onClick={() => setShowResetConfirm(false)}
|
|
disabled={resetting}
|
|
className="px-4 py-2 text-sm font-medium text-wing-muted bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors disabled:opacity-50"
|
|
>
|
|
취소
|
|
</button>
|
|
<button
|
|
onClick={handleResetRetry}
|
|
disabled={resetting}
|
|
className="px-4 py-2 text-sm font-medium text-white bg-amber-500 hover:bg-amber-600 rounded-lg transition-colors disabled:opacity-50 inline-flex items-center gap-1"
|
|
>
|
|
{resetting ? (
|
|
<>
|
|
<div className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
|
처리 중...
|
|
</>
|
|
) : (
|
|
'초기화 실행'
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|