feat: API 호출 로그 페이징 및 필터 추가

This commit is contained in:
hyojin kim 2026-02-24 14:58:30 +09:00
부모 a708df3534
커밋 e289aa1611
8개의 변경된 파일500개의 추가작업 그리고 198개의 파일을 삭제

파일 보기

@ -125,9 +125,18 @@ export interface StepApiLogSummary {
minResponseMs: number; minResponseMs: number;
totalResponseMs: number; totalResponseMs: number;
totalRecordCount: 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 { export interface JobExecutionDetailDto {
executionId: number; executionId: number;
jobName: string; jobName: string;
@ -420,6 +429,17 @@ export const batchApi = {
return fetchJson<RecollectionSearchResponse>(`${BASE}/recollection-histories?${qs.toString()}`); 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) => getRecollectionDetail: (historyId: number) =>
fetchJson<RecollectionDetailResponse>(`${BASE}/recollection-histories/${historyId}`), 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 { 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 { formatDateTime, formatDuration, calculateDuration } from '../utils/formatters';
import { usePoller } from '../hooks/usePoller'; import { usePoller } from '../hooks/usePoller';
import StatusBadge from '../components/StatusBadge'; 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 { interface StepCardProps {
step: StepExecutionDto; step: StepExecutionDto;
} }
function StepCard({ step }: StepCardProps) { function StepCard({ step }: StepCardProps) {
const [logsOpen, setLogsOpen] = useState(false);
const stats = [ const stats = [
{ label: '읽기', value: step.readCount }, { label: '읽기', value: step.readCount },
{ label: '쓰기', value: step.writeCount }, { label: '쓰기', value: step.writeCount },
@ -166,84 +350,8 @@ function StepCard({ step }: StepCardProps) {
</div> </div>
</div> </div>
{step.apiLogSummary.logs.length > 0 && ( {step.apiLogSummary.totalCalls > 0 && (
<div className="mt-2"> <ApiLogSection stepExecutionId={step.stepExecutionId} summary={step.apiLogSummary} />
<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>
)} )}
</div> </div>
) : step.apiCallInfo && ( ) : 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 { useParams, useNavigate } from 'react-router-dom';
import { import {
batchApi, batchApi,
type RecollectionDetailResponse, type RecollectionDetailResponse,
type StepExecutionDto, type StepExecutionDto,
type ApiLogPageResponse,
type ApiLogStatus,
} from '../api/batchApi'; } from '../api/batchApi';
import { formatDateTime, formatDuration, calculateDuration } from '../utils/formatters'; import { formatDateTime, formatDuration, calculateDuration } from '../utils/formatters';
import { usePoller } from '../hooks/usePoller'; import { usePoller } from '../hooks/usePoller';
@ -79,9 +81,192 @@ function CopyButton({ text }: { text: string }) {
); );
} }
function StepCard({ step }: { step: StepExecutionDto }) { interface ApiLogSectionProps {
const [logsOpen, setLogsOpen] = useState(false); 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 = [ const stats = [
{ label: '읽기', value: step.readCount }, { label: '읽기', value: step.readCount },
{ label: '쓰기', value: step.writeCount }, { label: '쓰기', value: step.writeCount },
@ -167,85 +352,8 @@ function StepCard({ step }: { step: StepExecutionDto }) {
</div> </div>
</div> </div>
{/* 펼침/접기 개별 로그 */} {summary.totalCalls > 0 && (
{summary.logs.length > 0 && ( <ApiLogSection stepExecutionId={step.stepExecutionId} summary={summary} />
<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>
)} )}
</div> </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 // F1: 강제 종료(Abandon) API
@Operation(summary = "오래된 실행 중 목록 조회", description = "지정된 시간(분) 이상 STARTED/STARTING 상태인 실행 목록을 조회합니다") @Operation(summary = "오래된 실행 중 목록 조회", description = "지정된 시간(분) 이상 STARTED/STARTING 상태인 실행 목록을 조회합니다")

파일 보기

@ -90,6 +90,7 @@ public class JobExecutionDetailDto {
/** /**
* Step별 API 로그 집계 요약 (batch_api_log 테이블 기반) * Step별 API 로그 집계 요약 (batch_api_log 테이블 기반)
* 개별 로그 목록은 별도 API(/api/batch/steps/{id}/api-logs) 페이징 조회
*/ */
@Data @Data
@Builder @Builder
@ -104,7 +105,21 @@ public class JobExecutionDetailDto {
private Long minResponseMs; // 최소 응답시간 private Long minResponseMs; // 최소 응답시간
private Long totalResponseMs; // 응답시간 private Long totalResponseMs; // 응답시간
private Long totalRecordCount; // 반환 건수 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; package com.snp.batch.global.repository;
import com.snp.batch.global.model.BatchApiLog; 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.JpaRepository;
import org.springframework.data.jpa.repository.Query; import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param; import org.springframework.data.repository.query.Param;
@ -43,4 +45,33 @@ public interface BatchApiLogRepository extends JpaRepository<BatchApiLog, Long>
* Step별 개별 API 호출 로그 목록 * Step별 개별 API 호출 로그 목록
*/ */
List<BatchApiLog> findByStepExecutionIdOrderByCreatedAtAsc(Long stepExecutionId); 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.batch.core.launch.JobOperator;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy; 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.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; 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) { private com.snp.batch.global.dto.JobExecutionDetailDto.StepApiLogSummary buildStepApiLogSummary(Long stepExecutionId) {
List<Object[]> stats = apiLogRepository.getApiStatsByStepExecutionId(stepExecutionId); List<Object[]> stats = apiLogRepository.getApiStatsByStepExecutionId(stepExecutionId);
@ -311,11 +313,35 @@ public class BatchService {
Object[] row = stats.get(0); Object[] row = stats.get(0);
List<BatchApiLog> logs = apiLogRepository return com.snp.batch.global.dto.JobExecutionDetailDto.StepApiLogSummary.builder()
.findByStepExecutionIdOrderByCreatedAtAsc(stepExecutionId); .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()) .logId(apiLog.getLogId())
.requestUri(apiLog.getRequestUri()) .requestUri(apiLog.getRequestUri())
.httpMethod(apiLog.getHttpMethod()) .httpMethod(apiLog.getHttpMethod())
@ -327,16 +353,12 @@ public class BatchService {
.build()) .build())
.toList(); .toList();
return com.snp.batch.global.dto.JobExecutionDetailDto.StepApiLogSummary.builder() return JobExecutionDetailDto.ApiLogPageResponse.builder()
.totalCalls(((Number) row[0]).longValue()) .content(content)
.successCount(((Number) row[1]).longValue()) .page(page.getNumber())
.errorCount(((Number) row[2]).longValue()) .size(page.getSize())
.avgResponseMs(((Number) row[3]).doubleValue()) .totalElements(page.getTotalElements())
.maxResponseMs(((Number) row[4]).longValue()) .totalPages(page.getTotalPages())
.minResponseMs(((Number) row[5]).longValue())
.totalResponseMs(((Number) row[6]).longValue())
.totalRecordCount(((Number) row[7]).longValue())
.logs(logEntries)
.build(); .build();
} }

파일 보기

@ -290,7 +290,7 @@ public class RecollectionHistoryService {
} }
/** /**
* Step별 batch_api_log 집계 + 개별 로그 목록 조회 * Step별 batch_api_log 통계 집계 (개별 로그는 별도 API로 페이징 조회)
*/ */
private JobExecutionDetailDto.StepApiLogSummary buildStepApiLogSummary(Long stepExecutionId) { private JobExecutionDetailDto.StepApiLogSummary buildStepApiLogSummary(Long stepExecutionId) {
List<Object[]> stats = apiLogRepository.getApiStatsByStepExecutionId(stepExecutionId); List<Object[]> stats = apiLogRepository.getApiStatsByStepExecutionId(stepExecutionId);
@ -300,22 +300,6 @@ public class RecollectionHistoryService {
Object[] row = stats.get(0); 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() return JobExecutionDetailDto.StepApiLogSummary.builder()
.totalCalls(((Number) row[0]).longValue()) .totalCalls(((Number) row[0]).longValue())
.successCount(((Number) row[1]).longValue()) .successCount(((Number) row[1]).longValue())
@ -325,7 +309,6 @@ public class RecollectionHistoryService {
.minResponseMs(((Number) row[5]).longValue()) .minResponseMs(((Number) row[5]).longValue())
.totalResponseMs(((Number) row[6]).longValue()) .totalResponseMs(((Number) row[6]).longValue())
.totalRecordCount(((Number) row[7]).longValue()) .totalRecordCount(((Number) row[7]).longValue())
.logs(logEntries)
.build(); .build();
} }