feat: API 호출 로그 상세화 추가
This commit is contained in:
부모
f1af7f60b2
커밋
e3eac7133d
@ -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));
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user