feat: API 호출 로그 상세화 추가

This commit is contained in:
hyojin kim 2026-02-20 10:51:59 +09:00
부모 f1af7f60b2
커밋 e3eac7133d
3개의 변경된 파일260개의 추가작업 그리고 6개의 파일을 삭제

파일 보기

@ -34,11 +34,55 @@ function StatCard({ label, value, gradient, icon }: StatCardProps) {
); );
} }
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = async (e: React.MouseEvent) => {
e.stopPropagation();
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}
};
return (
<button
onClick={handleCopy}
title={copied ? '복사됨!' : 'URI 복사'}
className="inline-flex items-center p-0.5 rounded hover:bg-blue-200 transition-colors shrink-0"
>
{copied ? (
<svg className="w-3.5 h-3.5 text-emerald-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
) : (
<svg className="w-3.5 h-3.5 text-blue-400 hover:text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
)}
</button>
);
}
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 },
@ -89,8 +133,120 @@ function StepCard({ step }: StepCardProps) {
))} ))}
</div> </div>
{/* API 호출 정보 */} {/* API 호출 정보: apiLogSummary가 있으면 개별 로그 리스트, 없으면 기존 apiCallInfo 요약 */}
{step.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.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>
)}
</div>
) : step.apiCallInfo && (
<div className="mt-4 rounded-lg bg-blue-50 border border-blue-200 p-3"> <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> <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 className="grid grid-cols-2 sm:grid-cols-4 gap-2 text-xs">

파일 보기

@ -36,6 +36,49 @@ function StatCard({ label, value, gradient, icon }: StatCardProps) {
); );
} }
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = async (e: React.MouseEvent) => {
e.stopPropagation();
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}
};
return (
<button
onClick={handleCopy}
title={copied ? '복사됨!' : 'URI 복사'}
className="inline-flex items-center p-0.5 rounded hover:bg-blue-200 transition-colors shrink-0"
>
{copied ? (
<svg className="w-3.5 h-3.5 text-emerald-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
) : (
<svg className="w-3.5 h-3.5 text-blue-400 hover:text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
)}
</button>
);
}
function StepCard({ step }: { step: StepExecutionDto }) { function StepCard({ step }: { step: StepExecutionDto }) {
const [logsOpen, setLogsOpen] = useState(false); const [logsOpen, setLogsOpen] = useState(false);
@ -164,8 +207,13 @@ function StepCard({ step }: { step: StepExecutionDto }) {
className={isError ? 'bg-red-50' : 'bg-white hover:bg-blue-50'} 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 text-blue-500">{idx + 1}</td>
<td className="px-2 py-1.5 font-mono text-blue-900 max-w-[200px] truncate" title={log.requestUri}> <td className="px-2 py-1.5 max-w-[200px]">
{log.requestUri} <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>
<td className="px-2 py-1.5 font-semibold text-blue-900">{log.httpMethod}</td> <td className="px-2 py-1.5 font-semibold text-blue-900">{log.httpMethod}</td>
<td className="px-2 py-1.5"> <td className="px-2 py-1.5">

파일 보기

@ -2,6 +2,8 @@ package com.snp.batch.service;
import com.snp.batch.common.batch.listener.RecollectionJobExecutionListener; import com.snp.batch.common.batch.listener.RecollectionJobExecutionListener;
import com.snp.batch.global.dto.*; import com.snp.batch.global.dto.*;
import com.snp.batch.global.model.BatchApiLog;
import com.snp.batch.global.repository.BatchApiLogRepository;
import com.snp.batch.global.repository.TimelineRepository; import com.snp.batch.global.repository.TimelineRepository;
import jakarta.annotation.PostConstruct; import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@ -35,6 +37,7 @@ public class BatchService {
private final ScheduleService scheduleService; private final ScheduleService scheduleService;
private final TimelineRepository timelineRepository; private final TimelineRepository timelineRepository;
private final RecollectionJobExecutionListener recollectionJobExecutionListener; private final RecollectionJobExecutionListener recollectionJobExecutionListener;
private final BatchApiLogRepository apiLogRepository;
@Autowired @Autowired
public BatchService(JobLauncher jobLauncher, public BatchService(JobLauncher jobLauncher,
@ -43,7 +46,8 @@ public class BatchService {
Map<String, Job> jobMap, Map<String, Job> jobMap,
@Lazy ScheduleService scheduleService, @Lazy ScheduleService scheduleService,
TimelineRepository timelineRepository, TimelineRepository timelineRepository,
RecollectionJobExecutionListener recollectionJobExecutionListener) { RecollectionJobExecutionListener recollectionJobExecutionListener,
BatchApiLogRepository apiLogRepository) {
this.jobLauncher = jobLauncher; this.jobLauncher = jobLauncher;
this.jobExplorer = jobExplorer; this.jobExplorer = jobExplorer;
this.jobOperator = jobOperator; this.jobOperator = jobOperator;
@ -51,6 +55,7 @@ public class BatchService {
this.scheduleService = scheduleService; this.scheduleService = scheduleService;
this.timelineRepository = timelineRepository; this.timelineRepository = timelineRepository;
this.recollectionJobExecutionListener = recollectionJobExecutionListener; this.recollectionJobExecutionListener = recollectionJobExecutionListener;
this.apiLogRepository = apiLogRepository;
} }
/** /**
@ -227,6 +232,10 @@ public class BatchService {
// StepExecutionContext에서 API 정보 추출 // StepExecutionContext에서 API 정보 추출
com.snp.batch.global.dto.JobExecutionDetailDto.ApiCallInfo apiCallInfo = extractApiCallInfo(stepExecution); com.snp.batch.global.dto.JobExecutionDetailDto.ApiCallInfo apiCallInfo = extractApiCallInfo(stepExecution);
// batch_api_log 테이블에서 Step별 API 로그 집계 + 개별 로그 조회
com.snp.batch.global.dto.JobExecutionDetailDto.StepApiLogSummary apiLogSummary =
buildStepApiLogSummary(stepExecution.getId());
return com.snp.batch.global.dto.JobExecutionDetailDto.StepExecutionDto.builder() return com.snp.batch.global.dto.JobExecutionDetailDto.StepExecutionDto.builder()
.stepExecutionId(stepExecution.getId()) .stepExecutionId(stepExecution.getId())
.stepName(stepExecution.getStepName()) .stepName(stepExecution.getStepName())
@ -244,7 +253,8 @@ public class BatchService {
.exitCode(stepExecution.getExitStatus().getExitCode()) .exitCode(stepExecution.getExitStatus().getExitCode())
.exitMessage(stepExecution.getExitStatus().getExitDescription()) .exitMessage(stepExecution.getExitStatus().getExitDescription())
.duration(duration) .duration(duration)
.apiCallInfo(apiCallInfo) // API 정보 추가 .apiCallInfo(apiCallInfo)
.apiLogSummary(apiLogSummary)
.build(); .build();
} }
@ -290,6 +300,46 @@ public class BatchService {
.build(); .build();
} }
/**
* Step별 batch_api_log 집계 + 개별 로그 목록 조회
*/
private com.snp.batch.global.dto.JobExecutionDetailDto.StepApiLogSummary buildStepApiLogSummary(Long stepExecutionId) {
List<Object[]> stats = apiLogRepository.getApiStatsByStepExecutionId(stepExecutionId);
if (stats.isEmpty() || stats.get(0) == null || ((Number) stats.get(0)[0]).longValue() == 0L) {
return null;
}
Object[] row = stats.get(0);
List<BatchApiLog> logs = apiLogRepository
.findByStepExecutionIdOrderByCreatedAtAsc(stepExecutionId);
List<com.snp.batch.global.dto.JobExecutionDetailDto.ApiLogEntryDto> logEntries = logs.stream()
.map(apiLog -> com.snp.batch.global.dto.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 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)
.build();
}
public com.snp.batch.global.dto.TimelineResponse getTimeline(String view, String dateStr) { public com.snp.batch.global.dto.TimelineResponse getTimeline(String view, String dateStr) {
try { try {
java.time.LocalDate date = java.time.LocalDate.parse(dateStr.substring(0, 10)); java.time.LocalDate date = java.time.LocalDate.parse(dateStr.substring(0, 10));