feat: API 호출 로그 페이징 및 필터 추가
This commit is contained in:
부모
a708df3534
커밋
e289aa1611
@ -125,9 +125,18 @@ export interface StepApiLogSummary {
|
||||
minResponseMs: number;
|
||||
totalResponseMs: number;
|
||||
totalRecordCount: number;
|
||||
logs: ApiLogEntryDto[];
|
||||
}
|
||||
|
||||
export interface ApiLogPageResponse {
|
||||
content: ApiLogEntryDto[];
|
||||
page: number;
|
||||
size: number;
|
||||
totalElements: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export type ApiLogStatus = 'ALL' | 'SUCCESS' | 'ERROR';
|
||||
|
||||
export interface JobExecutionDetailDto {
|
||||
executionId: number;
|
||||
jobName: string;
|
||||
@ -420,6 +429,17 @@ export const batchApi = {
|
||||
return fetchJson<RecollectionSearchResponse>(`${BASE}/recollection-histories?${qs.toString()}`);
|
||||
},
|
||||
|
||||
getStepApiLogs: (stepExecutionId: number, params?: {
|
||||
page?: number; size?: number; status?: ApiLogStatus;
|
||||
}) => {
|
||||
const qs = new URLSearchParams();
|
||||
qs.set('page', String(params?.page ?? 0));
|
||||
qs.set('size', String(params?.size ?? 50));
|
||||
if (params?.status) qs.set('status', params.status);
|
||||
return fetchJson<ApiLogPageResponse>(
|
||||
`${BASE}/steps/${stepExecutionId}/api-logs?${qs.toString()}`);
|
||||
},
|
||||
|
||||
getRecollectionDetail: (historyId: number) =>
|
||||
fetchJson<RecollectionDetailResponse>(`${BASE}/recollection-histories/${historyId}`),
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useParams, useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import { batchApi, type JobExecutionDetailDto, type StepExecutionDto } from '../api/batchApi';
|
||||
import { batchApi, type JobExecutionDetailDto, type StepExecutionDto, type ApiLogPageResponse, type ApiLogStatus } from '../api/batchApi';
|
||||
import { formatDateTime, formatDuration, calculateDuration } from '../utils/formatters';
|
||||
import { usePoller } from '../hooks/usePoller';
|
||||
import StatusBadge from '../components/StatusBadge';
|
||||
@ -77,12 +77,196 @@ function CopyButton({ text }: { text: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
interface ApiLogSectionProps {
|
||||
stepExecutionId: number;
|
||||
summary: { totalCalls: number; successCount: number; errorCount: number };
|
||||
}
|
||||
|
||||
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: 50, 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 max-h-80 overflow-y-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 * 50 + 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>
|
||||
|
||||
{/* 페이지네이션 */}
|
||||
{logData.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between mt-2 text-xs text-wing-muted">
|
||||
<span>
|
||||
{logData.totalElements.toLocaleString()}건 중{' '}
|
||||
{(page * 50 + 1).toLocaleString()}~{Math.min((page + 1) * 50, logData.totalElements).toLocaleString()}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
disabled={page === 0}
|
||||
className="px-2 py-1 rounded bg-gray-100 hover:bg-gray-200 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
이전
|
||||
</button>
|
||||
<span className="px-2">
|
||||
{page + 1} / {logData.totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.min(logData.totalPages - 1, p + 1))}
|
||||
disabled={page >= logData.totalPages - 1}
|
||||
className="px-2 py-1 rounded bg-gray-100 hover:bg-gray-200 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
다음
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-xs text-wing-muted py-3 text-center">조회된 로그가 없습니다.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface StepCardProps {
|
||||
step: StepExecutionDto;
|
||||
}
|
||||
|
||||
function StepCard({ step }: StepCardProps) {
|
||||
const [logsOpen, setLogsOpen] = useState(false);
|
||||
const stats = [
|
||||
{ label: '읽기', value: step.readCount },
|
||||
{ label: '쓰기', value: step.writeCount },
|
||||
@ -166,84 +350,8 @@ function StepCard({ step }: StepCardProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{step.apiLogSummary.logs.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<button
|
||||
onClick={() => setLogsOpen((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 ${logsOpen ? '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>
|
||||
개별 호출 로그 ({step.apiLogSummary.logs.length}건)
|
||||
</button>
|
||||
|
||||
{logsOpen && (
|
||||
<div className="mt-2 overflow-x-auto max-h-64 overflow-y-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">
|
||||
{step.apiLogSummary.logs.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">{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>
|
||||
)}
|
||||
</div>
|
||||
{step.apiLogSummary.totalCalls > 0 && (
|
||||
<ApiLogSection stepExecutionId={step.stepExecutionId} summary={step.apiLogSummary} />
|
||||
)}
|
||||
</div>
|
||||
) : step.apiCallInfo && (
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
batchApi,
|
||||
type RecollectionDetailResponse,
|
||||
type StepExecutionDto,
|
||||
type ApiLogPageResponse,
|
||||
type ApiLogStatus,
|
||||
} from '../api/batchApi';
|
||||
import { formatDateTime, formatDuration, calculateDuration } from '../utils/formatters';
|
||||
import { usePoller } from '../hooks/usePoller';
|
||||
@ -79,9 +81,192 @@ function CopyButton({ text }: { text: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function StepCard({ step }: { step: StepExecutionDto }) {
|
||||
const [logsOpen, setLogsOpen] = useState(false);
|
||||
interface ApiLogSectionProps {
|
||||
stepExecutionId: number;
|
||||
summary: { totalCalls: number; successCount: number; errorCount: number };
|
||||
}
|
||||
|
||||
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: 50, 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 max-h-80 overflow-y-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 * 50 + 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>
|
||||
|
||||
{/* 페이지네이션 */}
|
||||
{logData.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between mt-2 text-xs text-wing-muted">
|
||||
<span>
|
||||
{logData.totalElements.toLocaleString()}건 중{' '}
|
||||
{(page * 50 + 1).toLocaleString()}~{Math.min((page + 1) * 50, logData.totalElements).toLocaleString()}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
disabled={page === 0}
|
||||
className="px-2 py-1 rounded bg-gray-100 hover:bg-gray-200 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
이전
|
||||
</button>
|
||||
<span className="px-2">
|
||||
{page + 1} / {logData.totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.min(logData.totalPages - 1, p + 1))}
|
||||
disabled={page >= logData.totalPages - 1}
|
||||
className="px-2 py-1 rounded bg-gray-100 hover:bg-gray-200 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
다음
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-xs text-wing-muted py-3 text-center">조회된 로그가 없습니다.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StepCard({ step }: { step: StepExecutionDto }) {
|
||||
const stats = [
|
||||
{ label: '읽기', value: step.readCount },
|
||||
{ label: '쓰기', value: step.writeCount },
|
||||
@ -167,85 +352,8 @@ function StepCard({ step }: { step: StepExecutionDto }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 펼침/접기 개별 로그 */}
|
||||
{summary.logs.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<button
|
||||
onClick={() => setLogsOpen((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 ${logsOpen ? '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.logs.length}건)
|
||||
</button>
|
||||
|
||||
{logsOpen && (
|
||||
<div className="mt-2 overflow-x-auto max-h-64 overflow-y-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">
|
||||
{summary.logs.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">{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>
|
||||
)}
|
||||
</div>
|
||||
{summary.totalCalls > 0 && (
|
||||
<ApiLogSection stepExecutionId={step.stepExecutionId} summary={summary} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -344,6 +344,21 @@ public class BatchController {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step API 로그 페이징 조회 ─────────────────────────────
|
||||
|
||||
@Operation(summary = "Step API 호출 로그 페이징 조회", description = "Step 실행의 개별 API 호출 로그를 페이징 + 상태 필터로 조회합니다")
|
||||
@GetMapping("/steps/{stepExecutionId}/api-logs")
|
||||
public ResponseEntity<JobExecutionDetailDto.ApiLogPageResponse> getStepApiLogs(
|
||||
@Parameter(description = "Step 실행 ID", required = true) @PathVariable Long stepExecutionId,
|
||||
@Parameter(description = "페이지 번호 (0부터)") @RequestParam(defaultValue = "0") int page,
|
||||
@Parameter(description = "페이지 크기") @RequestParam(defaultValue = "50") int size,
|
||||
@Parameter(description = "상태 필터 (ALL, SUCCESS, ERROR)") @RequestParam(defaultValue = "ALL") String status) {
|
||||
log.debug("Get step API logs: stepExecutionId={}, page={}, size={}, status={}", stepExecutionId, page, size, status);
|
||||
JobExecutionDetailDto.ApiLogPageResponse response = batchService.getStepApiLogs(
|
||||
stepExecutionId, status, PageRequest.of(page, size));
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
// ── F1: 강제 종료(Abandon) API ─────────────────────────────
|
||||
|
||||
@Operation(summary = "오래된 실행 중 목록 조회", description = "지정된 시간(분) 이상 STARTED/STARTING 상태인 실행 목록을 조회합니다")
|
||||
|
||||
@ -90,6 +90,7 @@ public class JobExecutionDetailDto {
|
||||
|
||||
/**
|
||||
* Step별 API 로그 집계 요약 (batch_api_log 테이블 기반)
|
||||
* 개별 로그 목록은 별도 API(/api/batch/steps/{id}/api-logs)로 페이징 조회
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@ -104,7 +105,21 @@ public class JobExecutionDetailDto {
|
||||
private Long minResponseMs; // 최소 응답시간
|
||||
private Long totalResponseMs; // 총 응답시간
|
||||
private Long totalRecordCount; // 총 반환 건수
|
||||
private List<ApiLogEntryDto> logs; // 개별 로그 목록
|
||||
}
|
||||
|
||||
/**
|
||||
* API 호출 로그 페이징 응답
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class ApiLogPageResponse {
|
||||
private List<ApiLogEntryDto> content;
|
||||
private int page;
|
||||
private int size;
|
||||
private long totalElements;
|
||||
private int totalPages;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
package com.snp.batch.global.repository;
|
||||
|
||||
import com.snp.batch.global.model.BatchApiLog;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
@ -43,4 +45,33 @@ public interface BatchApiLogRepository extends JpaRepository<BatchApiLog, Long>
|
||||
* Step별 개별 API 호출 로그 목록
|
||||
*/
|
||||
List<BatchApiLog> findByStepExecutionIdOrderByCreatedAtAsc(Long stepExecutionId);
|
||||
|
||||
/**
|
||||
* Step별 개별 API 호출 로그 - 페이징 (전체)
|
||||
*/
|
||||
Page<BatchApiLog> findByStepExecutionIdOrderByCreatedAtAsc(Long stepExecutionId, Pageable pageable);
|
||||
|
||||
/**
|
||||
* Step별 성공 로그 - 페이징 (statusCode 200~299)
|
||||
*/
|
||||
@Query("""
|
||||
SELECT l FROM BatchApiLog l
|
||||
WHERE l.stepExecutionId = :stepExecutionId
|
||||
AND l.statusCode >= 200 AND l.statusCode < 300
|
||||
ORDER BY l.createdAt ASC
|
||||
""")
|
||||
Page<BatchApiLog> findSuccessByStepExecutionId(
|
||||
@Param("stepExecutionId") Long stepExecutionId, Pageable pageable);
|
||||
|
||||
/**
|
||||
* Step별 에러 로그 - 페이징 (statusCode >= 400 OR errorMessage 존재)
|
||||
*/
|
||||
@Query("""
|
||||
SELECT l FROM BatchApiLog l
|
||||
WHERE l.stepExecutionId = :stepExecutionId
|
||||
AND (l.statusCode >= 400 OR l.errorMessage IS NOT NULL)
|
||||
ORDER BY l.createdAt ASC
|
||||
""")
|
||||
Page<BatchApiLog> findErrorByStepExecutionId(
|
||||
@Param("stepExecutionId") Long stepExecutionId, Pageable pageable);
|
||||
}
|
||||
@ -18,6 +18,8 @@ import org.springframework.batch.core.launch.JobLauncher;
|
||||
import org.springframework.batch.core.launch.JobOperator;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@ -301,7 +303,7 @@ public class BatchService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Step별 batch_api_log 집계 + 개별 로그 목록 조회
|
||||
* Step별 batch_api_log 통계 집계 (개별 로그는 별도 API로 페이징 조회)
|
||||
*/
|
||||
private com.snp.batch.global.dto.JobExecutionDetailDto.StepApiLogSummary buildStepApiLogSummary(Long stepExecutionId) {
|
||||
List<Object[]> stats = apiLogRepository.getApiStatsByStepExecutionId(stepExecutionId);
|
||||
@ -311,11 +313,35 @@ public class BatchService {
|
||||
|
||||
Object[] row = stats.get(0);
|
||||
|
||||
List<BatchApiLog> logs = apiLogRepository
|
||||
.findByStepExecutionIdOrderByCreatedAtAsc(stepExecutionId);
|
||||
return com.snp.batch.global.dto.JobExecutionDetailDto.StepApiLogSummary.builder()
|
||||
.totalCalls(((Number) row[0]).longValue())
|
||||
.successCount(((Number) row[1]).longValue())
|
||||
.errorCount(((Number) row[2]).longValue())
|
||||
.avgResponseMs(((Number) row[3]).doubleValue())
|
||||
.maxResponseMs(((Number) row[4]).longValue())
|
||||
.minResponseMs(((Number) row[5]).longValue())
|
||||
.totalResponseMs(((Number) row[6]).longValue())
|
||||
.totalRecordCount(((Number) row[7]).longValue())
|
||||
.build();
|
||||
}
|
||||
|
||||
List<com.snp.batch.global.dto.JobExecutionDetailDto.ApiLogEntryDto> logEntries = logs.stream()
|
||||
.map(apiLog -> com.snp.batch.global.dto.JobExecutionDetailDto.ApiLogEntryDto.builder()
|
||||
/**
|
||||
* Step별 API 호출 로그 페이징 조회 (상태 필터 지원)
|
||||
*
|
||||
* @param stepExecutionId Step 실행 ID
|
||||
* @param status 필터: ALL(전체), SUCCESS(2xx), ERROR(4xx+/에러)
|
||||
* @param pageable 페이징 정보
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public JobExecutionDetailDto.ApiLogPageResponse getStepApiLogs(Long stepExecutionId, String status, Pageable pageable) {
|
||||
Page<BatchApiLog> page = switch (status) {
|
||||
case "SUCCESS" -> apiLogRepository.findSuccessByStepExecutionId(stepExecutionId, pageable);
|
||||
case "ERROR" -> apiLogRepository.findErrorByStepExecutionId(stepExecutionId, pageable);
|
||||
default -> apiLogRepository.findByStepExecutionIdOrderByCreatedAtAsc(stepExecutionId, pageable);
|
||||
};
|
||||
|
||||
List<JobExecutionDetailDto.ApiLogEntryDto> content = page.getContent().stream()
|
||||
.map(apiLog -> JobExecutionDetailDto.ApiLogEntryDto.builder()
|
||||
.logId(apiLog.getLogId())
|
||||
.requestUri(apiLog.getRequestUri())
|
||||
.httpMethod(apiLog.getHttpMethod())
|
||||
@ -327,16 +353,12 @@ public class BatchService {
|
||||
.build())
|
||||
.toList();
|
||||
|
||||
return com.snp.batch.global.dto.JobExecutionDetailDto.StepApiLogSummary.builder()
|
||||
.totalCalls(((Number) row[0]).longValue())
|
||||
.successCount(((Number) row[1]).longValue())
|
||||
.errorCount(((Number) row[2]).longValue())
|
||||
.avgResponseMs(((Number) row[3]).doubleValue())
|
||||
.maxResponseMs(((Number) row[4]).longValue())
|
||||
.minResponseMs(((Number) row[5]).longValue())
|
||||
.totalResponseMs(((Number) row[6]).longValue())
|
||||
.totalRecordCount(((Number) row[7]).longValue())
|
||||
.logs(logEntries)
|
||||
return JobExecutionDetailDto.ApiLogPageResponse.builder()
|
||||
.content(content)
|
||||
.page(page.getNumber())
|
||||
.size(page.getSize())
|
||||
.totalElements(page.getTotalElements())
|
||||
.totalPages(page.getTotalPages())
|
||||
.build();
|
||||
}
|
||||
|
||||
|
||||
@ -290,7 +290,7 @@ public class RecollectionHistoryService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Step별 batch_api_log 집계 + 개별 로그 목록 조회
|
||||
* Step별 batch_api_log 통계 집계 (개별 로그는 별도 API로 페이징 조회)
|
||||
*/
|
||||
private JobExecutionDetailDto.StepApiLogSummary buildStepApiLogSummary(Long stepExecutionId) {
|
||||
List<Object[]> stats = apiLogRepository.getApiStatsByStepExecutionId(stepExecutionId);
|
||||
@ -300,22 +300,6 @@ public class RecollectionHistoryService {
|
||||
|
||||
Object[] row = stats.get(0);
|
||||
|
||||
List<BatchApiLog> logs = apiLogRepository
|
||||
.findByStepExecutionIdOrderByCreatedAtAsc(stepExecutionId);
|
||||
|
||||
List<JobExecutionDetailDto.ApiLogEntryDto> logEntries = logs.stream()
|
||||
.map(apiLog -> JobExecutionDetailDto.ApiLogEntryDto.builder()
|
||||
.logId(apiLog.getLogId())
|
||||
.requestUri(apiLog.getRequestUri())
|
||||
.httpMethod(apiLog.getHttpMethod())
|
||||
.statusCode(apiLog.getStatusCode())
|
||||
.responseTimeMs(apiLog.getResponseTimeMs())
|
||||
.responseCount(apiLog.getResponseCount())
|
||||
.errorMessage(apiLog.getErrorMessage())
|
||||
.createdAt(apiLog.getCreatedAt())
|
||||
.build())
|
||||
.toList();
|
||||
|
||||
return JobExecutionDetailDto.StepApiLogSummary.builder()
|
||||
.totalCalls(((Number) row[0]).longValue())
|
||||
.successCount(((Number) row[1]).longValue())
|
||||
@ -325,7 +309,6 @@ public class RecollectionHistoryService {
|
||||
.minResponseMs(((Number) row[5]).longValue())
|
||||
.totalResponseMs(((Number) row[6]).longValue())
|
||||
.totalRecordCount(((Number) row[7]).longValue())
|
||||
.logs(logEntries)
|
||||
.build();
|
||||
}
|
||||
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user