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 {
|
||||
step: StepExecutionDto;
|
||||
}
|
||||
|
||||
function StepCard({ step }: StepCardProps) {
|
||||
const [logsOpen, setLogsOpen] = useState(false);
|
||||
const stats = [
|
||||
{ label: '읽기', value: step.readCount },
|
||||
{ label: '쓰기', value: step.writeCount },
|
||||
@ -89,8 +133,120 @@ function StepCard({ step }: StepCardProps) {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* API 호출 정보 */}
|
||||
{step.apiCallInfo && (
|
||||
{/* API 호출 정보: apiLogSummary가 있으면 개별 로그 리스트, 없으면 기존 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">
|
||||
<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">
|
||||
|
||||
@ -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 }) {
|
||||
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'}
|
||||
>
|
||||
<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}>
|
||||
{log.requestUri}
|
||||
<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">
|
||||
|
||||
@ -2,6 +2,8 @@ package com.snp.batch.service;
|
||||
|
||||
import com.snp.batch.common.batch.listener.RecollectionJobExecutionListener;
|
||||
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 jakarta.annotation.PostConstruct;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@ -35,6 +37,7 @@ public class BatchService {
|
||||
private final ScheduleService scheduleService;
|
||||
private final TimelineRepository timelineRepository;
|
||||
private final RecollectionJobExecutionListener recollectionJobExecutionListener;
|
||||
private final BatchApiLogRepository apiLogRepository;
|
||||
|
||||
@Autowired
|
||||
public BatchService(JobLauncher jobLauncher,
|
||||
@ -43,7 +46,8 @@ public class BatchService {
|
||||
Map<String, Job> jobMap,
|
||||
@Lazy ScheduleService scheduleService,
|
||||
TimelineRepository timelineRepository,
|
||||
RecollectionJobExecutionListener recollectionJobExecutionListener) {
|
||||
RecollectionJobExecutionListener recollectionJobExecutionListener,
|
||||
BatchApiLogRepository apiLogRepository) {
|
||||
this.jobLauncher = jobLauncher;
|
||||
this.jobExplorer = jobExplorer;
|
||||
this.jobOperator = jobOperator;
|
||||
@ -51,6 +55,7 @@ public class BatchService {
|
||||
this.scheduleService = scheduleService;
|
||||
this.timelineRepository = timelineRepository;
|
||||
this.recollectionJobExecutionListener = recollectionJobExecutionListener;
|
||||
this.apiLogRepository = apiLogRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -227,6 +232,10 @@ public class BatchService {
|
||||
// StepExecutionContext에서 API 정보 추출
|
||||
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()
|
||||
.stepExecutionId(stepExecution.getId())
|
||||
.stepName(stepExecution.getStepName())
|
||||
@ -244,7 +253,8 @@ public class BatchService {
|
||||
.exitCode(stepExecution.getExitStatus().getExitCode())
|
||||
.exitMessage(stepExecution.getExitStatus().getExitDescription())
|
||||
.duration(duration)
|
||||
.apiCallInfo(apiCallInfo) // API 정보 추가
|
||||
.apiCallInfo(apiCallInfo)
|
||||
.apiLogSummary(apiLogSummary)
|
||||
.build();
|
||||
}
|
||||
|
||||
@ -290,6 +300,46 @@ public class BatchService {
|
||||
.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) {
|
||||
try {
|
||||
java.time.LocalDate date = java.time.LocalDate.parse(dateStr.substring(0, 10));
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user