- 실행이력상세/재수집이력상세 API 호출 로그 다크모드 적용 - 개별 호출 로그 (ApiLogSection) 필터/테이블 다크모드 적용 - 작업관리 스케줄 라벨 rounded-full 및 디자인 통일 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
171 lines
9.5 KiB
TypeScript
171 lines
9.5 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-500/15 text-red-500'
|
|
: key === 'SUCCESS'
|
|
? 'bg-emerald-500/15 text-emerald-500'
|
|
: 'bg-blue-500/15 text-blue-500'
|
|
: 'bg-wing-card text-wing-muted hover:bg-wing-hover'
|
|
}`}
|
|
>
|
|
{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-500/15 text-blue-500 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-500/15">
|
|
{logData.content.map((log, idx) => {
|
|
const isError = (log.statusCode != null && log.statusCode >= 400) || log.errorMessage;
|
|
return (
|
|
<tr
|
|
key={log.logId}
|
|
className={isError ? 'bg-red-500/10' : 'bg-wing-surface hover:bg-blue-500/10'}
|
|
>
|
|
<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-wing-text truncate" title={log.requestUri}>
|
|
{log.requestUri}
|
|
</span>
|
|
<CopyButton text={log.requestUri} />
|
|
</div>
|
|
</td>
|
|
<td className="px-2 py-1.5 font-semibold text-wing-text">{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-wing-text">
|
|
{log.responseTimeMs?.toLocaleString() ?? '-'}
|
|
</td>
|
|
<td className="px-2 py-1.5 text-right text-wing-text">
|
|
{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>
|
|
);
|
|
}
|