snp-batch-validation/frontend/src/components/ApiLogSection.tsx
HYOJIN 2bc2f1fc32 feat(recollection): 자동 재수집 및 재수집 프로세스 전면 개선 (#30)
- 자동 재수집 리스너(AutoRetryJobExecutionListener) 및 비동기 트리거 서비스 추가
- 실패 레코드 최대 재시도 횟수(3회) 제한으로 무한 루프 방지
- 전용 스레드 풀(autoRetryExecutor) 분리
- last_success_date 복원 시 경합 조건 보호
- 재수집 이력 N+1 쿼리 해결 (벌크 조회)
- 실패 레코드 일괄 RESOLVED 처리 API 추가
- 재수집 이력 CSV 내보내기 API 추가 (UTF-8 BOM)
- 프론트엔드 공유 컴포넌트 추출 (StatCard, CopyButton, ApiLogSection, InfoItem)
- 대시보드 재수집 통계 위젯 추가
- 실행 이력 미해결 건수 COMPLETED 상태만 표시

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 17:28:23 +09:00

171 lines
9.4 KiB
TypeScript

import { useState, useCallback, useEffect } from 'react';
import { batchApi, type ApiLogPageResponse, type ApiLogStatus } from '../api/batchApi';
import { formatDateTime } from '../utils/formatters';
import Pagination from './Pagination';
import CopyButton from './CopyButton';
interface ApiLogSectionProps {
stepExecutionId: number;
summary: { totalCalls: number; successCount: number; errorCount: number };
}
export default function ApiLogSection({ stepExecutionId, summary }: ApiLogSectionProps) {
const [open, setOpen] = useState(false);
const [status, setStatus] = useState<ApiLogStatus>('ALL');
const [page, setPage] = useState(0);
const [logData, setLogData] = useState<ApiLogPageResponse | null>(null);
const [loading, setLoading] = useState(false);
const fetchLogs = useCallback(async (p: number, s: ApiLogStatus) => {
setLoading(true);
try {
const data = await batchApi.getStepApiLogs(stepExecutionId, { page: p, size: 10, status: s });
setLogData(data);
} catch {
setLogData(null);
} finally {
setLoading(false);
}
}, [stepExecutionId]);
useEffect(() => {
if (open) {
fetchLogs(page, status);
}
}, [open, page, status, fetchLogs]);
const handleStatusChange = (s: ApiLogStatus) => {
setStatus(s);
setPage(0);
};
const filters: { key: ApiLogStatus; label: string; count: number }[] = [
{ key: 'ALL', label: '전체', count: summary.totalCalls },
{ key: 'SUCCESS', label: '성공', count: summary.successCount },
{ key: 'ERROR', label: '에러', count: summary.errorCount },
];
return (
<div className="mt-2">
<button
onClick={() => setOpen((v) => !v)}
className="inline-flex items-center gap-1 text-xs font-medium text-blue-600 hover:text-blue-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>
({summary.totalCalls.toLocaleString()})
</button>
{open && (
<div className="mt-2">
{/* 상태 필터 탭 */}
<div className="flex gap-1 mb-2">
{filters.map(({ key, label, count }) => (
<button
key={key}
onClick={() => handleStatusChange(key)}
className={`px-2.5 py-1 text-xs rounded-full font-medium transition-colors ${
status === key
? key === 'ERROR'
? 'bg-red-100 text-red-700'
: key === 'SUCCESS'
? 'bg-emerald-100 text-emerald-700'
: 'bg-blue-100 text-blue-700'
: 'bg-gray-100 text-gray-500 hover:bg-gray-200'
}`}
>
{label} ({count.toLocaleString()})
</button>
))}
</div>
{loading ? (
<div className="flex items-center justify-center py-6">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
<span className="ml-2 text-xs text-blue-500">...</span>
</div>
) : logData && logData.content.length > 0 ? (
<>
<div className="overflow-x-auto">
<table className="w-full text-xs text-left">
<thead className="bg-blue-100 text-blue-700 sticky top-0">
<tr>
<th className="px-2 py-1.5 font-medium">#</th>
<th className="px-2 py-1.5 font-medium">URI</th>
<th className="px-2 py-1.5 font-medium">Method</th>
<th className="px-2 py-1.5 font-medium"></th>
<th className="px-2 py-1.5 font-medium text-right">(ms)</th>
<th className="px-2 py-1.5 font-medium text-right"></th>
<th className="px-2 py-1.5 font-medium"></th>
<th className="px-2 py-1.5 font-medium"></th>
</tr>
</thead>
<tbody className="divide-y divide-blue-100">
{logData.content.map((log, idx) => {
const isError = (log.statusCode != null && log.statusCode >= 400) || log.errorMessage;
return (
<tr
key={log.logId}
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 max-w-[200px]">
<div className="flex items-center gap-0.5">
<span className="font-mono text-blue-900 truncate" title={log.requestUri}>
{log.requestUri}
</span>
<CopyButton text={log.requestUri} />
</div>
</td>
<td className="px-2 py-1.5 font-semibold text-blue-900">{log.httpMethod}</td>
<td className="px-2 py-1.5">
<span className={`font-semibold ${
log.statusCode == null ? 'text-gray-400'
: log.statusCode < 300 ? 'text-emerald-600'
: log.statusCode < 400 ? 'text-amber-600'
: 'text-red-600'
}`}>
{log.statusCode ?? '-'}
</span>
</td>
<td className="px-2 py-1.5 text-right text-blue-900">
{log.responseTimeMs?.toLocaleString() ?? '-'}
</td>
<td className="px-2 py-1.5 text-right text-blue-900">
{log.responseCount?.toLocaleString() ?? '-'}
</td>
<td className="px-2 py-1.5 text-blue-600 whitespace-nowrap">
{formatDateTime(log.createdAt)}
</td>
<td className="px-2 py-1.5 text-red-500 max-w-[150px] truncate" title={log.errorMessage || ''}>
{log.errorMessage || '-'}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* 페이지네이션 */}
<Pagination
page={page}
totalPages={logData.totalPages}
totalElements={logData.totalElements}
pageSize={10}
onPageChange={setPage}
/>
</>
) : (
<p className="text-xs text-wing-muted py-3 text-center"> .</p>
)}
</div>
)}
</div>
);
}