feat(recollection): 자동 재수집 및 재수집 프로세스 전면 개선 (#30) #31
@ -4,5 +4,8 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### 추가
|
||||||
|
- 자동 재수집 및 재수집 프로세스 전면 개선 (#30)
|
||||||
|
|
||||||
### 기타
|
### 기타
|
||||||
- 팀 워크플로우 v1.6.1 동기화
|
- 팀 워크플로우 v1.6.1 동기화
|
||||||
|
|||||||
205
docs/recollection-process.md
Normal file
205
docs/recollection-process.md
Normal file
@ -0,0 +1,205 @@
|
|||||||
|
# 재수집 프로세스 분석 및 테스트 시나리오
|
||||||
|
|
||||||
|
## 1. 트리거 경로 3가지 비교
|
||||||
|
|
||||||
|
| 항목 | 자동 재수집 (AUTO_RETRY) | 수동 실패건 재수집 (MANUAL_RETRY) | 수동 날짜범위 재수집 (MANUAL) |
|
||||||
|
|------|--------------------------|----------------------------------|------------------------------|
|
||||||
|
| **트리거** | Job COMPLETED 후 리스너 자동 | UI "실패 건 재수집" 버튼 | UI "재수집 실행" 버튼 |
|
||||||
|
| **대상** | 실패한 record key | 실패한 record key | 날짜 범위 전체 |
|
||||||
|
| `executionMode` | RECOLLECT | RECOLLECT | RECOLLECT |
|
||||||
|
| `executor` | AUTO_RETRY | MANUAL_RETRY | MANUAL |
|
||||||
|
| `retryRecordKeys` | 있음 (콤마 구분) | 있음 (콤마 구분) | 없음 |
|
||||||
|
| `sourceStepExecutionId` | 있음 | 있음 | 없음 |
|
||||||
|
| `apiKey` | 있음 (ExecutionContext) | 없음 (resolveApiKey fallback) | 있음 (파라미터) |
|
||||||
|
| **Reader 모드** | retry (키 기반) | retry (키 기반) | normal (API 전체 호출) |
|
||||||
|
| **last_success_date** | Tasklet 스킵 → 불변 | Tasklet 스킵 → 불변 | Tasklet 실행 → 갱신 → 원복 |
|
||||||
|
| **이력 날짜범위** | NULL / NULL | NULL / NULL | from / to |
|
||||||
|
| **중복 검사** | 안함 | 안함 | 함 (findOverlappingHistories) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 데이터 흐름 다이어그램
|
||||||
|
|
||||||
|
### 자동 재수집 흐름 (AUTO_RETRY)
|
||||||
|
|
||||||
|
```
|
||||||
|
[정상 배치 실행]
|
||||||
|
│
|
||||||
|
├─ Reader.fetchNextBatch() → API 호출 실패 (3회 재시도 소진)
|
||||||
|
│ └─ failedImoNumbers에 누적
|
||||||
|
│
|
||||||
|
├─ Reader.afterFetch(null) [일반 모드]
|
||||||
|
│ ├─ BatchFailedRecordService.saveFailedRecords() ──→ [batch_failed_record] INSERT (FAILED)
|
||||||
|
│ └─ finally: ExecutionContext에 failedRecordKeys/stepId/apiKey 저장
|
||||||
|
│
|
||||||
|
├─ [Job COMPLETED]
|
||||||
|
│
|
||||||
|
├─ AutoRetryJobExecutionListener.afterJob()
|
||||||
|
│ ├─ executionMode != "RECOLLECT" ✓
|
||||||
|
│ ├─ status == COMPLETED ✓
|
||||||
|
│ ├─ 모든 Step의 failedRecordKeys → LinkedHashSet 병합
|
||||||
|
│ ├─ findExceededRetryKeys() → retryCount >= 3 인 키 제외
|
||||||
|
│ └─ AutoRetryTriggerService.triggerRetryAsync() [autoRetryExecutor 스레드]
|
||||||
|
│
|
||||||
|
├─ [재수집 Job 시작 (executionMode=RECOLLECT, executor=AUTO_RETRY)]
|
||||||
|
│
|
||||||
|
├─ RecollectionJobExecutionListener.beforeJob()
|
||||||
|
│ ├─ last_success_date 저장 (ExecutionContext)
|
||||||
|
│ └─ recordStart() ──→ [batch_recollection_history] INSERT (STARTED, 날짜 NULL)
|
||||||
|
│
|
||||||
|
├─ Reader.beforeFetch() [retry 모드]
|
||||||
|
│ └─ allImoNumbers = retryRecordKeys (API 호출 없이 키 목록 직접 사용)
|
||||||
|
│
|
||||||
|
├─ Reader.afterFetch(null) [retry 모드]
|
||||||
|
│ ├─ resolveSuccessfulRetries() ──→ [batch_failed_record] UPDATE (RESOLVED)
|
||||||
|
│ └─ 재실패 건 saveFailedRecords() ──→ [batch_failed_record] INSERT (FAILED, 새 레코드)
|
||||||
|
│
|
||||||
|
├─ retryModeDecider → "RETRY" → lastExecutionUpdateStep 스킵
|
||||||
|
│
|
||||||
|
├─ RecollectionJobExecutionListener.afterJob()
|
||||||
|
│ ├─ recordCompletion() ──→ [batch_recollection_history] UPDATE (COMPLETED/FAILED)
|
||||||
|
│ └─ last_success_date 복원 검사 (DB 현재값 > 원본 → 스킵)
|
||||||
|
│ └─ restoreLastSuccessDate() ──→ [batch_last_execution] UPDATE
|
||||||
|
│
|
||||||
|
└─ [완료]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 수동 실패건 재수집 흐름 (MANUAL_RETRY)
|
||||||
|
|
||||||
|
```
|
||||||
|
[프론트엔드 ExecutionDetail / RecollectDetail]
|
||||||
|
│
|
||||||
|
├─ "실패 건 재수집" 버튼 클릭 → 확인 다이얼로그
|
||||||
|
│
|
||||||
|
├─ batchApi.retryFailedRecords(jobName, recordKeys, stepExecutionId)
|
||||||
|
│ └─ POST /api/batch/jobs/{jobName}/execute
|
||||||
|
│ ?retryRecordKeys=IMO1,IMO2
|
||||||
|
│ &sourceStepExecutionId=123
|
||||||
|
│ &executionMode=RECOLLECT
|
||||||
|
│ &executor=MANUAL_RETRY
|
||||||
|
│ &reason=실패 건 수동 재수집 (N건)
|
||||||
|
│
|
||||||
|
├─ BatchController.executeJob() → BatchService.executeJob()
|
||||||
|
│ └─ jobLauncher.run(job, params)
|
||||||
|
│
|
||||||
|
├─ [이후 흐름은 AUTO_RETRY와 동일]
|
||||||
|
│ ├─ RecollectionJobExecutionListener.beforeJob() → 이력 INSERT
|
||||||
|
│ ├─ Reader [retry 모드] → 키 기반 재처리
|
||||||
|
│ ├─ retryModeDecider → "RETRY" → Tasklet 스킵
|
||||||
|
│ └─ RecollectionJobExecutionListener.afterJob() → 이력 UPDATE + 복원
|
||||||
|
│
|
||||||
|
└─ 성공 시 UI에서 새 실행 상세 화면으로 이동
|
||||||
|
```
|
||||||
|
|
||||||
|
### 수동 날짜범위 재수집 흐름 (MANUAL)
|
||||||
|
|
||||||
|
```
|
||||||
|
[프론트엔드 Recollects.tsx]
|
||||||
|
│
|
||||||
|
├─ updateCollectionPeriod(apiKey, from, to) ──→ [batch_collection_period] UPDATE
|
||||||
|
│
|
||||||
|
├─ executeJob(jobName, {executionMode, apiKey, executor, reason})
|
||||||
|
│ └─ POST /api/batch/jobs/{jobName}/execute
|
||||||
|
│ ?executionMode=RECOLLECT&apiKey=...&executor=MANUAL&reason=...
|
||||||
|
│
|
||||||
|
├─ RecollectionJobExecutionListener.beforeJob()
|
||||||
|
│ ├─ last_success_date 저장
|
||||||
|
│ ├─ periodRepository.findById(apiKey) → 날짜범위 조회
|
||||||
|
│ ├─ findOverlappingHistories() → 중복 검사
|
||||||
|
│ └─ recordStart() ──→ [batch_recollection_history] INSERT (날짜범위 포함)
|
||||||
|
│
|
||||||
|
├─ Reader [일반 모드] → API 전체 호출
|
||||||
|
│
|
||||||
|
├─ retryModeDecider → "NORMAL" → lastExecutionUpdateStep 실행
|
||||||
|
│ └─ Tasklet ──→ [batch_last_execution] UPDATE (NOW())
|
||||||
|
│
|
||||||
|
├─ RecollectionJobExecutionListener.afterJob()
|
||||||
|
│ ├─ recordCompletion() ──→ [batch_recollection_history] UPDATE
|
||||||
|
│ └─ last_success_date 복원 ──→ [batch_last_execution] UPDATE (원래 값 복원)
|
||||||
|
│
|
||||||
|
└─ [완료]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 테이블 영향 매트릭스
|
||||||
|
|
||||||
|
| 테이블 | AUTO_RETRY | MANUAL_RETRY | MANUAL(날짜) | UI 일괄 RESOLVED |
|
||||||
|
|--------|-----------|--------------|-------------|-----------------|
|
||||||
|
| `batch_failed_record` | INSERT(FAILED) + UPDATE(RESOLVED) | INSERT(FAILED) + UPDATE(RESOLVED) | - | UPDATE(RESOLVED) |
|
||||||
|
| `batch_recollection_history` | INSERT + UPDATE | INSERT + UPDATE | INSERT + UPDATE | - |
|
||||||
|
| `batch_collection_period` | READ (apiKeyName 조회) | READ (apiKeyName 조회) | READ/WRITE (날짜범위) | - |
|
||||||
|
| `batch_last_execution` | READ (복원용) | READ (복원용) | READ → WRITE(갱신) → WRITE(복원) | - |
|
||||||
|
| `batch_job_execution` | INSERT (Spring Batch 자동) | INSERT (Spring Batch 자동) | INSERT (Spring Batch 자동) | - |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 무한루프 방지 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
[이중 가드]
|
||||||
|
|
||||||
|
Guard 1: AutoRetryJobExecutionListener
|
||||||
|
└─ executionMode == "RECOLLECT" → 즉시 리턴 (재수집 Job에서는 자동 재수집 트리거 안 함)
|
||||||
|
|
||||||
|
Guard 2: findExceededRetryKeys()
|
||||||
|
└─ retryCount >= 3 인 키 → mergedKeys에서 제거
|
||||||
|
└─ 남은 키 없으면 → 재수집 자체 스킵
|
||||||
|
|
||||||
|
[시나리오]
|
||||||
|
일반 실행 → 실패 3건 → 자동 재수집(retryCount=3 저장)
|
||||||
|
→ 재수집 Job은 executionMode=RECOLLECT → Guard 1에서 차단
|
||||||
|
→ 다음 정상 실행에서 같은 키 다시 실패해도 retryCount >= 3 → Guard 2에서 차단
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 프론트엔드 UI 진입점
|
||||||
|
|
||||||
|
| 화면 | 컴포넌트/영역 | 기능 |
|
||||||
|
|------|--------------|------|
|
||||||
|
| **Executions** | failedRecordCount 뱃지 | COMPLETED 상태 + 미해결 실패건 > 0 → amber 뱃지 |
|
||||||
|
| **ExecutionDetail** | FailedRecordsToggle | 실패 레코드 목록 + "재수집 실행" + "일괄 RESOLVED" 버튼 |
|
||||||
|
| **RecollectDetail** | FailedRecordsToggle | 재수집 상세 내 실패 레코드 + 동일 버튼 |
|
||||||
|
| **Recollects** | 이력 목록 + 수집기간 관리 | 검색/필터 + CSV 내보내기 + 재수집 실행 + 기간 편집 |
|
||||||
|
| **Dashboard** | 재수집 현황 위젯 | 통계 카드 5개 + 최근 5건 테이블 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 주요 파일 맵
|
||||||
|
|
||||||
|
### 백엔드
|
||||||
|
|
||||||
|
| 파일 | 역할 |
|
||||||
|
|------|------|
|
||||||
|
| `ShipDetailUpdateDataReader.java` | API 호출 + 실패 감지 + ExecutionContext 저장 |
|
||||||
|
| `ShipDetailUpdateJobConfig.java` | Job/Step 구성 + retryModeDecider + 리스너 등록 |
|
||||||
|
| `AutoRetryJobExecutionListener.java` | Job 완료 후 실패 키 병합 + retryCount 필터 + 트리거 |
|
||||||
|
| `AutoRetryTriggerService.java` | @Async 비동기 Job 재실행 (autoRetryExecutor) |
|
||||||
|
| `RecollectionJobExecutionListener.java` | 재수집 이력 기록 + last_success_date 백업/복원 |
|
||||||
|
| `RecollectionHistoryService.java` | 이력 CRUD + 통계 + 수집기간 관리 |
|
||||||
|
| `BatchFailedRecordService.java` | 실패 레코드 저장/RESOLVED 처리 |
|
||||||
|
| `BatchService.java` | Job 실행 + failedRecordCount 조회 |
|
||||||
|
| `BatchController.java` | REST API 엔드포인트 (실행/이력/CSV/RESOLVED) |
|
||||||
|
|
||||||
|
### 프론트엔드
|
||||||
|
|
||||||
|
| 파일 | 역할 |
|
||||||
|
|------|------|
|
||||||
|
| `batchApi.ts` | API 클라이언트 (retryFailedRecords, resolveFailedRecords, exportCSV 등) |
|
||||||
|
| `Executions.tsx` | 실행 목록 + failedRecordCount amber 뱃지 |
|
||||||
|
| `ExecutionDetail.tsx` | 실행 상세 + FailedRecordsToggle (재수집/RESOLVED) |
|
||||||
|
| `RecollectDetail.tsx` | 재수집 상세 + FailedRecordsToggle (재수집/RESOLVED) |
|
||||||
|
| `Recollects.tsx` | 재수집 이력 목록 + 수집기간 관리 + CSV 내보내기 |
|
||||||
|
| `Dashboard.tsx` | 대시보드 재수집 현황 위젯 |
|
||||||
|
|
||||||
|
### 공유 컴포넌트
|
||||||
|
|
||||||
|
| 파일 | 사용처 |
|
||||||
|
|------|--------|
|
||||||
|
| `components/CopyButton.tsx` | ApiLogSection 내부 |
|
||||||
|
| `components/DetailStatCard.tsx` | ExecutionDetail, RecollectDetail |
|
||||||
|
| `components/ApiLogSection.tsx` | ExecutionDetail, RecollectDetail |
|
||||||
|
| `components/InfoItem.tsx` | ExecutionDetail, RecollectDetail |
|
||||||
|
|
||||||
|
---
|
||||||
@ -73,6 +73,7 @@ export interface JobExecutionDto {
|
|||||||
endTime: string | null;
|
endTime: string | null;
|
||||||
exitCode: string | null;
|
exitCode: string | null;
|
||||||
exitMessage: string | null;
|
exitMessage: string | null;
|
||||||
|
failedRecordCount: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApiCallInfo {
|
export interface ApiCallInfo {
|
||||||
@ -356,6 +357,9 @@ export const batchApi = {
|
|||||||
const qs = new URLSearchParams({
|
const qs = new URLSearchParams({
|
||||||
retryRecordKeys: recordKeys.join(','),
|
retryRecordKeys: recordKeys.join(','),
|
||||||
sourceStepExecutionId: String(stepExecutionId),
|
sourceStepExecutionId: String(stepExecutionId),
|
||||||
|
executionMode: 'RECOLLECT',
|
||||||
|
executor: 'MANUAL_RETRY',
|
||||||
|
reason: `실패 건 수동 재수집 (${recordKeys.length}건)`,
|
||||||
});
|
});
|
||||||
return postJson<{ success: boolean; message: string; executionId?: number }>(
|
return postJson<{ success: boolean; message: string; executionId?: number }>(
|
||||||
`${BASE}/jobs/${jobName}/execute?${qs.toString()}`);
|
`${BASE}/jobs/${jobName}/execute?${qs.toString()}`);
|
||||||
@ -466,7 +470,7 @@ export const batchApi = {
|
|||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
qs.set('page', String(params?.page ?? 0));
|
qs.set('page', String(params?.page ?? 0));
|
||||||
qs.set('size', String(params?.size ?? 50));
|
qs.set('size', String(params?.size ?? 50));
|
||||||
if (params?.status) qs.set('status', params.status);
|
if (params?.status && params.status !== 'ALL') qs.set('status', params.status);
|
||||||
return fetchJson<ApiLogPageResponse>(
|
return fetchJson<ApiLogPageResponse>(
|
||||||
`${BASE}/steps/${stepExecutionId}/api-logs?${qs.toString()}`);
|
`${BASE}/steps/${stepExecutionId}/api-logs?${qs.toString()}`);
|
||||||
},
|
},
|
||||||
@ -489,4 +493,24 @@ export const batchApi = {
|
|||||||
// Last Collection Status
|
// Last Collection Status
|
||||||
getLastCollectionStatuses: () =>
|
getLastCollectionStatuses: () =>
|
||||||
fetchJson<LastCollectionStatusDto[]>(`${BASE}/last-collections`),
|
fetchJson<LastCollectionStatusDto[]>(`${BASE}/last-collections`),
|
||||||
|
|
||||||
|
resolveFailedRecords: (ids: number[]) =>
|
||||||
|
postJson<{ success: boolean; message: string; resolvedCount?: number }>(
|
||||||
|
`${BASE}/failed-records/resolve`, { ids }),
|
||||||
|
|
||||||
|
exportRecollectionHistories: (params: {
|
||||||
|
apiKey?: string;
|
||||||
|
jobName?: string;
|
||||||
|
status?: string;
|
||||||
|
fromDate?: string;
|
||||||
|
toDate?: string;
|
||||||
|
}) => {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (params.apiKey) qs.set('apiKey', params.apiKey);
|
||||||
|
if (params.jobName) qs.set('jobName', params.jobName);
|
||||||
|
if (params.status) qs.set('status', params.status);
|
||||||
|
if (params.fromDate) qs.set('fromDate', params.fromDate);
|
||||||
|
if (params.toDate) qs.set('toDate', params.toDate);
|
||||||
|
window.open(`${BASE}/recollection-histories/export?${qs.toString()}`);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
170
frontend/src/components/ApiLogSection.tsx
Normal file
170
frontend/src/components/ApiLogSection.tsx
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
|
import { batchApi, type ApiLogPageResponse, type ApiLogStatus } from '../api/batchApi';
|
||||||
|
import { formatDateTime } from '../utils/formatters';
|
||||||
|
import Pagination from './Pagination';
|
||||||
|
import CopyButton from './CopyButton';
|
||||||
|
|
||||||
|
interface ApiLogSectionProps {
|
||||||
|
stepExecutionId: number;
|
||||||
|
summary: { totalCalls: number; successCount: number; errorCount: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default 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: 10, 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">
|
||||||
|
<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 * 10 + 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>
|
||||||
|
|
||||||
|
{/* 페이지네이션 */}
|
||||||
|
<Pagination
|
||||||
|
page={page}
|
||||||
|
totalPages={logData.totalPages}
|
||||||
|
totalElements={logData.totalElements}
|
||||||
|
pageSize={10}
|
||||||
|
onPageChange={setPage}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-wing-muted py-3 text-center">조회된 로그가 없습니다.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
frontend/src/components/CopyButton.tsx
Normal file
48
frontend/src/components/CopyButton.tsx
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
interface CopyButtonProps {
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CopyButton({ text }: CopyButtonProps) {
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
frontend/src/components/DetailStatCard.tsx
Normal file
22
frontend/src/components/DetailStatCard.tsx
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
interface DetailStatCardProps {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
gradient: string;
|
||||||
|
icon: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DetailStatCard({ label, value, gradient, icon }: DetailStatCardProps) {
|
||||||
|
return (
|
||||||
|
<div className={`rounded-xl p-5 text-white shadow-md ${gradient}`}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-white/80">{label}</p>
|
||||||
|
<p className="mt-1 text-3xl font-bold">
|
||||||
|
{value.toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-3xl opacity-80">{icon}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
frontend/src/components/InfoItem.tsx
Normal file
15
frontend/src/components/InfoItem.tsx
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
interface InfoItemProps {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function InfoItem({ label, value }: InfoItemProps) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs font-medium text-wing-muted uppercase tracking-wide">
|
||||||
|
{label}
|
||||||
|
</dt>
|
||||||
|
<dd className="mt-1 text-sm text-wing-text break-words">{value || '-'}</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -5,6 +5,7 @@ import {
|
|||||||
type DashboardResponse,
|
type DashboardResponse,
|
||||||
type DashboardStats,
|
type DashboardStats,
|
||||||
type ExecutionStatisticsDto,
|
type ExecutionStatisticsDto,
|
||||||
|
type RecollectionStatsResponse,
|
||||||
} from '../api/batchApi';
|
} from '../api/batchApi';
|
||||||
import { usePoller } from '../hooks/usePoller';
|
import { usePoller } from '../hooks/usePoller';
|
||||||
import { useToastContext } from '../contexts/ToastContext';
|
import { useToastContext } from '../contexts/ToastContext';
|
||||||
@ -54,6 +55,7 @@ export default function Dashboard() {
|
|||||||
const [stopDate, setStopDate] = useState('');
|
const [stopDate, setStopDate] = useState('');
|
||||||
const [abandoning, setAbandoning] = useState(false);
|
const [abandoning, setAbandoning] = useState(false);
|
||||||
const [statistics, setStatistics] = useState<ExecutionStatisticsDto | null>(null);
|
const [statistics, setStatistics] = useState<ExecutionStatisticsDto | null>(null);
|
||||||
|
const [recollectionStats, setRecollectionStats] = useState<RecollectionStatsResponse | null>(null);
|
||||||
|
|
||||||
const loadStatistics = useCallback(async () => {
|
const loadStatistics = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@ -68,6 +70,19 @@ export default function Dashboard() {
|
|||||||
loadStatistics();
|
loadStatistics();
|
||||||
}, [loadStatistics]);
|
}, [loadStatistics]);
|
||||||
|
|
||||||
|
const loadRecollectionStats = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await batchApi.getRecollectionStats();
|
||||||
|
setRecollectionStats(data);
|
||||||
|
} catch {
|
||||||
|
/* 통계 로드 실패는 무시 */
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadRecollectionStats();
|
||||||
|
}, [loadRecollectionStats]);
|
||||||
|
|
||||||
const loadDashboard = useCallback(async () => {
|
const loadDashboard = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const data = await batchApi.getDashboard();
|
const data = await batchApi.getDashboard();
|
||||||
@ -391,6 +406,73 @@ export default function Dashboard() {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 재수집 현황 */}
|
||||||
|
{recollectionStats && recollectionStats.totalCount > 0 && (
|
||||||
|
<section className="bg-wing-surface rounded-xl shadow-md p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-lg font-semibold text-wing-text">재수집 현황</h2>
|
||||||
|
<Link to="/recollects" className="text-sm text-wing-accent hover:text-wing-accent no-underline">
|
||||||
|
전체 보기 →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-5 gap-3 mb-4">
|
||||||
|
<div className="bg-blue-50 rounded-lg p-3 text-center">
|
||||||
|
<p className="text-2xl font-bold text-blue-700">{recollectionStats.totalCount}</p>
|
||||||
|
<p className="text-xs text-blue-500 mt-1">전체</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-emerald-50 rounded-lg p-3 text-center">
|
||||||
|
<p className="text-2xl font-bold text-emerald-700">{recollectionStats.completedCount}</p>
|
||||||
|
<p className="text-xs text-emerald-500 mt-1">완료</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-red-50 rounded-lg p-3 text-center">
|
||||||
|
<p className="text-2xl font-bold text-red-700">{recollectionStats.failedCount}</p>
|
||||||
|
<p className="text-xs text-red-500 mt-1">실패</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-amber-50 rounded-lg p-3 text-center">
|
||||||
|
<p className="text-2xl font-bold text-amber-700">{recollectionStats.runningCount}</p>
|
||||||
|
<p className="text-xs text-amber-500 mt-1">실행 중</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-violet-50 rounded-lg p-3 text-center">
|
||||||
|
<p className="text-2xl font-bold text-violet-700">{recollectionStats.overlapCount}</p>
|
||||||
|
<p className="text-xs text-violet-500 mt-1">중복</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* 최근 재수집 이력 (최대 5건) */}
|
||||||
|
{recollectionStats.recentHistories.length > 0 && (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-wing-border text-left text-wing-muted">
|
||||||
|
<th className="pb-2 font-medium">이력 ID</th>
|
||||||
|
<th className="pb-2 font-medium">작업명</th>
|
||||||
|
<th className="pb-2 font-medium">실행자</th>
|
||||||
|
<th className="pb-2 font-medium">시작 시간</th>
|
||||||
|
<th className="pb-2 font-medium">상태</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{recollectionStats.recentHistories.slice(0, 5).map((h) => (
|
||||||
|
<tr key={h.historyId} className="border-b border-wing-border/50">
|
||||||
|
<td className="py-3">
|
||||||
|
<Link to={`/recollects/${h.historyId}`} className="text-wing-accent hover:text-wing-accent no-underline font-medium">
|
||||||
|
#{h.historyId}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 text-wing-text">{h.apiKeyName || h.jobName}</td>
|
||||||
|
<td className="py-3 text-wing-muted">{h.executor || '-'}</td>
|
||||||
|
<td className="py-3 text-wing-muted">{formatDateTime(h.executionStartTime)}</td>
|
||||||
|
<td className="py-3">
|
||||||
|
<StatusBadge status={h.executionStatus} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* F8: Execution Statistics Chart */}
|
{/* F8: Execution Statistics Chart */}
|
||||||
{statistics && statistics.dailyStats.length > 0 && (
|
{statistics && statistics.dailyStats.length > 0 && (
|
||||||
<section className="bg-wing-surface rounded-xl shadow-md p-6">
|
<section className="bg-wing-surface rounded-xl shadow-md p-6">
|
||||||
|
|||||||
@ -1,248 +1,18 @@
|
|||||||
import { useState, useCallback, useEffect } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { useParams, useSearchParams, useNavigate } from 'react-router-dom';
|
import { useParams, useSearchParams, useNavigate } from 'react-router-dom';
|
||||||
import { batchApi, type JobExecutionDetailDto, type StepExecutionDto, type FailedRecordDto, type ApiLogPageResponse, type ApiLogStatus } from '../api/batchApi';
|
import { batchApi, type JobExecutionDetailDto, type StepExecutionDto, type FailedRecordDto } 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';
|
||||||
import EmptyState from '../components/EmptyState';
|
import EmptyState from '../components/EmptyState';
|
||||||
import LoadingSpinner from '../components/LoadingSpinner';
|
import LoadingSpinner from '../components/LoadingSpinner';
|
||||||
import Pagination from '../components/Pagination';
|
import Pagination from '../components/Pagination';
|
||||||
|
import DetailStatCard from '../components/DetailStatCard';
|
||||||
|
import ApiLogSection from '../components/ApiLogSection';
|
||||||
|
import InfoItem from '../components/InfoItem';
|
||||||
|
|
||||||
const POLLING_INTERVAL_MS = 5000;
|
const POLLING_INTERVAL_MS = 5000;
|
||||||
|
|
||||||
interface StatCardProps {
|
|
||||||
label: string;
|
|
||||||
value: number;
|
|
||||||
gradient: string;
|
|
||||||
icon: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatCard({ label, value, gradient, icon }: StatCardProps) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`rounded-xl p-5 text-white shadow-md ${gradient}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-white/80">{label}</p>
|
|
||||||
<p className="mt-1 text-3xl font-bold">
|
|
||||||
{value.toLocaleString()}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<span className="text-3xl opacity-80">{icon}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 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: 10, 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">
|
|
||||||
<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 * 10 + 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>
|
|
||||||
|
|
||||||
{/* 페이지네이션 */}
|
|
||||||
<Pagination
|
|
||||||
page={page}
|
|
||||||
totalPages={logData.totalPages}
|
|
||||||
totalElements={logData.totalElements}
|
|
||||||
pageSize={10}
|
|
||||||
onPageChange={setPage}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<p className="text-xs text-wing-muted py-3 text-center">조회된 로그가 없습니다.</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StepCardProps {
|
interface StepCardProps {
|
||||||
step: StepExecutionDto;
|
step: StepExecutionDto;
|
||||||
jobName: string;
|
jobName: string;
|
||||||
@ -489,25 +259,25 @@ export default function ExecutionDetail() {
|
|||||||
|
|
||||||
{/* 실행 통계 카드 4개 */}
|
{/* 실행 통계 카드 4개 */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
<StatCard
|
<DetailStatCard
|
||||||
label="읽기 (Read)"
|
label="읽기 (Read)"
|
||||||
value={detail.readCount}
|
value={detail.readCount}
|
||||||
gradient="bg-gradient-to-br from-blue-500 to-blue-600"
|
gradient="bg-gradient-to-br from-blue-500 to-blue-600"
|
||||||
icon="📥"
|
icon="📥"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<DetailStatCard
|
||||||
label="쓰기 (Write)"
|
label="쓰기 (Write)"
|
||||||
value={detail.writeCount}
|
value={detail.writeCount}
|
||||||
gradient="bg-gradient-to-br from-emerald-500 to-emerald-600"
|
gradient="bg-gradient-to-br from-emerald-500 to-emerald-600"
|
||||||
icon="📤"
|
icon="📤"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<DetailStatCard
|
||||||
label="건너뜀 (Skip)"
|
label="건너뜀 (Skip)"
|
||||||
value={detail.skipCount}
|
value={detail.skipCount}
|
||||||
gradient="bg-gradient-to-br from-amber-500 to-amber-600"
|
gradient="bg-gradient-to-br from-amber-500 to-amber-600"
|
||||||
icon="⏭"
|
icon="⏭"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<DetailStatCard
|
||||||
label="필터 (Filter)"
|
label="필터 (Filter)"
|
||||||
value={detail.filterCount}
|
value={detail.filterCount}
|
||||||
gradient="bg-gradient-to-br from-purple-500 to-purple-600"
|
gradient="bg-gradient-to-br from-purple-500 to-purple-600"
|
||||||
@ -580,7 +350,9 @@ const FAILED_PAGE_SIZE = 10;
|
|||||||
function FailedRecordsToggle({ records, jobName, stepExecutionId }: { records: FailedRecordDto[]; jobName: string; stepExecutionId: number }) {
|
function FailedRecordsToggle({ records, jobName, stepExecutionId }: { records: FailedRecordDto[]; jobName: string; stepExecutionId: number }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [showConfirm, setShowConfirm] = useState(false);
|
const [showConfirm, setShowConfirm] = useState(false);
|
||||||
|
const [showResolveConfirm, setShowResolveConfirm] = useState(false);
|
||||||
const [retrying, setRetrying] = useState(false);
|
const [retrying, setRetrying] = useState(false);
|
||||||
|
const [resolving, setResolving] = useState(false);
|
||||||
const [page, setPage] = useState(0);
|
const [page, setPage] = useState(0);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
@ -601,9 +373,15 @@ function FailedRecordsToggle({ records, jobName, stepExecutionId }: { records: F
|
|||||||
try {
|
try {
|
||||||
const keys = failedRecords.map((r) => r.recordKey);
|
const keys = failedRecords.map((r) => r.recordKey);
|
||||||
const result = await batchApi.retryFailedRecords(jobName, keys, stepExecutionId);
|
const result = await batchApi.retryFailedRecords(jobName, keys, stepExecutionId);
|
||||||
if (result.success && result.executionId) {
|
if (result.success) {
|
||||||
setShowConfirm(false);
|
setShowConfirm(false);
|
||||||
navigate(`/executions/${result.executionId}`);
|
if (result.executionId) {
|
||||||
|
navigate(`/executions/${result.executionId}`);
|
||||||
|
} else {
|
||||||
|
alert(result.message || '재수집이 요청되었습니다.');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
alert(result.message || '재수집 실행에 실패했습니다.');
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
alert('재수집 실행에 실패했습니다.');
|
alert('재수집 실행에 실패했습니다.');
|
||||||
@ -612,6 +390,20 @@ function FailedRecordsToggle({ records, jobName, stepExecutionId }: { records: F
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleResolve = async () => {
|
||||||
|
setResolving(true);
|
||||||
|
try {
|
||||||
|
const ids = failedRecords.map((r) => r.id);
|
||||||
|
await batchApi.resolveFailedRecords(ids);
|
||||||
|
setShowResolveConfirm(false);
|
||||||
|
navigate(0);
|
||||||
|
} catch {
|
||||||
|
alert('일괄 RESOLVED 처리에 실패했습니다.');
|
||||||
|
} finally {
|
||||||
|
setResolving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-4 rounded-lg bg-red-50 border border-red-200 p-3">
|
<div className="mt-4 rounded-lg bg-red-50 border border-red-200 p-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@ -625,19 +417,30 @@ function FailedRecordsToggle({ records, jobName, stepExecutionId }: { records: F
|
|||||||
>
|
>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
</svg>
|
</svg>
|
||||||
호출 실패 데이터 ({records.length.toLocaleString()}건)
|
호출 실패 데이터 ({records.length.toLocaleString()}건, FAILED {failedRecords.length}건)
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{failedRecords.length > 0 && (
|
{failedRecords.length > 0 && (
|
||||||
<button
|
<div className="flex items-center gap-1.5">
|
||||||
onClick={() => setShowConfirm(true)}
|
<button
|
||||||
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-medium text-white bg-red-500 hover:bg-red-600 rounded-md transition-colors"
|
onClick={() => setShowResolveConfirm(true)}
|
||||||
>
|
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-medium text-emerald-700 bg-emerald-50 hover:bg-emerald-100 border border-emerald-200 rounded-md transition-colors"
|
||||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
</svg>
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
실패 건 재수집 ({failedRecords.length}건)
|
</svg>
|
||||||
</button>
|
일괄 RESOLVED ({failedRecords.length}건)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowConfirm(true)}
|
||||||
|
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-medium text-white bg-red-500 hover:bg-red-600 rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
|
</svg>
|
||||||
|
실패 건 재수집 ({failedRecords.length}건)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -692,7 +495,7 @@ function FailedRecordsToggle({ records, jobName, stepExecutionId }: { records: F
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 확인 다이얼로그 */}
|
{/* 재수집 확인 다이얼로그 */}
|
||||||
{showConfirm && (
|
{showConfirm && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||||
<div className="bg-white rounded-xl shadow-2xl p-6 w-full max-w-md mx-4">
|
<div className="bg-white rounded-xl shadow-2xl p-6 w-full max-w-md mx-4">
|
||||||
@ -740,17 +543,45 @@ function FailedRecordsToggle({ records, jobName, stepExecutionId }: { records: F
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 일괄 RESOLVED 확인 다이얼로그 */}
|
||||||
|
{showResolveConfirm && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||||
|
<div className="bg-white rounded-xl shadow-2xl p-6 w-full max-w-md mx-4">
|
||||||
|
<h3 className="text-lg font-semibold text-wing-text mb-2">
|
||||||
|
일괄 RESOLVED 확인
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-wing-muted mb-4">
|
||||||
|
FAILED 상태의 {failedRecords.length}건을 RESOLVED로 변경합니다.
|
||||||
|
이 작업은 되돌릴 수 없습니다.
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowResolveConfirm(false)}
|
||||||
|
disabled={resolving}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-wing-muted bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleResolve}
|
||||||
|
disabled={resolving}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-white bg-emerald-500 hover:bg-emerald-600 rounded-lg transition-colors disabled:opacity-50 inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
{resolving ? (
|
||||||
|
<>
|
||||||
|
<div className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
||||||
|
처리 중...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'RESOLVED 처리'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function InfoItem({ label, value }: { label: string; value: string }) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<dt className="text-xs font-medium text-wing-muted uppercase tracking-wide">
|
|
||||||
{label}
|
|
||||||
</dt>
|
|
||||||
<dd className="mt-1 text-sm text-wing-text break-words">{value || '-'}</dd>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -410,18 +410,31 @@ export default function Executions() {
|
|||||||
{exec.jobName}
|
{exec.jobName}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
{/* F9: FAILED 상태 클릭 시 실패 로그 모달 */}
|
<div className="flex items-center gap-1.5">
|
||||||
{exec.status === 'FAILED' ? (
|
{/* F9: FAILED 상태 클릭 시 실패 로그 모달 */}
|
||||||
<button
|
{exec.status === 'FAILED' ? (
|
||||||
onClick={() => setFailLogTarget(exec)}
|
<button
|
||||||
className="cursor-pointer"
|
onClick={() => setFailLogTarget(exec)}
|
||||||
title="클릭하여 실패 로그 확인"
|
className="cursor-pointer"
|
||||||
>
|
title="클릭하여 실패 로그 확인"
|
||||||
|
>
|
||||||
|
<StatusBadge status={exec.status} />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
<StatusBadge status={exec.status} />
|
<StatusBadge status={exec.status} />
|
||||||
</button>
|
)}
|
||||||
) : (
|
{exec.status === 'COMPLETED' && exec.failedRecordCount != null && exec.failedRecordCount > 0 && (
|
||||||
<StatusBadge status={exec.status} />
|
<span
|
||||||
)}
|
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] font-semibold text-amber-700 bg-amber-50 border border-amber-200 rounded-full"
|
||||||
|
title={`미해결 실패 레코드 ${exec.failedRecordCount}건`}
|
||||||
|
>
|
||||||
|
<svg className="w-2.5 h-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
{exec.failedRecordCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 text-wing-muted whitespace-nowrap">
|
<td className="px-6 py-4 text-wing-muted whitespace-nowrap">
|
||||||
{formatDateTime(exec.startTime)}
|
{formatDateTime(exec.startTime)}
|
||||||
|
|||||||
@ -1,12 +1,10 @@
|
|||||||
import { useState, useCallback, useEffect } from 'react';
|
import { useState, useCallback } 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 FailedRecordDto,
|
type FailedRecordDto,
|
||||||
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';
|
||||||
@ -14,240 +12,12 @@ import StatusBadge from '../components/StatusBadge';
|
|||||||
import EmptyState from '../components/EmptyState';
|
import EmptyState from '../components/EmptyState';
|
||||||
import LoadingSpinner from '../components/LoadingSpinner';
|
import LoadingSpinner from '../components/LoadingSpinner';
|
||||||
import Pagination from '../components/Pagination';
|
import Pagination from '../components/Pagination';
|
||||||
|
import DetailStatCard from '../components/DetailStatCard';
|
||||||
|
import ApiLogSection from '../components/ApiLogSection';
|
||||||
|
import InfoItem from '../components/InfoItem';
|
||||||
|
|
||||||
const POLLING_INTERVAL_MS = 10_000;
|
const POLLING_INTERVAL_MS = 10_000;
|
||||||
|
|
||||||
interface StatCardProps {
|
|
||||||
label: string;
|
|
||||||
value: number;
|
|
||||||
gradient: string;
|
|
||||||
icon: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatCard({ label, value, gradient, icon }: StatCardProps) {
|
|
||||||
return (
|
|
||||||
<div className={`rounded-xl p-5 text-white shadow-md ${gradient}`}>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-white/80">{label}</p>
|
|
||||||
<p className="mt-1 text-3xl font-bold">
|
|
||||||
{value.toLocaleString()}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<span className="text-3xl opacity-80">{icon}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 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: 10, 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">
|
|
||||||
<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 * 10 + 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>
|
|
||||||
|
|
||||||
{/* 페이지네이션 */}
|
|
||||||
<Pagination
|
|
||||||
page={page}
|
|
||||||
totalPages={logData.totalPages}
|
|
||||||
totalElements={logData.totalElements}
|
|
||||||
pageSize={10}
|
|
||||||
onPageChange={setPage}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<p className="text-xs text-wing-muted py-3 text-center">조회된 로그가 없습니다.</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StepCard({ step, jobName }: { step: StepExecutionDto; jobName: string }) {
|
function StepCard({ step, jobName }: { step: StepExecutionDto; jobName: string }) {
|
||||||
const stats = [
|
const stats = [
|
||||||
{ label: '읽기', value: step.readCount },
|
{ label: '읽기', value: step.readCount },
|
||||||
@ -471,25 +241,25 @@ export default function RecollectDetail() {
|
|||||||
|
|
||||||
{/* 처리 통계 카드 */}
|
{/* 처리 통계 카드 */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
<StatCard
|
<DetailStatCard
|
||||||
label="읽기 (Read)"
|
label="읽기 (Read)"
|
||||||
value={history.readCount ?? 0}
|
value={history.readCount ?? 0}
|
||||||
gradient="bg-gradient-to-br from-blue-500 to-blue-600"
|
gradient="bg-gradient-to-br from-blue-500 to-blue-600"
|
||||||
icon="📥"
|
icon="📥"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<DetailStatCard
|
||||||
label="쓰기 (Write)"
|
label="쓰기 (Write)"
|
||||||
value={history.writeCount ?? 0}
|
value={history.writeCount ?? 0}
|
||||||
gradient="bg-gradient-to-br from-emerald-500 to-emerald-600"
|
gradient="bg-gradient-to-br from-emerald-500 to-emerald-600"
|
||||||
icon="📤"
|
icon="📤"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<DetailStatCard
|
||||||
label="건너뜀 (Skip)"
|
label="건너뜀 (Skip)"
|
||||||
value={history.skipCount ?? 0}
|
value={history.skipCount ?? 0}
|
||||||
gradient="bg-gradient-to-br from-amber-500 to-amber-600"
|
gradient="bg-gradient-to-br from-amber-500 to-amber-600"
|
||||||
icon="⏭"
|
icon="⏭"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<DetailStatCard
|
||||||
label="API 호출"
|
label="API 호출"
|
||||||
value={history.apiCallCount ?? 0}
|
value={history.apiCallCount ?? 0}
|
||||||
gradient="bg-gradient-to-br from-purple-500 to-purple-600"
|
gradient="bg-gradient-to-br from-purple-500 to-purple-600"
|
||||||
@ -631,7 +401,9 @@ const FAILED_PAGE_SIZE = 10;
|
|||||||
function FailedRecordsToggle({ records, jobName, stepExecutionId }: { records: FailedRecordDto[]; jobName: string; stepExecutionId: number }) {
|
function FailedRecordsToggle({ records, jobName, stepExecutionId }: { records: FailedRecordDto[]; jobName: string; stepExecutionId: number }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [showConfirm, setShowConfirm] = useState(false);
|
const [showConfirm, setShowConfirm] = useState(false);
|
||||||
|
const [showResolveConfirm, setShowResolveConfirm] = useState(false);
|
||||||
const [retrying, setRetrying] = useState(false);
|
const [retrying, setRetrying] = useState(false);
|
||||||
|
const [resolving, setResolving] = useState(false);
|
||||||
const [page, setPage] = useState(0);
|
const [page, setPage] = useState(0);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
@ -652,9 +424,15 @@ function FailedRecordsToggle({ records, jobName, stepExecutionId }: { records: F
|
|||||||
try {
|
try {
|
||||||
const keys = failedRecords.map((r) => r.recordKey);
|
const keys = failedRecords.map((r) => r.recordKey);
|
||||||
const result = await batchApi.retryFailedRecords(jobName, keys, stepExecutionId);
|
const result = await batchApi.retryFailedRecords(jobName, keys, stepExecutionId);
|
||||||
if (result.success && result.executionId) {
|
if (result.success) {
|
||||||
setShowConfirm(false);
|
setShowConfirm(false);
|
||||||
navigate(`/executions/${result.executionId}`);
|
if (result.executionId) {
|
||||||
|
navigate(`/executions/${result.executionId}`);
|
||||||
|
} else {
|
||||||
|
alert(result.message || '재수집이 요청되었습니다.');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
alert(result.message || '재수집 실행에 실패했습니다.');
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
alert('재수집 실행에 실패했습니다.');
|
alert('재수집 실행에 실패했습니다.');
|
||||||
@ -663,6 +441,20 @@ function FailedRecordsToggle({ records, jobName, stepExecutionId }: { records: F
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleResolve = async () => {
|
||||||
|
setResolving(true);
|
||||||
|
try {
|
||||||
|
const ids = failedRecords.map((r) => r.id);
|
||||||
|
await batchApi.resolveFailedRecords(ids);
|
||||||
|
setShowResolveConfirm(false);
|
||||||
|
navigate(0);
|
||||||
|
} catch {
|
||||||
|
alert('일괄 RESOLVED 처리에 실패했습니다.');
|
||||||
|
} finally {
|
||||||
|
setResolving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-4 rounded-lg bg-red-50 border border-red-200 p-3">
|
<div className="mt-4 rounded-lg bg-red-50 border border-red-200 p-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@ -676,19 +468,30 @@ function FailedRecordsToggle({ records, jobName, stepExecutionId }: { records: F
|
|||||||
>
|
>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
</svg>
|
</svg>
|
||||||
호출 실패 데이터 ({records.length.toLocaleString()}건)
|
호출 실패 데이터 ({records.length.toLocaleString()}건, FAILED {failedRecords.length}건)
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{failedRecords.length > 0 && (
|
{failedRecords.length > 0 && (
|
||||||
<button
|
<div className="flex items-center gap-1.5">
|
||||||
onClick={() => setShowConfirm(true)}
|
<button
|
||||||
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-medium text-white bg-red-500 hover:bg-red-600 rounded-md transition-colors"
|
onClick={() => setShowResolveConfirm(true)}
|
||||||
>
|
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-medium text-emerald-700 bg-emerald-50 hover:bg-emerald-100 border border-emerald-200 rounded-md transition-colors"
|
||||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
</svg>
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
실패 건 재수집 ({failedRecords.length}건)
|
</svg>
|
||||||
</button>
|
일괄 RESOLVED ({failedRecords.length}건)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowConfirm(true)}
|
||||||
|
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-medium text-white bg-red-500 hover:bg-red-600 rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
|
</svg>
|
||||||
|
실패 건 재수집 ({failedRecords.length}건)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -743,7 +546,7 @@ function FailedRecordsToggle({ records, jobName, stepExecutionId }: { records: F
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 확인 다이얼로그 */}
|
{/* 재수집 확인 다이얼로그 */}
|
||||||
{showConfirm && (
|
{showConfirm && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||||
<div className="bg-white rounded-xl shadow-2xl p-6 w-full max-w-md mx-4">
|
<div className="bg-white rounded-xl shadow-2xl p-6 w-full max-w-md mx-4">
|
||||||
@ -791,17 +594,44 @@ function FailedRecordsToggle({ records, jobName, stepExecutionId }: { records: F
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function InfoItem({ label, value }: { label: string; value: string }) {
|
{/* 일괄 RESOLVED 확인 다이얼로그 */}
|
||||||
return (
|
{showResolveConfirm && (
|
||||||
<div>
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||||
<dt className="text-xs font-medium text-wing-muted uppercase tracking-wide">
|
<div className="bg-white rounded-xl shadow-2xl p-6 w-full max-w-md mx-4">
|
||||||
{label}
|
<h3 className="text-lg font-semibold text-wing-text mb-2">
|
||||||
</dt>
|
일괄 RESOLVED 확인
|
||||||
<dd className="mt-1 text-sm text-wing-text break-words">{value || '-'}</dd>
|
</h3>
|
||||||
|
<p className="text-sm text-wing-muted mb-4">
|
||||||
|
FAILED 상태의 {failedRecords.length}건을 RESOLVED로 변경합니다.
|
||||||
|
이 작업은 되돌릴 수 없습니다.
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowResolveConfirm(false)}
|
||||||
|
disabled={resolving}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-wing-muted bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleResolve}
|
||||||
|
disabled={resolving}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-white bg-emerald-500 hover:bg-emerald-600 rounded-lg transition-colors disabled:opacity-50 inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
{resolving ? (
|
||||||
|
<>
|
||||||
|
<div className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
||||||
|
처리 중...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'RESOLVED 처리'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -278,7 +278,7 @@ export default function Recollects() {
|
|||||||
const data = await batchApi.getLastCollectionStatuses();
|
const data = await batchApi.getLastCollectionStatuses();
|
||||||
setLastCollectionStatuses(data);
|
setLastCollectionStatuses(data);
|
||||||
} catch {
|
} catch {
|
||||||
/* 수집 성공일시 로드 실패 무시 */
|
/* 수집 성공일시 로드 실패 무시 (폴링 중 반복 에러 toast 방지) */
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@ -287,10 +287,16 @@ export default function Recollects() {
|
|||||||
const data = await batchApi.getCollectionPeriods();
|
const data = await batchApi.getCollectionPeriods();
|
||||||
setPeriods(data);
|
setPeriods(data);
|
||||||
} catch {
|
} catch {
|
||||||
/* 수집기간 로드 실패 무시 */
|
/* 수집기간 로드 실패 무시 (폴링 중 반복 에러 toast 방지) */
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const loadMetadata = useCallback(async () => {
|
||||||
|
await Promise.all([loadLastCollectionStatuses(), loadPeriods()]);
|
||||||
|
}, [loadLastCollectionStatuses, loadPeriods]);
|
||||||
|
|
||||||
|
const [initialLoad, setInitialLoad] = useState(true);
|
||||||
|
|
||||||
const loadHistories = useCallback(async () => {
|
const loadHistories = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const params: {
|
const params: {
|
||||||
@ -315,17 +321,22 @@ export default function Recollects() {
|
|||||||
setTotalCount(data.totalElements);
|
setTotalCount(data.totalElements);
|
||||||
setFailedRecordCounts(data.failedRecordCounts ?? {});
|
setFailedRecordCounts(data.failedRecordCounts ?? {});
|
||||||
if (!useSearch) setPage(data.number);
|
if (!useSearch) setPage(data.number);
|
||||||
} catch {
|
setInitialLoad(false);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('이력 조회 실패:', err);
|
||||||
|
/* 초기 로드 실패만 toast, 폴링 중 실패는 console.error만 */
|
||||||
|
if (initialLoad) {
|
||||||
|
showToast('재수집 이력을 불러오지 못했습니다.', 'error');
|
||||||
|
}
|
||||||
setHistories([]);
|
setHistories([]);
|
||||||
setTotalPages(0);
|
setTotalPages(0);
|
||||||
setTotalCount(0);
|
setTotalCount(0);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [selectedApiKey, statusFilter, startDate, endDate, useSearch, page]);
|
}, [selectedApiKey, statusFilter, startDate, endDate, useSearch, page, showToast, initialLoad]);
|
||||||
|
|
||||||
usePoller(loadLastCollectionStatuses, 60_000, []);
|
usePoller(loadMetadata, 60_000, []);
|
||||||
usePoller(loadPeriods, 60_000, []);
|
|
||||||
usePoller(loadHistories, POLLING_INTERVAL_MS, [selectedApiKey, statusFilter, useSearch, page]);
|
usePoller(loadHistories, POLLING_INTERVAL_MS, [selectedApiKey, statusFilter, useSearch, page]);
|
||||||
|
|
||||||
const collectionStatusSummary = useMemo(() => {
|
const collectionStatusSummary = useMemo(() => {
|
||||||
@ -349,6 +360,7 @@ export default function Recollects() {
|
|||||||
setUseSearch(true);
|
setUseSearch(true);
|
||||||
setPage(0);
|
setPage(0);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
await loadHistories();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleResetSearch = () => {
|
const handleResetSearch = () => {
|
||||||
@ -833,6 +845,17 @@ export default function Recollects() {
|
|||||||
초기화
|
초기화
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => batchApi.exportRecollectionHistories({
|
||||||
|
apiKey: selectedApiKey || undefined,
|
||||||
|
status: statusFilter !== 'ALL' ? statusFilter : undefined,
|
||||||
|
fromDate: useSearch && startDate ? `${startDate}T00:00:00` : undefined,
|
||||||
|
toDate: useSearch && endDate ? `${endDate}T23:59:59` : undefined,
|
||||||
|
})}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-wing-text border border-wing-border rounded-lg hover:bg-wing-hover transition-colors"
|
||||||
|
>
|
||||||
|
CSV 내보내기
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -0,0 +1,120 @@
|
|||||||
|
package com.snp.batch.common.batch.listener;
|
||||||
|
|
||||||
|
import com.snp.batch.global.repository.BatchFailedRecordRepository;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.batch.core.BatchStatus;
|
||||||
|
import org.springframework.batch.core.JobExecution;
|
||||||
|
import org.springframework.batch.core.JobExecutionListener;
|
||||||
|
import org.springframework.batch.core.StepExecution;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 배치 Job 완료 후 실패 레코드가 있으면 자동으로 재수집을 트리거하는 리스너.
|
||||||
|
*
|
||||||
|
* 동작 조건:
|
||||||
|
* - Job 상태가 COMPLETED일 때만 실행
|
||||||
|
* - executionMode가 RECOLLECT가 아닌 일반 모드일 때만 실행 (무한 루프 방지)
|
||||||
|
* - StepExecution의 ExecutionContext에 failedRecordKeys가 존재할 때만 실행
|
||||||
|
* - 모든 Step의 failedRecordKeys를 Job 레벨에서 병합한 후 1회만 triggerRetryAsync 호출
|
||||||
|
* - retryCount가 MAX_AUTO_RETRY_COUNT 이상인 키는 재수집에서 제외 (무한 루프 방지)
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AutoRetryJobExecutionListener implements JobExecutionListener {
|
||||||
|
|
||||||
|
private static final String FAILED_RECORD_KEYS = "failedRecordKeys";
|
||||||
|
private static final String FAILED_STEP_EXECUTION_ID = "failedStepExecutionId";
|
||||||
|
private static final String FAILED_API_KEY = "failedApiKey";
|
||||||
|
private static final int MAX_AUTO_RETRY_COUNT = 3;
|
||||||
|
|
||||||
|
private final AutoRetryTriggerService autoRetryTriggerService;
|
||||||
|
private final BatchFailedRecordRepository batchFailedRecordRepository;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void beforeJob(JobExecution jobExecution) {
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void afterJob(JobExecution jobExecution) {
|
||||||
|
String executionMode = jobExecution.getJobParameters()
|
||||||
|
.getString("executionMode", "NORMAL");
|
||||||
|
|
||||||
|
// 재수집 모드에서는 자동 재수집을 트리거하지 않음 (무한 루프 방지)
|
||||||
|
if ("RECOLLECT".equals(executionMode)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Job이 정상 완료된 경우에만 재수집 트리거
|
||||||
|
if (jobExecution.getStatus() != BatchStatus.COMPLETED) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String jobName = jobExecution.getJobInstance().getJobName();
|
||||||
|
|
||||||
|
// 모든 Step의 failedRecordKeys를 Set으로 병합 (중복 제거)
|
||||||
|
Set<String> mergedKeys = new LinkedHashSet<>();
|
||||||
|
Long sourceStepExecutionId = null;
|
||||||
|
String apiKey = null;
|
||||||
|
|
||||||
|
for (StepExecution stepExecution : jobExecution.getStepExecutions()) {
|
||||||
|
String failedKeys = stepExecution.getExecutionContext()
|
||||||
|
.getString(FAILED_RECORD_KEYS, null);
|
||||||
|
|
||||||
|
if (failedKeys == null || failedKeys.isBlank()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
Arrays.stream(failedKeys.split(","))
|
||||||
|
.map(String::trim)
|
||||||
|
.filter(key -> !key.isBlank())
|
||||||
|
.forEach(mergedKeys::add);
|
||||||
|
|
||||||
|
// sourceStepExecutionId: 마지막 Step 것 사용
|
||||||
|
sourceStepExecutionId = stepExecution.getExecutionContext()
|
||||||
|
.containsKey(FAILED_STEP_EXECUTION_ID)
|
||||||
|
? stepExecution.getExecutionContext().getLong(FAILED_STEP_EXECUTION_ID)
|
||||||
|
: stepExecution.getId();
|
||||||
|
|
||||||
|
// apiKey: non-null인 것 중 첫 번째 사용
|
||||||
|
if (apiKey == null) {
|
||||||
|
apiKey = stepExecution.getExecutionContext()
|
||||||
|
.getString(FAILED_API_KEY, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mergedKeys.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// retryCount가 MAX_AUTO_RETRY_COUNT 이상인 키 필터링
|
||||||
|
List<String> exceededKeys = batchFailedRecordRepository.findExceededRetryKeys(
|
||||||
|
jobName, List.copyOf(mergedKeys), MAX_AUTO_RETRY_COUNT);
|
||||||
|
|
||||||
|
if (!exceededKeys.isEmpty()) {
|
||||||
|
log.warn("[AutoRetry] {} Job: 최대 재시도 횟수({})를 초과한 키 {}건 제외: {}",
|
||||||
|
jobName, MAX_AUTO_RETRY_COUNT, exceededKeys.size(), exceededKeys);
|
||||||
|
mergedKeys.removeAll(exceededKeys);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mergedKeys.isEmpty()) {
|
||||||
|
log.warn("[AutoRetry] {} Job: 모든 실패 키가 최대 재시도 횟수를 초과하여 재수집을 건너뜁니다.", jobName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String mergedFailedKeys = String.join(",", mergedKeys);
|
||||||
|
log.info("[AutoRetry] {} Job 완료 후 실패 건 {}건 감지 → 자동 재수집 트리거",
|
||||||
|
jobName, mergedKeys.size());
|
||||||
|
|
||||||
|
// 합산된 키로 1회만 triggerRetryAsync 호출
|
||||||
|
autoRetryTriggerService.triggerRetryAsync(
|
||||||
|
jobName, mergedFailedKeys, sourceStepExecutionId, apiKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,67 @@
|
|||||||
|
package com.snp.batch.common.batch.listener;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.batch.core.Job;
|
||||||
|
import org.springframework.batch.core.JobExecution;
|
||||||
|
import org.springframework.batch.core.JobParameters;
|
||||||
|
import org.springframework.batch.core.JobParametersBuilder;
|
||||||
|
import org.springframework.batch.core.launch.JobLauncher;
|
||||||
|
import org.springframework.context.annotation.Lazy;
|
||||||
|
import org.springframework.scheduling.annotation.Async;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 자동 재수집 Job 비동기 트리거 서비스.
|
||||||
|
* JobExecutionListener 내부 self-invocation으로는 @Async 프록시가 동작하지 않으므로
|
||||||
|
* 별도 빈으로 분리하여 프록시를 통한 비동기 호출을 보장합니다.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
public class AutoRetryTriggerService {
|
||||||
|
|
||||||
|
private final JobLauncher jobLauncher;
|
||||||
|
private final Map<String, Job> jobMap;
|
||||||
|
|
||||||
|
public AutoRetryTriggerService(JobLauncher jobLauncher, @Lazy Map<String, Job> jobMap) {
|
||||||
|
this.jobLauncher = jobLauncher;
|
||||||
|
this.jobMap = jobMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Async("autoRetryExecutor")
|
||||||
|
public void triggerRetryAsync(String jobName, String failedKeys,
|
||||||
|
Long sourceStepExecutionId, String apiKey) {
|
||||||
|
try {
|
||||||
|
Job job = jobMap.get(jobName);
|
||||||
|
if (job == null) {
|
||||||
|
log.error("[AutoRetry] Job을 찾을 수 없습니다: {}", jobName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
JobParametersBuilder builder = new JobParametersBuilder()
|
||||||
|
.addLong("timestamp", System.currentTimeMillis())
|
||||||
|
.addString("retryRecordKeys", failedKeys)
|
||||||
|
.addString("sourceStepExecutionId", String.valueOf(sourceStepExecutionId))
|
||||||
|
.addString("executionMode", "RECOLLECT")
|
||||||
|
.addString("reason", "자동 재수집 (실패 건 자동 처리)")
|
||||||
|
.addString("executor", "AUTO_RETRY");
|
||||||
|
|
||||||
|
if (apiKey != null) {
|
||||||
|
builder.addString("apiKey", apiKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
JobParameters retryParams = builder.toJobParameters();
|
||||||
|
|
||||||
|
log.info("[AutoRetry] 재수집 Job 실행 시작: jobName={}, 실패건={}, sourceStepExecutionId={}",
|
||||||
|
jobName, failedKeys.split(",").length, sourceStepExecutionId);
|
||||||
|
|
||||||
|
JobExecution retryExecution = jobLauncher.run(job, retryParams);
|
||||||
|
|
||||||
|
log.info("[AutoRetry] 재수집 Job 실행 완료: jobName={}, executionId={}, status={}",
|
||||||
|
jobName, retryExecution.getId(), retryExecution.getStatus());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("[AutoRetry] 재수집 Job 실행 실패: jobName={}, error={}", jobName, e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -30,7 +30,7 @@ public class RecollectionJobExecutionListener implements JobExecutionListener {
|
|||||||
|
|
||||||
Long jobExecutionId = jobExecution.getId();
|
Long jobExecutionId = jobExecution.getId();
|
||||||
String jobName = jobExecution.getJobInstance().getJobName();
|
String jobName = jobExecution.getJobInstance().getJobName();
|
||||||
String apiKey = jobExecution.getJobParameters().getString("apiKey");
|
String apiKey = resolveApiKey(jobExecution);
|
||||||
String executor = jobExecution.getJobParameters().getString("executor", "SYSTEM");
|
String executor = jobExecution.getJobParameters().getString("executor", "SYSTEM");
|
||||||
String reason = jobExecution.getJobParameters().getString("reason");
|
String reason = jobExecution.getJobParameters().getString("reason");
|
||||||
|
|
||||||
@ -65,7 +65,7 @@ public class RecollectionJobExecutionListener implements JobExecutionListener {
|
|||||||
|
|
||||||
Long jobExecutionId = jobExecution.getId();
|
Long jobExecutionId = jobExecution.getId();
|
||||||
String status = jobExecution.getStatus().name();
|
String status = jobExecution.getStatus().name();
|
||||||
String apiKey = jobExecution.getJobParameters().getString("apiKey");
|
String apiKey = resolveApiKey(jobExecution);
|
||||||
|
|
||||||
// Step별 통계 집계
|
// Step별 통계 집계
|
||||||
long totalRead = 0;
|
long totalRead = 0;
|
||||||
@ -124,9 +124,16 @@ public class RecollectionJobExecutionListener implements JobExecutionListener {
|
|||||||
apiKey, originalDateStr);
|
apiKey, originalDateStr);
|
||||||
if (originalDateStr != null) {
|
if (originalDateStr != null) {
|
||||||
LocalDateTime originalDate = LocalDateTime.parse(originalDateStr);
|
LocalDateTime originalDate = LocalDateTime.parse(originalDateStr);
|
||||||
recollectionHistoryService.restoreLastSuccessDate(apiKey, originalDate);
|
// 현재 DB 값이 원본보다 미래면(정상 수집 발생) 복원 스킵
|
||||||
log.info("[RecollectionListener] last_success_date 복원 완료: apiKey={}, date={}",
|
LocalDateTime currentDate = recollectionHistoryService.getLastSuccessDate(apiKey);
|
||||||
apiKey, originalDate);
|
if (currentDate != null && currentDate.isAfter(originalDate)) {
|
||||||
|
log.info("[RecollectionListener] last_success_date가 이미 갱신됨 (정상 수집 발생), 복원 스킵: apiKey={}, original={}, current={}",
|
||||||
|
apiKey, originalDate, currentDate);
|
||||||
|
} else {
|
||||||
|
recollectionHistoryService.restoreLastSuccessDate(apiKey, originalDate);
|
||||||
|
log.info("[RecollectionListener] last_success_date 복원 완료: apiKey={}, date={}",
|
||||||
|
apiKey, originalDate);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
log.warn("[RecollectionListener] originalLastSuccessDate가 ExecutionContext에 없음: apiKey={}",
|
log.warn("[RecollectionListener] originalLastSuccessDate가 ExecutionContext에 없음: apiKey={}",
|
||||||
apiKey);
|
apiKey);
|
||||||
@ -137,4 +144,24 @@ public class RecollectionJobExecutionListener implements JobExecutionListener {
|
|||||||
apiKey, jobExecutionId, e);
|
apiKey, jobExecutionId, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Job 파라미터에서 apiKey를 읽고, 없으면 jobName으로 BatchCollectionPeriod에서 조회합니다.
|
||||||
|
* 수동 재수집(UI 실패건 재수집)에서는 apiKey가 파라미터로 전달되지 않을 수 있으므로
|
||||||
|
* jobName → apiKey 매핑을 fallback으로 사용합니다.
|
||||||
|
*/
|
||||||
|
private String resolveApiKey(JobExecution jobExecution) {
|
||||||
|
String apiKey = jobExecution.getJobParameters().getString("apiKey");
|
||||||
|
if (apiKey != null) {
|
||||||
|
return apiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
// fallback: jobName으로 BatchCollectionPeriod에서 apiKey 조회
|
||||||
|
String jobName = jobExecution.getJobInstance().getJobName();
|
||||||
|
apiKey = recollectionHistoryService.findApiKeyByJobName(jobName);
|
||||||
|
if (apiKey != null) {
|
||||||
|
log.info("[RecollectionListener] apiKey를 jobName에서 조회: jobName={}, apiKey={}", jobName, apiKey);
|
||||||
|
}
|
||||||
|
return apiKey;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,4 +21,19 @@ public class AsyncConfig {
|
|||||||
executor.initialize();
|
executor.initialize();
|
||||||
return executor;
|
return executor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 자동 재수집 전용 Executor.
|
||||||
|
* 재수집 Job은 장시간 실행되므로 apiLogExecutor와 분리하여 별도 풀로 관리.
|
||||||
|
*/
|
||||||
|
@Bean(name = "autoRetryExecutor")
|
||||||
|
public Executor autoRetryExecutor() {
|
||||||
|
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
||||||
|
executor.setCorePoolSize(1); // 재수집은 순차적으로 충분
|
||||||
|
executor.setMaxPoolSize(2); // 동시 최대 2개까지 허용
|
||||||
|
executor.setQueueCapacity(10); // 대기 큐 (초과 시 CallerRunsPolicy)
|
||||||
|
executor.setThreadNamePrefix("AutoRetry-");
|
||||||
|
executor.initialize();
|
||||||
|
return executor;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -3,6 +3,7 @@ package com.snp.batch.global.controller;
|
|||||||
import com.snp.batch.global.dto.*;
|
import com.snp.batch.global.dto.*;
|
||||||
import com.snp.batch.global.model.BatchCollectionPeriod;
|
import com.snp.batch.global.model.BatchCollectionPeriod;
|
||||||
import com.snp.batch.global.model.BatchRecollectionHistory;
|
import com.snp.batch.global.model.BatchRecollectionHistory;
|
||||||
|
import com.snp.batch.service.BatchFailedRecordService;
|
||||||
import com.snp.batch.service.BatchService;
|
import com.snp.batch.service.BatchService;
|
||||||
import com.snp.batch.service.RecollectionHistoryService;
|
import com.snp.batch.service.RecollectionHistoryService;
|
||||||
import com.snp.batch.service.ScheduleService;
|
import com.snp.batch.service.ScheduleService;
|
||||||
@ -23,6 +24,10 @@ import org.springframework.web.bind.annotation.*;
|
|||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.PageRequest;
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.PrintWriter;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
@ -40,6 +45,7 @@ public class BatchController {
|
|||||||
private final BatchService batchService;
|
private final BatchService batchService;
|
||||||
private final ScheduleService scheduleService;
|
private final ScheduleService scheduleService;
|
||||||
private final RecollectionHistoryService recollectionHistoryService;
|
private final RecollectionHistoryService recollectionHistoryService;
|
||||||
|
private final BatchFailedRecordService batchFailedRecordService;
|
||||||
|
|
||||||
@Operation(summary = "배치 작업 실행", description = "지정된 배치 작업을 즉시 실행합니다. 쿼리 파라미터로 Job Parameters 전달 가능")
|
@Operation(summary = "배치 작업 실행", description = "지정된 배치 작업을 즉시 실행합니다. 쿼리 파라미터로 Job Parameters 전달 가능")
|
||||||
@ApiResponses(value = {
|
@ApiResponses(value = {
|
||||||
@ -612,4 +618,102 @@ public class BatchController {
|
|||||||
"message", "수집 기간 초기화 실패: " + e.getMessage()));
|
"message", "수집 기간 초기화 실패: " + e.getMessage()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 실패 레코드 관리 API ──────────────────────────────────────
|
||||||
|
|
||||||
|
@Operation(summary = "실패 레코드 일괄 RESOLVED 처리", description = "특정 Job의 FAILED 상태 레코드를 일괄 RESOLVED 처리합니다")
|
||||||
|
@PostMapping("/failed-records/resolve")
|
||||||
|
public ResponseEntity<Map<String, Object>> resolveFailedRecords(
|
||||||
|
@RequestBody Map<String, Object> request) {
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
List<Integer> rawIds = (List<Integer>) request.get("ids");
|
||||||
|
if (rawIds == null || rawIds.isEmpty()) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of(
|
||||||
|
"success", false,
|
||||||
|
"message", "ids는 필수이며 비어있을 수 없습니다"));
|
||||||
|
}
|
||||||
|
List<Long> ids = rawIds.stream().map(Integer::longValue).toList();
|
||||||
|
log.info("Resolve failed records: ids count={}", ids.size());
|
||||||
|
try {
|
||||||
|
int resolved = batchFailedRecordService.resolveByIds(ids);
|
||||||
|
return ResponseEntity.ok(Map.of(
|
||||||
|
"success", true,
|
||||||
|
"resolvedCount", resolved,
|
||||||
|
"message", resolved + "건의 실패 레코드가 RESOLVED 처리되었습니다"));
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Error resolving failed records", e);
|
||||||
|
return ResponseEntity.internalServerError().body(Map.of(
|
||||||
|
"success", false,
|
||||||
|
"message", "실패 레코드 RESOLVED 처리 실패: " + e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 재수집 이력 CSV 내보내기 API ──────────────────────────────
|
||||||
|
|
||||||
|
@Operation(summary = "재수집 이력 CSV 내보내기", description = "필터 조건으로 재수집 이력을 CSV 파일로 내보냅니다 (최대 10,000건)")
|
||||||
|
@GetMapping("/recollection-histories/export")
|
||||||
|
public void exportRecollectionHistories(
|
||||||
|
@Parameter(description = "API Key") @RequestParam(required = false) String apiKey,
|
||||||
|
@Parameter(description = "Job 이름") @RequestParam(required = false) String jobName,
|
||||||
|
@Parameter(description = "실행 상태") @RequestParam(required = false) String status,
|
||||||
|
@Parameter(description = "시작일 (yyyy-MM-dd'T'HH:mm:ss)") @RequestParam(required = false) String fromDate,
|
||||||
|
@Parameter(description = "종료일 (yyyy-MM-dd'T'HH:mm:ss)") @RequestParam(required = false) String toDate,
|
||||||
|
HttpServletResponse response) throws IOException {
|
||||||
|
log.info("Export recollection histories: apiKey={}, jobName={}, status={}", apiKey, jobName, status);
|
||||||
|
|
||||||
|
LocalDateTime from = fromDate != null ? LocalDateTime.parse(fromDate) : null;
|
||||||
|
LocalDateTime to = toDate != null ? LocalDateTime.parse(toDate) : null;
|
||||||
|
|
||||||
|
List<BatchRecollectionHistory> histories = recollectionHistoryService
|
||||||
|
.getHistoriesForExport(apiKey, jobName, status, from, to);
|
||||||
|
|
||||||
|
response.setContentType("text/csv; charset=UTF-8");
|
||||||
|
response.setHeader("Content-Disposition", "attachment; filename=recollection-histories.csv");
|
||||||
|
// BOM for Excel UTF-8
|
||||||
|
response.getOutputStream().write(new byte[]{(byte) 0xEF, (byte) 0xBB, (byte) 0xBF});
|
||||||
|
|
||||||
|
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||||
|
|
||||||
|
PrintWriter writer = response.getWriter();
|
||||||
|
writer.println("이력ID,API Key,작업명,Job실행ID,수집시작일,수집종료일,상태,실행시작,실행종료,소요시간(ms),읽기,쓰기,스킵,API호출,실행자,사유,실패사유,중복여부,생성일");
|
||||||
|
|
||||||
|
for (BatchRecollectionHistory h : histories) {
|
||||||
|
writer.println(String.join(",",
|
||||||
|
safeStr(h.getHistoryId()),
|
||||||
|
safeStr(h.getApiKey()),
|
||||||
|
safeStr(h.getJobName()),
|
||||||
|
safeStr(h.getJobExecutionId()),
|
||||||
|
h.getRangeFromDate() != null ? h.getRangeFromDate().format(formatter) : "",
|
||||||
|
h.getRangeToDate() != null ? h.getRangeToDate().format(formatter) : "",
|
||||||
|
safeStr(h.getExecutionStatus()),
|
||||||
|
h.getExecutionStartTime() != null ? h.getExecutionStartTime().format(formatter) : "",
|
||||||
|
h.getExecutionEndTime() != null ? h.getExecutionEndTime().format(formatter) : "",
|
||||||
|
safeStr(h.getDurationMs()),
|
||||||
|
safeStr(h.getReadCount()),
|
||||||
|
safeStr(h.getWriteCount()),
|
||||||
|
safeStr(h.getSkipCount()),
|
||||||
|
safeStr(h.getApiCallCount()),
|
||||||
|
escapeCsvField(h.getExecutor()),
|
||||||
|
escapeCsvField(h.getRecollectionReason()),
|
||||||
|
escapeCsvField(h.getFailureReason()),
|
||||||
|
h.getHasOverlap() != null ? (h.getHasOverlap() ? "Y" : "N") : "",
|
||||||
|
h.getCreatedAt() != null ? h.getCreatedAt().format(formatter) : ""
|
||||||
|
));
|
||||||
|
}
|
||||||
|
writer.flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String safeStr(Object value) {
|
||||||
|
return value != null ? value.toString() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private String escapeCsvField(String value) {
|
||||||
|
if (value == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
if (value.contains(",") || value.contains("\"") || value.contains("\n")) {
|
||||||
|
return "\"" + value.replace("\"", "\"\"") + "\"";
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,4 +20,5 @@ public class JobExecutionDto {
|
|||||||
private LocalDateTime endTime;
|
private LocalDateTime endTime;
|
||||||
private String exitCode;
|
private String exitCode;
|
||||||
private String exitMessage;
|
private String exitMessage;
|
||||||
|
private Long failedRecordCount;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -35,10 +35,10 @@ public class BatchRecollectionHistory {
|
|||||||
@Column(name = "JOB_EXECUTION_ID")
|
@Column(name = "JOB_EXECUTION_ID")
|
||||||
private Long jobExecutionId;
|
private Long jobExecutionId;
|
||||||
|
|
||||||
@Column(name = "RANGE_FROM_DATE", nullable = false)
|
@Column(name = "RANGE_FROM_DATE")
|
||||||
private LocalDateTime rangeFromDate;
|
private LocalDateTime rangeFromDate;
|
||||||
|
|
||||||
@Column(name = "RANGE_TO_DATE", nullable = false)
|
@Column(name = "RANGE_TO_DATE")
|
||||||
private LocalDateTime rangeToDate;
|
private LocalDateTime rangeToDate;
|
||||||
|
|
||||||
@Column(name = "EXECUTION_STATUS", length = 20, nullable = false)
|
@Column(name = "EXECUTION_STATUS", length = 20, nullable = false)
|
||||||
|
|||||||
@ -5,9 +5,12 @@ import org.springframework.data.jpa.repository.JpaRepository;
|
|||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
public interface BatchCollectionPeriodRepository extends JpaRepository<BatchCollectionPeriod, String> {
|
public interface BatchCollectionPeriodRepository extends JpaRepository<BatchCollectionPeriod, String> {
|
||||||
|
|
||||||
List<BatchCollectionPeriod> findAllByOrderByOrderSeqAsc();
|
List<BatchCollectionPeriod> findAllByOrderByOrderSeqAsc();
|
||||||
|
|
||||||
|
Optional<BatchCollectionPeriod> findByJobName(String jobName);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -28,6 +28,11 @@ public interface BatchFailedRecordRepository extends JpaRepository<BatchFailedRe
|
|||||||
*/
|
*/
|
||||||
List<BatchFailedRecord> findByStepExecutionId(Long stepExecutionId);
|
List<BatchFailedRecord> findByStepExecutionId(Long stepExecutionId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 여러 Step에 대한 실패 레코드 일괄 조회 (N+1 방지)
|
||||||
|
*/
|
||||||
|
List<BatchFailedRecord> findByStepExecutionIdIn(List<Long> stepExecutionIds);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 실행별 실패 건수
|
* 실행별 실패 건수
|
||||||
*/
|
*/
|
||||||
@ -53,4 +58,23 @@ public interface BatchFailedRecordRepository extends JpaRepository<BatchFailedRe
|
|||||||
@Param("stepExecutionId") Long stepExecutionId,
|
@Param("stepExecutionId") Long stepExecutionId,
|
||||||
@Param("recordKeys") List<String> recordKeys,
|
@Param("recordKeys") List<String> recordKeys,
|
||||||
@Param("resolvedAt") LocalDateTime resolvedAt);
|
@Param("resolvedAt") LocalDateTime resolvedAt);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ID 목록으로 FAILED 상태 레코드를 일괄 RESOLVED 처리
|
||||||
|
*/
|
||||||
|
@Modifying
|
||||||
|
@Query("UPDATE BatchFailedRecord r SET r.status = 'RESOLVED', r.resolvedAt = :resolvedAt " +
|
||||||
|
"WHERE r.id IN :ids AND r.status = 'FAILED'")
|
||||||
|
int resolveByIds(@Param("ids") List<Long> ids, @Param("resolvedAt") LocalDateTime resolvedAt);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 최대 재시도 횟수를 초과한 recordKey 목록 조회.
|
||||||
|
* 자동 재수집 무한 루프 방지에 사용.
|
||||||
|
*/
|
||||||
|
@Query("SELECT DISTINCT r.recordKey FROM BatchFailedRecord r " +
|
||||||
|
"WHERE r.jobName = :jobName AND r.recordKey IN :recordKeys " +
|
||||||
|
"AND r.status = 'FAILED' AND r.retryCount >= :maxRetryCount")
|
||||||
|
List<String> findExceededRetryKeys(@Param("jobName") String jobName,
|
||||||
|
@Param("recordKeys") List<String> recordKeys,
|
||||||
|
@Param("maxRetryCount") int maxRetryCount);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -24,6 +24,8 @@ public interface BatchRecollectionHistoryRepository
|
|||||||
SELECT h FROM BatchRecollectionHistory h
|
SELECT h FROM BatchRecollectionHistory h
|
||||||
WHERE h.apiKey = :apiKey
|
WHERE h.apiKey = :apiKey
|
||||||
AND h.historyId != :excludeId
|
AND h.historyId != :excludeId
|
||||||
|
AND h.rangeFromDate IS NOT NULL
|
||||||
|
AND h.rangeToDate IS NOT NULL
|
||||||
AND h.rangeFromDate < :toDate
|
AND h.rangeFromDate < :toDate
|
||||||
AND h.rangeToDate > :fromDate
|
AND h.rangeToDate > :fromDate
|
||||||
ORDER BY h.createdAt DESC
|
ORDER BY h.createdAt DESC
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package com.snp.batch.jobs.shipdetail.batch.config;
|
|||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.snp.batch.common.batch.config.BaseMultiStepJobConfig;
|
import com.snp.batch.common.batch.config.BaseMultiStepJobConfig;
|
||||||
|
import org.springframework.batch.core.JobExecutionListener;
|
||||||
import com.snp.batch.jobs.shipdetail.batch.dto.ShipDetailDto;
|
import com.snp.batch.jobs.shipdetail.batch.dto.ShipDetailDto;
|
||||||
import com.snp.batch.jobs.shipdetail.batch.entity.ShipDetailEntity;
|
import com.snp.batch.jobs.shipdetail.batch.entity.ShipDetailEntity;
|
||||||
import com.snp.batch.jobs.shipdetail.batch.processor.ShipDetailDataProcessor;
|
import com.snp.batch.jobs.shipdetail.batch.processor.ShipDetailDataProcessor;
|
||||||
@ -48,10 +49,11 @@ public class ShipDetailUpdateJobConfig extends BaseMultiStepJobConfig<ShipDetail
|
|||||||
private final ShipDetailUpdateDataReader shipDetailUpdateDataReader;
|
private final ShipDetailUpdateDataReader shipDetailUpdateDataReader;
|
||||||
private final JdbcTemplate jdbcTemplate;
|
private final JdbcTemplate jdbcTemplate;
|
||||||
private final WebClient maritimeApiWebClient;
|
private final WebClient maritimeApiWebClient;
|
||||||
private final ObjectMapper objectMapper; // ObjectMapper 주입 추가
|
private final ObjectMapper objectMapper;
|
||||||
private final BatchDateService batchDateService;
|
private final BatchDateService batchDateService;
|
||||||
private final BatchApiLogService batchApiLogService;
|
private final BatchApiLogService batchApiLogService;
|
||||||
private final BatchFailedRecordService batchFailedRecordService;
|
private final BatchFailedRecordService batchFailedRecordService;
|
||||||
|
private final JobExecutionListener autoRetryJobExecutionListener;
|
||||||
|
|
||||||
@Value("${app.batch.ship-api.url}")
|
@Value("${app.batch.ship-api.url}")
|
||||||
private String maritimeApiUrl;
|
private String maritimeApiUrl;
|
||||||
@ -88,7 +90,8 @@ public class ShipDetailUpdateJobConfig extends BaseMultiStepJobConfig<ShipDetail
|
|||||||
ObjectMapper objectMapper,
|
ObjectMapper objectMapper,
|
||||||
BatchDateService batchDateService,
|
BatchDateService batchDateService,
|
||||||
BatchApiLogService batchApiLogService,
|
BatchApiLogService batchApiLogService,
|
||||||
BatchFailedRecordService batchFailedRecordService) {
|
BatchFailedRecordService batchFailedRecordService,
|
||||||
|
@Qualifier("autoRetryJobExecutionListener") JobExecutionListener autoRetryJobExecutionListener) {
|
||||||
super(jobRepository, transactionManager);
|
super(jobRepository, transactionManager);
|
||||||
this.shipDetailDataProcessor = shipDetailDataProcessor;
|
this.shipDetailDataProcessor = shipDetailDataProcessor;
|
||||||
this.shipDetailDataWriter = shipDetailDataWriter;
|
this.shipDetailDataWriter = shipDetailDataWriter;
|
||||||
@ -99,6 +102,12 @@ public class ShipDetailUpdateJobConfig extends BaseMultiStepJobConfig<ShipDetail
|
|||||||
this.batchDateService = batchDateService;
|
this.batchDateService = batchDateService;
|
||||||
this.batchApiLogService = batchApiLogService;
|
this.batchApiLogService = batchApiLogService;
|
||||||
this.batchFailedRecordService = batchFailedRecordService;
|
this.batchFailedRecordService = batchFailedRecordService;
|
||||||
|
this.autoRetryJobExecutionListener = autoRetryJobExecutionListener;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void configureJob(JobBuilder jobBuilder) {
|
||||||
|
jobBuilder.listener(autoRetryJobExecutionListener);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@ -122,7 +122,8 @@ public class ShipDetailUpdateDataReader extends BaseApiReader<ShipDetailDto> {
|
|||||||
} else {
|
} else {
|
||||||
log.info("[{}] 변경된 IMO 번호 조회 시작...", getReaderName());
|
log.info("[{}] 변경된 IMO 번호 조회 시작...", getReaderName());
|
||||||
ShipUpdateApiResponse response = callShipUpdateApi();
|
ShipUpdateApiResponse response = callShipUpdateApi();
|
||||||
allImoNumbers = extractUpdateImoNumbers(response);
|
List<String> fullList = extractUpdateImoNumbers(response);
|
||||||
|
allImoNumbers = new ArrayList<>(fullList.subList(0, Math.min(60, fullList.size()))); // TODO: 임시 - 테스트용 100건 제한
|
||||||
log.info("[{}] 변경된 IMO 번호 수: {} 개", getReaderName(), response.getShipCount());
|
log.info("[{}] 변경된 IMO 번호 수: {} 개", getReaderName(), response.getShipCount());
|
||||||
log.info("[{}] 총 {} 개의 변경된 IMO 번호 조회 완료", getReaderName(), allImoNumbers.size());
|
log.info("[{}] 총 {} 개의 변경된 IMO 번호 조회 완료", getReaderName(), allImoNumbers.size());
|
||||||
}
|
}
|
||||||
@ -331,9 +332,21 @@ public class ShipDetailUpdateDataReader extends BaseApiReader<ShipDetailDto> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.info("[{}] 전체 {} 개 배치 처리 실패", getReaderName(), totalBatches);
|
log.error("[{}] afterFetch 처리 중 예외 발생: {}", getReaderName(), e.getMessage(), e);
|
||||||
log.info("[{}] 총 {} 개의 IMO 번호에 대한 API 호출 종료",
|
} finally {
|
||||||
getReaderName(), allImoNumbers.size());
|
// 일반 모드에서만: 실패 건이 있으면 ExecutionContext에 저장 (자동 재수집 트리거용)
|
||||||
|
if (!isRetryMode() && !failedImoNumbers.isEmpty() && stepExecution != null) {
|
||||||
|
try {
|
||||||
|
String failedKeys = String.join(",", failedImoNumbers);
|
||||||
|
stepExecution.getExecutionContext().putString("failedRecordKeys", failedKeys);
|
||||||
|
stepExecution.getExecutionContext().putLong("failedStepExecutionId", getStepExecutionId());
|
||||||
|
stepExecution.getExecutionContext().putString("failedApiKey", getApiKey());
|
||||||
|
log.info("[{}] 자동 재수집 대상 실패 키 {} 건 ExecutionContext에 저장",
|
||||||
|
getReaderName(), failedImoNumbers.size());
|
||||||
|
} catch (Exception ex) {
|
||||||
|
log.error("[{}] ExecutionContext 저장 실패: {}", getReaderName(), ex.getMessage(), ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -54,6 +54,19 @@ public class BatchFailedRecordService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ID 목록으로 FAILED 상태 실패 레코드를 일괄 RESOLVED 처리합니다.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public int resolveByIds(List<Long> ids) {
|
||||||
|
if (ids == null || ids.isEmpty()) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
int resolved = batchFailedRecordRepository.resolveByIds(ids, LocalDateTime.now());
|
||||||
|
log.info("실패 레코드 일괄 RESOLVED: {} 건", resolved);
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 재수집 성공 건을 RESOLVED로 처리합니다.
|
* 재수집 성공 건을 RESOLVED로 처리합니다.
|
||||||
* 원본 stepExecutionId로 범위를 제한하여 해당 Step의 실패 건만 RESOLVED 처리합니다.
|
* 원본 stepExecutionId로 범위를 제한하여 해당 Step의 실패 건만 RESOLVED 처리합니다.
|
||||||
|
|||||||
@ -120,18 +120,24 @@ public class BatchService {
|
|||||||
public List<JobExecutionDto> getJobExecutions(String jobName) {
|
public List<JobExecutionDto> getJobExecutions(String jobName) {
|
||||||
List<JobInstance> jobInstances = jobExplorer.findJobInstancesByJobName(jobName, 0, 100);
|
List<JobInstance> jobInstances = jobExplorer.findJobInstancesByJobName(jobName, 0, 100);
|
||||||
|
|
||||||
return jobInstances.stream()
|
List<JobExecutionDto> executions = jobInstances.stream()
|
||||||
.flatMap(instance -> jobExplorer.getJobExecutions(instance).stream())
|
.flatMap(instance -> jobExplorer.getJobExecutions(instance).stream())
|
||||||
.map(this::convertToDto)
|
.map(this::convertToDto)
|
||||||
.sorted(Comparator.comparing(JobExecutionDto::getExecutionId).reversed())
|
.sorted(Comparator.comparing(JobExecutionDto::getExecutionId).reversed())
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
populateFailedRecordCounts(executions);
|
||||||
|
return executions;
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<JobExecutionDto> getRecentExecutions(int limit) {
|
public List<JobExecutionDto> getRecentExecutions(int limit) {
|
||||||
List<Map<String, Object>> recentData = timelineRepository.findRecentExecutions(limit);
|
List<Map<String, Object>> recentData = timelineRepository.findRecentExecutions(limit);
|
||||||
return recentData.stream()
|
List<JobExecutionDto> executions = recentData.stream()
|
||||||
.map(this::convertMapToDto)
|
.map(this::convertMapToDto)
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
populateFailedRecordCounts(executions);
|
||||||
|
return executions;
|
||||||
}
|
}
|
||||||
|
|
||||||
public JobExecutionDto getExecutionDetails(Long executionId) {
|
public JobExecutionDto getExecutionDetails(Long executionId) {
|
||||||
@ -797,6 +803,8 @@ public class BatchService {
|
|||||||
.map(this::convertMapToDto)
|
.map(this::convertMapToDto)
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
populateFailedRecordCounts(executions);
|
||||||
|
|
||||||
return ExecutionSearchResponse.builder()
|
return ExecutionSearchResponse.builder()
|
||||||
.executions(executions)
|
.executions(executions)
|
||||||
.totalCount(totalCount)
|
.totalCount(totalCount)
|
||||||
@ -897,6 +905,29 @@ public class BatchService {
|
|||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 공통: 실패 레코드 건수 세팅 ────────────────────────────────
|
||||||
|
|
||||||
|
private void populateFailedRecordCounts(List<JobExecutionDto> executions) {
|
||||||
|
List<Long> executionIds = executions.stream()
|
||||||
|
.map(JobExecutionDto::getExecutionId)
|
||||||
|
.filter(java.util.Objects::nonNull)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
if (executionIds.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<Long, Long> countMap = failedRecordRepository.countFailedByJobExecutionIds(executionIds)
|
||||||
|
.stream()
|
||||||
|
.collect(Collectors.toMap(
|
||||||
|
row -> ((Number) row[0]).longValue(),
|
||||||
|
row -> ((Number) row[1]).longValue()
|
||||||
|
));
|
||||||
|
|
||||||
|
executions.forEach(exec -> exec.setFailedRecordCount(
|
||||||
|
countMap.getOrDefault(exec.getExecutionId(), 0L)));
|
||||||
|
}
|
||||||
|
|
||||||
// ── 공통: Map → DTO 변환 헬퍼 ────────────────────────────────
|
// ── 공통: Map → DTO 변환 헬퍼 ────────────────────────────────
|
||||||
|
|
||||||
private JobExecutionDto convertMapToDto(Map<String, Object> data) {
|
private JobExecutionDto convertMapToDto(Map<String, Object> data) {
|
||||||
|
|||||||
@ -54,28 +54,56 @@ public class RecollectionHistoryService {
|
|||||||
String executor,
|
String executor,
|
||||||
String reason) {
|
String reason) {
|
||||||
|
|
||||||
Optional<BatchCollectionPeriod> period = periodRepository.findById(apiKey);
|
if (apiKey == null || apiKey.isBlank()) {
|
||||||
if (period.isEmpty()) {
|
log.warn("[RecollectionHistory] apiKey가 null이므로 이력 미생성: jobName={}, executor={}", jobName, executor);
|
||||||
log.warn("[RecollectionHistory] apiKey {} 에 대한 수집기간 없음, 이력 미생성", apiKey);
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
BatchCollectionPeriod cp = period.get();
|
boolean isRetryByRecordKeys = "AUTO_RETRY".equals(executor) || "MANUAL_RETRY".equals(executor);
|
||||||
LocalDateTime rangeFrom = cp.getRangeFromDate();
|
|
||||||
LocalDateTime rangeTo = cp.getRangeToDate();
|
|
||||||
|
|
||||||
// 기간 중복 검출
|
LocalDateTime rangeFrom = null;
|
||||||
List<BatchRecollectionHistory> overlaps = historyRepository
|
LocalDateTime rangeTo = null;
|
||||||
.findOverlappingHistories(apiKey, rangeFrom, rangeTo, -1L);
|
String apiKeyName = null;
|
||||||
boolean hasOverlap = !overlaps.isEmpty();
|
boolean hasOverlap = false;
|
||||||
String overlapIds = overlaps.stream()
|
String overlapIds = null;
|
||||||
.map(h -> String.valueOf(h.getHistoryId()))
|
|
||||||
.collect(Collectors.joining(","));
|
if (isRetryByRecordKeys) {
|
||||||
|
// 실패 건 재수집 (자동/수동): 날짜 범위가 아닌 실패 레코드 키 기반이므로 날짜 없이 이력 생성
|
||||||
|
if (apiKey != null) {
|
||||||
|
apiKeyName = periodRepository.findById(apiKey)
|
||||||
|
.map(BatchCollectionPeriod::getApiKeyName)
|
||||||
|
.orElse(null);
|
||||||
|
}
|
||||||
|
log.info("[RecollectionHistory] 실패 건 재수집 이력 생성 (날짜 범위 없음): executor={}, apiKey={}, apiKeyName={}", executor, apiKey, apiKeyName);
|
||||||
|
} else {
|
||||||
|
// 수동 재수집: BatchCollectionPeriod에서 날짜 범위 조회
|
||||||
|
Optional<BatchCollectionPeriod> period = periodRepository.findById(apiKey);
|
||||||
|
if (period.isEmpty()) {
|
||||||
|
log.warn("[RecollectionHistory] apiKey {} 에 대한 수집기간 없음, 이력 미생성", apiKey);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
BatchCollectionPeriod cp = period.get();
|
||||||
|
rangeFrom = cp.getRangeFromDate();
|
||||||
|
rangeTo = cp.getRangeToDate();
|
||||||
|
apiKeyName = cp.getApiKeyName();
|
||||||
|
|
||||||
|
// 기간 중복 검출
|
||||||
|
List<BatchRecollectionHistory> overlaps = historyRepository
|
||||||
|
.findOverlappingHistories(apiKey, rangeFrom, rangeTo, -1L);
|
||||||
|
hasOverlap = !overlaps.isEmpty();
|
||||||
|
overlapIds = overlaps.stream()
|
||||||
|
.map(h -> String.valueOf(h.getHistoryId()))
|
||||||
|
.collect(Collectors.joining(","));
|
||||||
|
if (overlapIds.length() > 490) {
|
||||||
|
overlapIds = overlapIds.substring(0, 490) + "...";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
LocalDateTime now = LocalDateTime.now();
|
LocalDateTime now = LocalDateTime.now();
|
||||||
BatchRecollectionHistory history = BatchRecollectionHistory.builder()
|
BatchRecollectionHistory history = BatchRecollectionHistory.builder()
|
||||||
.apiKey(apiKey)
|
.apiKey(apiKey)
|
||||||
.apiKeyName(cp.getApiKeyName())
|
.apiKeyName(apiKeyName)
|
||||||
.jobName(jobName)
|
.jobName(jobName)
|
||||||
.jobExecutionId(jobExecutionId)
|
.jobExecutionId(jobExecutionId)
|
||||||
.rangeFromDate(rangeFrom)
|
.rangeFromDate(rangeFrom)
|
||||||
@ -175,6 +203,40 @@ public class RecollectionHistoryService {
|
|||||||
return historyRepository.findAll(spec, pageable);
|
return historyRepository.findAll(spec, pageable);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CSV 내보내기용 전체 목록 조회 (최대 10,000건)
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<BatchRecollectionHistory> getHistoriesForExport(
|
||||||
|
String apiKey, String jobName, String status,
|
||||||
|
LocalDateTime from, LocalDateTime to) {
|
||||||
|
|
||||||
|
Specification<BatchRecollectionHistory> spec = (root, query, cb) -> {
|
||||||
|
List<Predicate> predicates = new ArrayList<>();
|
||||||
|
|
||||||
|
if (apiKey != null && !apiKey.isEmpty()) {
|
||||||
|
predicates.add(cb.equal(root.get("apiKey"), apiKey));
|
||||||
|
}
|
||||||
|
if (jobName != null && !jobName.isEmpty()) {
|
||||||
|
predicates.add(cb.equal(root.get("jobName"), jobName));
|
||||||
|
}
|
||||||
|
if (status != null && !status.isEmpty()) {
|
||||||
|
predicates.add(cb.equal(root.get("executionStatus"), status));
|
||||||
|
}
|
||||||
|
if (from != null) {
|
||||||
|
predicates.add(cb.greaterThanOrEqualTo(root.get("executionStartTime"), from));
|
||||||
|
}
|
||||||
|
if (to != null) {
|
||||||
|
predicates.add(cb.lessThanOrEqualTo(root.get("executionStartTime"), to));
|
||||||
|
}
|
||||||
|
|
||||||
|
query.orderBy(cb.desc(root.get("createdAt")));
|
||||||
|
return cb.and(predicates.toArray(new Predicate[0]));
|
||||||
|
};
|
||||||
|
|
||||||
|
return historyRepository.findAll(spec, org.springframework.data.domain.PageRequest.of(0, 10000)).getContent();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 상세 조회 (중복 이력 실시간 재검사 포함)
|
* 상세 조회 (중복 이력 실시간 재검사 포함)
|
||||||
*/
|
*/
|
||||||
@ -184,10 +246,14 @@ public class RecollectionHistoryService {
|
|||||||
.orElseThrow(() -> new IllegalArgumentException("이력을 찾을 수 없습니다: " + historyId));
|
.orElseThrow(() -> new IllegalArgumentException("이력을 찾을 수 없습니다: " + historyId));
|
||||||
|
|
||||||
// 중복 이력 실시간 재검사
|
// 중복 이력 실시간 재검사
|
||||||
List<BatchRecollectionHistory> currentOverlaps = historyRepository
|
List<BatchRecollectionHistory> currentOverlaps;
|
||||||
.findOverlappingHistories(history.getApiKey(),
|
if (history.getRangeFromDate() != null && history.getRangeToDate() != null) {
|
||||||
history.getRangeFromDate(), history.getRangeToDate(),
|
currentOverlaps = historyRepository.findOverlappingHistories(
|
||||||
history.getHistoryId());
|
history.getApiKey(), history.getRangeFromDate(), history.getRangeToDate(),
|
||||||
|
history.getHistoryId());
|
||||||
|
} else {
|
||||||
|
currentOverlaps = Collections.emptyList();
|
||||||
|
}
|
||||||
|
|
||||||
// API 응답시간 통계
|
// API 응답시간 통계
|
||||||
Map<String, Object> apiStats = null;
|
Map<String, Object> apiStats = null;
|
||||||
@ -212,10 +278,14 @@ public class RecollectionHistoryService {
|
|||||||
.orElseThrow(() -> new IllegalArgumentException("이력을 찾을 수 없습니다: " + historyId));
|
.orElseThrow(() -> new IllegalArgumentException("이력을 찾을 수 없습니다: " + historyId));
|
||||||
|
|
||||||
// 중복 이력 실시간 재검사
|
// 중복 이력 실시간 재검사
|
||||||
List<BatchRecollectionHistory> currentOverlaps = historyRepository
|
List<BatchRecollectionHistory> currentOverlaps;
|
||||||
.findOverlappingHistories(history.getApiKey(),
|
if (history.getRangeFromDate() != null && history.getRangeToDate() != null) {
|
||||||
history.getRangeFromDate(), history.getRangeToDate(),
|
currentOverlaps = historyRepository.findOverlappingHistories(
|
||||||
history.getHistoryId());
|
history.getApiKey(), history.getRangeFromDate(), history.getRangeToDate(),
|
||||||
|
history.getHistoryId());
|
||||||
|
} else {
|
||||||
|
currentOverlaps = Collections.emptyList();
|
||||||
|
}
|
||||||
|
|
||||||
// API 응답시간 통계
|
// API 응답시간 통계
|
||||||
Map<String, Object> apiStats = null;
|
Map<String, Object> apiStats = null;
|
||||||
@ -232,8 +302,16 @@ public class RecollectionHistoryService {
|
|||||||
if (history.getJobExecutionId() != null) {
|
if (history.getJobExecutionId() != null) {
|
||||||
JobExecution jobExecution = jobExplorer.getJobExecution(history.getJobExecutionId());
|
JobExecution jobExecution = jobExplorer.getJobExecution(history.getJobExecutionId());
|
||||||
if (jobExecution != null) {
|
if (jobExecution != null) {
|
||||||
|
// N+1 방지: stepExecutionId 목록을 일괄 조회 후 Map으로 변환
|
||||||
|
List<Long> stepIds = jobExecution.getStepExecutions().stream()
|
||||||
|
.map(StepExecution::getId)
|
||||||
|
.toList();
|
||||||
|
Map<Long, List<BatchFailedRecord>> failedRecordsMap = failedRecordRepository
|
||||||
|
.findByStepExecutionIdIn(stepIds).stream()
|
||||||
|
.collect(Collectors.groupingBy(BatchFailedRecord::getStepExecutionId));
|
||||||
|
|
||||||
stepExecutions = jobExecution.getStepExecutions().stream()
|
stepExecutions = jobExecution.getStepExecutions().stream()
|
||||||
.map(this::convertStepToDto)
|
.map(step -> convertStepToDto(step, failedRecordsMap.getOrDefault(step.getId(), Collections.emptyList())))
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -247,7 +325,8 @@ public class RecollectionHistoryService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private JobExecutionDetailDto.StepExecutionDto convertStepToDto(StepExecution stepExecution) {
|
private JobExecutionDetailDto.StepExecutionDto convertStepToDto(StepExecution stepExecution,
|
||||||
|
List<BatchFailedRecord> failedRecords) {
|
||||||
Long duration = null;
|
Long duration = null;
|
||||||
if (stepExecution.getStartTime() != null && stepExecution.getEndTime() != null) {
|
if (stepExecution.getStartTime() != null && stepExecution.getEndTime() != null) {
|
||||||
duration = Duration.between(stepExecution.getStartTime(), stepExecution.getEndTime()).toMillis();
|
duration = Duration.between(stepExecution.getStartTime(), stepExecution.getEndTime()).toMillis();
|
||||||
@ -270,9 +349,8 @@ public class RecollectionHistoryService {
|
|||||||
JobExecutionDetailDto.StepApiLogSummary apiLogSummary =
|
JobExecutionDetailDto.StepApiLogSummary apiLogSummary =
|
||||||
buildStepApiLogSummary(stepExecution.getId());
|
buildStepApiLogSummary(stepExecution.getId());
|
||||||
|
|
||||||
// Step별 실패 레코드 조회
|
// Step별 실패 레코드 DTO 변환 (사전 일괄 조회된 목록 사용)
|
||||||
List<JobExecutionDetailDto.FailedRecordDto> failedRecordDtos =
|
List<JobExecutionDetailDto.FailedRecordDto> failedRecordDtos = failedRecords.stream()
|
||||||
failedRecordRepository.findByStepExecutionId(stepExecution.getId()).stream()
|
|
||||||
.map(record -> JobExecutionDetailDto.FailedRecordDto.builder()
|
.map(record -> JobExecutionDetailDto.FailedRecordDto.builder()
|
||||||
.id(record.getId())
|
.id(record.getId())
|
||||||
.jobName(record.getJobName())
|
.jobName(record.getJobName())
|
||||||
@ -387,6 +465,16 @@ public class RecollectionHistoryService {
|
|||||||
return stats;
|
return stats;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* jobName으로 BatchCollectionPeriod에서 apiKey를 조회합니다.
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public String findApiKeyByJobName(String jobName) {
|
||||||
|
return periodRepository.findByJobName(jobName)
|
||||||
|
.map(BatchCollectionPeriod::getApiKey)
|
||||||
|
.orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 재수집 실행 전: 현재 last_success_date 조회 (복원용)
|
* 재수집 실행 전: 현재 last_success_date 조회 (복원용)
|
||||||
*/
|
*/
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user