diff --git a/frontend/src/pages/ExecutionDetail.tsx b/frontend/src/pages/ExecutionDetail.tsx index 3a74678..afba8da 100644 --- a/frontend/src/pages/ExecutionDetail.tsx +++ b/frontend/src/pages/ExecutionDetail.tsx @@ -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 ( + + ); +} + 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) { ))} - {/* API 호출 정보 */} - {step.apiCallInfo && ( + {/* API 호출 정보: apiLogSummary가 있으면 개별 로그 리스트, 없으면 기존 apiCallInfo 요약 */} + {step.apiLogSummary ? ( +
+

API 호출 정보

+
+
+

{step.apiLogSummary.totalCalls.toLocaleString()}

+

총 호출

+
+
+

{step.apiLogSummary.successCount.toLocaleString()}

+

성공

+
+
+

0 ? 'text-red-500' : 'text-wing-text'}`}> + {step.apiLogSummary.errorCount.toLocaleString()} +

+

에러

+
+
+

{Math.round(step.apiLogSummary.avgResponseMs).toLocaleString()}

+

평균(ms)

+
+
+

{step.apiLogSummary.maxResponseMs.toLocaleString()}

+

최대(ms)

+
+
+

{step.apiLogSummary.minResponseMs.toLocaleString()}

+

최소(ms)

+
+
+ + {step.apiLogSummary.logs.length > 0 && ( +
+ + + {logsOpen && ( +
+ + + + + + + + + + + + + + + {step.apiLogSummary.logs.map((log, idx) => { + const isError = (log.statusCode != null && log.statusCode >= 400) || log.errorMessage; + return ( + + + + + + + + + + + ); + })} + +
#URIMethod상태응답(ms)건수시간에러
{idx + 1} +
+ + {log.requestUri} + + +
+
{log.httpMethod} + + {log.statusCode ?? '-'} + + + {log.responseTimeMs?.toLocaleString() ?? '-'} + + {log.responseCount?.toLocaleString() ?? '-'} + + {formatDateTime(log.createdAt)} + + {log.errorMessage || '-'} +
+
+ )} +
+ )} +
+ ) : step.apiCallInfo && (

API 호출 정보

diff --git a/frontend/src/pages/RecollectDetail.tsx b/frontend/src/pages/RecollectDetail.tsx index ebacec1..3412a1e 100644 --- a/frontend/src/pages/RecollectDetail.tsx +++ b/frontend/src/pages/RecollectDetail.tsx @@ -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 ( + + ); +} + 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'} > {idx + 1} - - {log.requestUri} + +
+ + {log.requestUri} + + +
{log.httpMethod} diff --git a/src/main/java/com/snp/batch/service/BatchService.java b/src/main/java/com/snp/batch/service/BatchService.java index c380cc5..a82f9e9 100644 --- a/src/main/java/com/snp/batch/service/BatchService.java +++ b/src/main/java/com/snp/batch/service/BatchService.java @@ -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 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 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 logs = apiLogRepository + .findByStepExecutionIdOrderByCreatedAtAsc(stepExecutionId); + + List 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));