Compare commits
127 커밋
main
...
dev_migrat
| 작성자 | SHA1 | 날짜 | |
|---|---|---|---|
| f1af7f60b2 | |||
| 8755a92f34 | |||
| 41b06beeec | |||
| a9139cd1b3 | |||
| d9988b2d1e | |||
| 6fd37a087c | |||
| a3e90fdf93 | |||
| 50badbe2bb | |||
| 01df023966 | |||
| d2c39009ac | |||
| 90ffe68be3 | |||
| 99b59f0ed5 | |||
| 71d95bd6fa | |||
| ada5bbaa0f | |||
| ce9244ca0a | |||
| b77df66b78 | |||
| cfc80bbb0d | |||
| 0743fd4322 | |||
| 82d427bda2 | |||
| 290933f94f | |||
|
|
178ac506bf | ||
| 07368f18cb | |||
| a93942d4d6 | |||
| f53648290c | |||
| 6555c5e28f | |||
| 3cbc2d2e94 | |||
| a59c91ae1f | |||
| 30304de4e6 | |||
| 7a1b24e381 | |||
| 8d2cd09725 | |||
| 6c4ce9a536 | |||
| 9fed34e1bc | |||
| 21368ffaff | |||
| 7ab53d1bbf | |||
| 613980c496 | |||
| e63607a69d | |||
| f4421fa455 | |||
| 43057d74fb | |||
| 64a3a55e78 | |||
| f2c4e0d14f | |||
| 5305f61a41 | |||
| c3dabd370c | |||
| 9c021f298c | |||
| cbb53fd9f1 | |||
| 49d2de1965 | |||
| 1ab78e881f | |||
| 4e79794750 | |||
| abe5ea1a1c | |||
| d8b8a40316 | |||
| b842ec8d54 | |||
| e1fa48768e | |||
| 87a9217853 | |||
| 6e70e921af | |||
| 3fb133e367 | |||
| 31262f5dda | |||
| 99fcd38d24 | |||
| 7360736cb0 | |||
| 6aba0f55b0 | |||
| 1d2a3c53c8 | |||
| 020f16035b | |||
| 94f7d4b5c0 | |||
|
|
0a5e2e56af | ||
| 32af369f23 | |||
| fcf1d74c38 | |||
| 5683000024 | |||
|
|
a7cf1647f8 | ||
| 6d7b7c9eea | |||
| 6885d41ba5 | |||
| 7b1fe1d52c | |||
| bff4de17c7 | |||
| bda2d812ff | |||
|
|
1124c2e84a | ||
|
|
75531ab5e5 | ||
| 4700ec862b | |||
|
|
e7ea47b02c | ||
|
|
63e9253d7f | ||
| acd76bd358 | |||
| 270b2a0b55 | |||
| 084be88b98 | |||
| fb10e3cc39 | |||
| b2167d4ec7 | |||
| 630c366a06 | |||
|
|
e7f4a9d912 | ||
| 1c491de9e2 | |||
|
|
3118df3533 | ||
| 090f009529 | |||
|
|
c46a62268c | ||
|
|
f2970872fd | ||
|
|
ac78a1340a | ||
|
|
3ee6ae1bf7 | ||
| 2a0a80098d | |||
| eb81be5f21 | |||
| 655318e353 | |||
| 2e509560de | |||
| fedd89c9ca | |||
| 3dde3d0167 | |||
|
|
6c98ebc24f | ||
|
|
18ab11068a | ||
| 37f61fe924 | |||
| e9b30f8817 | |||
|
|
34ce85f33f | ||
|
|
919b0fc21a | ||
|
|
7941396d62 | ||
|
|
248e9c2c46 | ||
|
|
2671d613f3 | ||
| 1b7fa47dbd | |||
| 8d8ea53449 | |||
| 322ecb12a6 | |||
| 55d4dd5886 | |||
| c842e982c8 | |||
| 44ae82e2fa | |||
| d6cf58d737 | |||
| 5857a4a822 | |||
| 6af2fccbf0 | |||
|
|
c99b6993a7 | ||
| b3cb4f6f19 | |||
| 4282fc9106 | |||
| 8a3e9a973e | |||
| 68893f9657 | |||
| 5787fb5be0 | |||
| 4ed1070a37 | |||
| f9b20bdc59 | |||
| 7a405bb969 | |||
| 906611c9b8 | |||
|
|
e44637e1f3 | ||
| 6be90723b4 | |||
| 18fa95e903 |
73
.claude/rules/code-style.md
Normal file
73
.claude/rules/code-style.md
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
# Java 코드 스타일 규칙
|
||||||
|
|
||||||
|
## 일반
|
||||||
|
- Java 17+ 문법 사용 (record, sealed class, pattern matching, text block 활용)
|
||||||
|
- 들여쓰기: 4 spaces (탭 사용 금지)
|
||||||
|
- 줄 길이: 120자 이하
|
||||||
|
- 파일 끝에 빈 줄 추가
|
||||||
|
|
||||||
|
## 클래스 구조
|
||||||
|
클래스 내 멤버 순서:
|
||||||
|
1. static 상수 (public → private)
|
||||||
|
2. 인스턴스 필드 (public → private)
|
||||||
|
3. 생성자
|
||||||
|
4. public 메서드
|
||||||
|
5. protected/package-private 메서드
|
||||||
|
6. private 메서드
|
||||||
|
7. inner class/enum
|
||||||
|
|
||||||
|
## Spring Boot 규칙
|
||||||
|
|
||||||
|
### 계층 구조
|
||||||
|
- Controller → Service → Repository 단방향 의존
|
||||||
|
- Controller에 비즈니스 로직 금지 (요청/응답 변환만)
|
||||||
|
- Service 계층 간 순환 참조 금지
|
||||||
|
- Repository에 비즈니스 로직 금지
|
||||||
|
|
||||||
|
### DTO와 Entity 분리
|
||||||
|
- API 요청/응답에 Entity 직접 사용 금지
|
||||||
|
- DTO는 record 또는 불변 클래스로 작성
|
||||||
|
- DTO ↔ Entity 변환은 매퍼 클래스 또는 팩토리 메서드 사용
|
||||||
|
|
||||||
|
### 의존성 주입
|
||||||
|
- 생성자 주입 사용 (필드 주입 `@Autowired` 사용 금지)
|
||||||
|
- 단일 생성자는 `@Autowired` 어노테이션 생략
|
||||||
|
- Lombok `@RequiredArgsConstructor` 사용 가능
|
||||||
|
|
||||||
|
### 트랜잭션
|
||||||
|
- `@Transactional` 범위 최소화
|
||||||
|
- 읽기 전용: `@Transactional(readOnly = true)`
|
||||||
|
- Service 메서드 레벨에 적용 (클래스 레벨 지양)
|
||||||
|
|
||||||
|
## Lombok 규칙
|
||||||
|
- `@Getter`, `@Setter` 허용 (Entity에서 Setter는 지양)
|
||||||
|
- `@Builder` 허용
|
||||||
|
- `@Data` 사용 금지 (명시적으로 필요한 어노테이션만)
|
||||||
|
- `@AllArgsConstructor` 단독 사용 금지 (`@Builder`와 함께 사용)
|
||||||
|
|
||||||
|
## 로깅
|
||||||
|
- `@Slf4j` (Lombok) 로거 사용
|
||||||
|
- SLF4J `{}` 플레이스홀더에 printf 포맷 사용 금지 (`{:.1f}`, `{:d}`, `{%s}` 등)
|
||||||
|
- 숫자 포맷이 필요하면 `String.format()`으로 변환 후 전달
|
||||||
|
```java
|
||||||
|
// 잘못됨
|
||||||
|
log.info("처리율: {:.1f}%", rate);
|
||||||
|
// 올바름
|
||||||
|
log.info("처리율: {}%", String.format("%.1f", rate));
|
||||||
|
```
|
||||||
|
- 예외 로깅 시 예외 객체는 마지막 인자로 전달 (플레이스홀더 불필요)
|
||||||
|
```java
|
||||||
|
log.error("처리 실패: {}", id, exception);
|
||||||
|
```
|
||||||
|
|
||||||
|
## 예외 처리
|
||||||
|
- 비즈니스 예외는 커스텀 Exception 클래스 정의
|
||||||
|
- `@ControllerAdvice`로 전역 예외 처리
|
||||||
|
- 예외 메시지에 컨텍스트 정보 포함
|
||||||
|
- catch 블록에서 예외 무시 금지 (`// ignore` 금지)
|
||||||
|
|
||||||
|
## 기타
|
||||||
|
- `Optional`은 반환 타입으로만 사용 (필드, 파라미터에 사용 금지)
|
||||||
|
- `null` 반환보다 빈 컬렉션 또는 `Optional` 반환
|
||||||
|
- Stream API 활용 (단, 3단계 이상 체이닝은 메서드 추출)
|
||||||
|
- 하드코딩된 문자열/숫자 금지 → 상수 또는 설정값으로 추출
|
||||||
84
.claude/rules/git-workflow.md
Normal file
84
.claude/rules/git-workflow.md
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
# Git 워크플로우 규칙
|
||||||
|
|
||||||
|
## 브랜치 전략
|
||||||
|
|
||||||
|
### 브랜치 구조
|
||||||
|
```
|
||||||
|
main ← 배포 가능한 안정 브랜치 (보호됨)
|
||||||
|
└── develop ← 개발 통합 브랜치
|
||||||
|
├── feature/ISSUE-123-기능설명
|
||||||
|
├── bugfix/ISSUE-456-버그설명
|
||||||
|
└── hotfix/ISSUE-789-긴급수정
|
||||||
|
```
|
||||||
|
|
||||||
|
### 브랜치 네이밍
|
||||||
|
- feature 브랜치: `feature/ISSUE-번호-간단설명` (예: `feature/ISSUE-42-user-login`)
|
||||||
|
- bugfix 브랜치: `bugfix/ISSUE-번호-간단설명`
|
||||||
|
- hotfix 브랜치: `hotfix/ISSUE-번호-간단설명`
|
||||||
|
- 이슈 번호가 없는 경우: `feature/간단설명` (예: `feature/add-swagger-docs`)
|
||||||
|
|
||||||
|
### 브랜치 규칙
|
||||||
|
- main, develop 브랜치에 직접 커밋/푸시 금지
|
||||||
|
- feature 브랜치는 develop에서 분기
|
||||||
|
- hotfix 브랜치는 main에서 분기
|
||||||
|
- 머지는 반드시 MR(Merge Request)을 통해 수행
|
||||||
|
|
||||||
|
## 커밋 메시지 규칙
|
||||||
|
|
||||||
|
### Conventional Commits 형식
|
||||||
|
```
|
||||||
|
type(scope): subject
|
||||||
|
|
||||||
|
body (선택)
|
||||||
|
|
||||||
|
footer (선택)
|
||||||
|
```
|
||||||
|
|
||||||
|
### type (필수)
|
||||||
|
| type | 설명 |
|
||||||
|
|------|------|
|
||||||
|
| feat | 새로운 기능 추가 |
|
||||||
|
| fix | 버그 수정 |
|
||||||
|
| docs | 문서 변경 |
|
||||||
|
| style | 코드 포맷팅 (기능 변경 없음) |
|
||||||
|
| refactor | 리팩토링 (기능 변경 없음) |
|
||||||
|
| test | 테스트 추가/수정 |
|
||||||
|
| chore | 빌드, 설정 변경 |
|
||||||
|
| ci | CI/CD 설정 변경 |
|
||||||
|
| perf | 성능 개선 |
|
||||||
|
|
||||||
|
### scope (선택)
|
||||||
|
- 변경 범위를 나타내는 짧은 단어
|
||||||
|
- 한국어, 영어 모두 허용 (예: `feat(인증): 로그인 기능`, `fix(auth): token refresh`)
|
||||||
|
|
||||||
|
### subject (필수)
|
||||||
|
- 변경 내용을 간결하게 설명
|
||||||
|
- 한국어, 영어 모두 허용
|
||||||
|
- 72자 이내
|
||||||
|
- 마침표(.) 없이 끝냄
|
||||||
|
|
||||||
|
### 예시
|
||||||
|
```
|
||||||
|
feat(auth): JWT 기반 로그인 구현
|
||||||
|
fix(배치): 야간 배치 타임아웃 수정
|
||||||
|
docs: README에 빌드 방법 추가
|
||||||
|
refactor(user-service): 중복 로직 추출
|
||||||
|
test(결제): 환불 로직 단위 테스트 추가
|
||||||
|
chore: Gradle 의존성 버전 업데이트
|
||||||
|
```
|
||||||
|
|
||||||
|
## MR(Merge Request) 규칙
|
||||||
|
|
||||||
|
### MR 생성
|
||||||
|
- 제목: 커밋 메시지와 동일한 Conventional Commits 형식
|
||||||
|
- 본문: 변경 내용 요약, 테스트 방법, 관련 이슈 번호
|
||||||
|
- 라벨: 적절한 라벨 부착 (feature, bugfix, hotfix 등)
|
||||||
|
|
||||||
|
### MR 리뷰
|
||||||
|
- 최소 1명의 리뷰어 승인 필수
|
||||||
|
- CI 검증 통과 필수 (설정된 경우)
|
||||||
|
- 리뷰 코멘트 모두 해결 후 머지
|
||||||
|
|
||||||
|
### MR 머지
|
||||||
|
- Squash Merge 권장 (깔끔한 히스토리)
|
||||||
|
- 머지 후 소스 브랜치 삭제
|
||||||
60
.claude/rules/naming.md
Normal file
60
.claude/rules/naming.md
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
# Java 네이밍 규칙
|
||||||
|
|
||||||
|
## 패키지
|
||||||
|
- 모두 소문자, 단수형
|
||||||
|
- 도메인 역순: `com.gcsc.프로젝트명.모듈`
|
||||||
|
- 예: `com.gcsc.batch.scheduler`, `com.gcsc.api.auth`
|
||||||
|
|
||||||
|
## 클래스
|
||||||
|
- PascalCase
|
||||||
|
- 명사 또는 명사구
|
||||||
|
- 접미사로 역할 표시:
|
||||||
|
|
||||||
|
| 계층 | 접미사 | 예시 |
|
||||||
|
|------|--------|------|
|
||||||
|
| Controller | `Controller` | `UserController` |
|
||||||
|
| Service | `Service` | `UserService` |
|
||||||
|
| Service 구현 | `ServiceImpl` | `UserServiceImpl` (인터페이스 있을 때만) |
|
||||||
|
| Repository | `Repository` | `UserRepository` |
|
||||||
|
| Entity | (없음) | `User`, `ShipRoute` |
|
||||||
|
| DTO 요청 | `Request` | `CreateUserRequest` |
|
||||||
|
| DTO 응답 | `Response` | `UserResponse` |
|
||||||
|
| 설정 | `Config` | `SecurityConfig` |
|
||||||
|
| 예외 | `Exception` | `UserNotFoundException` |
|
||||||
|
| Enum | (없음) | `UserStatus`, `ShipType` |
|
||||||
|
| Mapper | `Mapper` | `UserMapper` |
|
||||||
|
|
||||||
|
## 메서드
|
||||||
|
- camelCase
|
||||||
|
- 동사로 시작
|
||||||
|
- CRUD 패턴:
|
||||||
|
|
||||||
|
| 작업 | Controller | Service | Repository |
|
||||||
|
|------|-----------|---------|------------|
|
||||||
|
| 조회(단건) | `getUser()` | `getUser()` | `findById()` |
|
||||||
|
| 조회(목록) | `getUsers()` | `getUsers()` | `findAll()` |
|
||||||
|
| 생성 | `createUser()` | `createUser()` | `save()` |
|
||||||
|
| 수정 | `updateUser()` | `updateUser()` | `save()` |
|
||||||
|
| 삭제 | `deleteUser()` | `deleteUser()` | `deleteById()` |
|
||||||
|
| 존재확인 | - | `existsUser()` | `existsById()` |
|
||||||
|
|
||||||
|
## 변수
|
||||||
|
- camelCase
|
||||||
|
- 의미 있는 이름 (단일 문자 변수 금지, 루프 인덱스 `i, j, k` 예외)
|
||||||
|
- boolean: `is`, `has`, `can`, `should` 접두사
|
||||||
|
- 예: `isActive`, `hasPermission`, `canDelete`
|
||||||
|
|
||||||
|
## 상수
|
||||||
|
- UPPER_SNAKE_CASE
|
||||||
|
- 예: `MAX_RETRY_COUNT`, `DEFAULT_PAGE_SIZE`
|
||||||
|
|
||||||
|
## 테스트
|
||||||
|
- 클래스: `{대상클래스}Test` (예: `UserServiceTest`)
|
||||||
|
- 메서드: `{메서드명}_{시나리오}_{기대결과}` 또는 한국어 `@DisplayName`
|
||||||
|
- 예: `createUser_withDuplicateEmail_throwsException()`
|
||||||
|
- 예: `@DisplayName("중복 이메일로 생성 시 예외 발생")`
|
||||||
|
|
||||||
|
## 파일/디렉토리
|
||||||
|
- Java 파일: PascalCase (클래스명과 동일)
|
||||||
|
- 리소스 파일: kebab-case (예: `application-local.yml`)
|
||||||
|
- SQL 파일: `V{번호}__{설명}.sql` (Flyway) 또는 kebab-case
|
||||||
34
.claude/rules/team-policy.md
Normal file
34
.claude/rules/team-policy.md
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
# 팀 정책 (Team Policy)
|
||||||
|
|
||||||
|
이 규칙은 조직 전체에 적용되는 필수 정책입니다.
|
||||||
|
프로젝트별 `.claude/rules/`에 추가 규칙을 정의할 수 있으나, 이 정책을 위반할 수 없습니다.
|
||||||
|
|
||||||
|
## 보안 정책
|
||||||
|
|
||||||
|
### 금지 행위
|
||||||
|
- `.env`, `.env.*`, `secrets/` 파일 읽기 및 내용 출력 금지
|
||||||
|
- 비밀번호, API 키, 토큰 등 민감 정보를 코드에 하드코딩 금지
|
||||||
|
- `git push --force`, `git reset --hard`, `git clean -fd` 실행 금지
|
||||||
|
- `rm -rf /`, `rm -rf ~`, `rm -rf .git` 등 파괴적 명령 실행 금지
|
||||||
|
- main/develop 브랜치에 직접 push 금지 (MR을 통해서만 머지)
|
||||||
|
|
||||||
|
### 인증 정보 관리
|
||||||
|
- 환경변수 또는 외부 설정 파일(`.env`, `application-local.yml`)로 관리
|
||||||
|
- 설정 파일은 `.gitignore`에 반드시 포함
|
||||||
|
- 예시 파일(`.env.example`, `application.yml.example`)만 커밋
|
||||||
|
|
||||||
|
## 코드 품질 정책
|
||||||
|
|
||||||
|
### 필수 검증
|
||||||
|
- 커밋 전 빌드(컴파일) 성공 확인
|
||||||
|
- 린트 경고 0개 유지 (CI에서도 검증)
|
||||||
|
- 테스트 코드가 있는 프로젝트는 테스트 통과 필수
|
||||||
|
|
||||||
|
### 코드 리뷰
|
||||||
|
- main 브랜치 머지 시 최소 1명 리뷰 필수
|
||||||
|
- 리뷰어 승인 없이 머지 불가
|
||||||
|
|
||||||
|
## 문서화 정책
|
||||||
|
- 공개 API(controller endpoint)에는 반드시 설명 주석 작성
|
||||||
|
- 복잡한 비즈니스 로직에는 의도를 설명하는 주석 작성
|
||||||
|
- README.md에 프로젝트 빌드/실행 방법 유지
|
||||||
62
.claude/rules/testing.md
Normal file
62
.claude/rules/testing.md
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
# Java 테스트 규칙
|
||||||
|
|
||||||
|
## 테스트 프레임워크
|
||||||
|
- JUnit 5 + AssertJ 조합
|
||||||
|
- Mockito로 의존성 모킹
|
||||||
|
- Spring Boot Test (`@SpringBootTest`) 는 통합 테스트에만 사용
|
||||||
|
|
||||||
|
## 테스트 구조
|
||||||
|
|
||||||
|
### 단위 테스트 (Unit Test)
|
||||||
|
- Service, Util, Domain 로직 테스트
|
||||||
|
- Spring 컨텍스트 로딩 없이 (`@ExtendWith(MockitoExtension.class)`)
|
||||||
|
- 외부 의존성은 Mockito로 모킹
|
||||||
|
|
||||||
|
```java
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class UserServiceTest {
|
||||||
|
@InjectMocks
|
||||||
|
private UserService userService;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private UserRepository userRepository;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("사용자 생성 시 정상 저장")
|
||||||
|
void createUser_withValidInput_savesUser() {
|
||||||
|
// given
|
||||||
|
// when
|
||||||
|
// then
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 통합 테스트 (Integration Test)
|
||||||
|
- Controller 테스트: `@WebMvcTest` + `MockMvc`
|
||||||
|
- Repository 테스트: `@DataJpaTest`
|
||||||
|
- 전체 플로우: `@SpringBootTest` (최소화)
|
||||||
|
|
||||||
|
### 테스트 패턴
|
||||||
|
- **Given-When-Then** 구조 사용
|
||||||
|
- 각 섹션을 주석으로 구분
|
||||||
|
- 하나의 테스트에 하나의 검증 원칙 (가능한 범위에서)
|
||||||
|
|
||||||
|
## 테스트 네이밍
|
||||||
|
- 메서드명: `{메서드}_{시나리오}_{기대결과}` 패턴
|
||||||
|
- `@DisplayName`: 한국어로 테스트 의도 설명
|
||||||
|
|
||||||
|
## 테스트 커버리지
|
||||||
|
- 새로 작성하는 Service 클래스: 핵심 비즈니스 로직 테스트 필수
|
||||||
|
- 기존 코드 수정 시: 수정된 로직에 대한 테스트 추가 권장
|
||||||
|
- Controller: 주요 API endpoint 통합 테스트 권장
|
||||||
|
|
||||||
|
## 테스트 데이터
|
||||||
|
- 테스트 데이터는 테스트 메서드 내부 또는 `@BeforeEach`에서 생성
|
||||||
|
- 공통 테스트 데이터는 TestFixture 클래스로 분리
|
||||||
|
- 실제 DB 연결 필요 시 H2 인메모리 또는 Testcontainers 사용
|
||||||
|
|
||||||
|
## 금지 사항
|
||||||
|
- `@SpringBootTest`를 단위 테스트에 사용 금지
|
||||||
|
- 테스트 간 상태 공유 금지
|
||||||
|
- `Thread.sleep()` 사용 금지 → `Awaitility` 사용
|
||||||
|
- 실제 외부 API 호출 금지 → WireMock 또는 Mockito 사용
|
||||||
78
.claude/settings.json
Normal file
78
.claude/settings.json
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/claude-code-settings.json",
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(./mvnw *)",
|
||||||
|
"Bash(mvn *)",
|
||||||
|
"Bash(java -version)",
|
||||||
|
"Bash(git status)",
|
||||||
|
"Bash(git diff*)",
|
||||||
|
"Bash(git log*)",
|
||||||
|
"Bash(git branch*)",
|
||||||
|
"Bash(git checkout*)",
|
||||||
|
"Bash(git add*)",
|
||||||
|
"Bash(git commit*)",
|
||||||
|
"Bash(git pull*)",
|
||||||
|
"Bash(git fetch*)",
|
||||||
|
"Bash(git merge*)",
|
||||||
|
"Bash(git stash*)",
|
||||||
|
"Bash(git remote*)",
|
||||||
|
"Bash(git config*)",
|
||||||
|
"Bash(git rev-parse*)",
|
||||||
|
"Bash(git show*)",
|
||||||
|
"Bash(git tag*)",
|
||||||
|
"Bash(curl -s *)",
|
||||||
|
"Bash(sdk *)"
|
||||||
|
],
|
||||||
|
"deny": [
|
||||||
|
"Bash(git push --force*)",
|
||||||
|
"Bash(git reset --hard*)",
|
||||||
|
"Bash(git clean -fd*)",
|
||||||
|
"Bash(git checkout -- .)",
|
||||||
|
"Bash(rm -rf /)",
|
||||||
|
"Bash(rm -rf ~)",
|
||||||
|
"Bash(rm -rf .git*)",
|
||||||
|
"Bash(rm -rf /*)",
|
||||||
|
"Read(./**/.env*)",
|
||||||
|
"Read(./**/secrets/**)",
|
||||||
|
"Read(./**/application-local.yml)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hooks": {
|
||||||
|
"SessionStart": [
|
||||||
|
{
|
||||||
|
"matcher": "compact",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "bash .claude/scripts/on-post-compact.sh",
|
||||||
|
"timeout": 10
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"PreCompact": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "bash .claude/scripts/on-pre-compact.sh",
|
||||||
|
"timeout": 30
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"PostToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": "Bash",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "bash .claude/scripts/on-commit.sh",
|
||||||
|
"timeout": 15
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
65
.claude/skills/create-mr/SKILL.md
Normal file
65
.claude/skills/create-mr/SKILL.md
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
---
|
||||||
|
name: create-mr
|
||||||
|
description: 현재 브랜치에서 Gitea MR(Merge Request)을 생성합니다
|
||||||
|
allowed-tools: "Bash, Read, Grep"
|
||||||
|
argument-hint: "[target-branch: develop|main] (기본: develop)"
|
||||||
|
---
|
||||||
|
|
||||||
|
현재 브랜치의 변경 사항을 기반으로 Gitea에 MR을 생성합니다.
|
||||||
|
타겟 브랜치: $ARGUMENTS (기본: develop)
|
||||||
|
|
||||||
|
## 수행 단계
|
||||||
|
|
||||||
|
### 1. 사전 검증
|
||||||
|
- 현재 브랜치가 main/develop이 아닌지 확인
|
||||||
|
- 커밋되지 않은 변경 사항 확인 (있으면 경고)
|
||||||
|
- 리모트에 현재 브랜치가 push되어 있는지 확인 (안 되어 있으면 push)
|
||||||
|
|
||||||
|
### 2. 변경 내역 분석
|
||||||
|
```bash
|
||||||
|
git log develop..HEAD --oneline
|
||||||
|
git diff develop..HEAD --stat
|
||||||
|
```
|
||||||
|
- 커밋 목록과 변경된 파일 목록 수집
|
||||||
|
- 주요 변경 사항 요약 작성
|
||||||
|
|
||||||
|
### 3. MR 정보 구성
|
||||||
|
- **제목**: 브랜치의 첫 커밋 메시지 또는 브랜치명에서 추출
|
||||||
|
- `feature/ISSUE-42-user-login` → `feat: ISSUE-42 user-login`
|
||||||
|
- **본문**:
|
||||||
|
```markdown
|
||||||
|
## 변경 사항
|
||||||
|
- (커밋 기반 자동 생성)
|
||||||
|
|
||||||
|
## 관련 이슈
|
||||||
|
- closes #이슈번호 (브랜치명에서 추출)
|
||||||
|
|
||||||
|
## 테스트
|
||||||
|
- [ ] 빌드 성공 확인
|
||||||
|
- [ ] 기존 테스트 통과
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Gitea API로 MR 생성
|
||||||
|
```bash
|
||||||
|
# Gitea remote URL에서 owner/repo 추출
|
||||||
|
REMOTE_URL=$(git remote get-url origin)
|
||||||
|
|
||||||
|
# Gitea API 호출
|
||||||
|
curl -X POST "GITEA_URL/api/v1/repos/{owner}/{repo}/pulls" \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"title": "MR 제목",
|
||||||
|
"body": "MR 본문",
|
||||||
|
"head": "현재브랜치",
|
||||||
|
"base": "타겟브랜치"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 결과 출력
|
||||||
|
- MR URL 출력
|
||||||
|
- 리뷰어 지정 안내
|
||||||
|
- 다음 단계: 리뷰 대기 → 승인 → 머지
|
||||||
|
|
||||||
|
## 필요 환경변수
|
||||||
|
- `GITEA_TOKEN`: Gitea API 접근 토큰 (없으면 안내)
|
||||||
49
.claude/skills/fix-issue/SKILL.md
Normal file
49
.claude/skills/fix-issue/SKILL.md
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
---
|
||||||
|
name: fix-issue
|
||||||
|
description: Gitea 이슈를 분석하고 수정 브랜치를 생성합니다
|
||||||
|
allowed-tools: "Bash, Read, Write, Edit, Glob, Grep"
|
||||||
|
argument-hint: "<issue-number>"
|
||||||
|
---
|
||||||
|
|
||||||
|
Gitea 이슈 #$ARGUMENTS 를 분석하고 수정 작업을 시작합니다.
|
||||||
|
|
||||||
|
## 수행 단계
|
||||||
|
|
||||||
|
### 1. 이슈 조회
|
||||||
|
```bash
|
||||||
|
curl -s "GITEA_URL/api/v1/repos/{owner}/{repo}/issues/$ARGUMENTS" \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}"
|
||||||
|
```
|
||||||
|
- 이슈 제목, 본문, 라벨, 담당자 정보 확인
|
||||||
|
- 이슈 내용을 사용자에게 요약하여 보여줌
|
||||||
|
|
||||||
|
### 2. 브랜치 생성
|
||||||
|
이슈 라벨에 따라 브랜치 타입 결정:
|
||||||
|
- `bug` 라벨 → `bugfix/ISSUE-번호-설명`
|
||||||
|
- 그 외 → `feature/ISSUE-번호-설명`
|
||||||
|
- 긴급 → `hotfix/ISSUE-번호-설명`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout develop
|
||||||
|
git pull origin develop
|
||||||
|
git checkout -b {type}/ISSUE-{number}-{slug}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 이슈 분석
|
||||||
|
이슈 내용을 바탕으로:
|
||||||
|
- 관련 파일 탐색 (Grep, Glob 활용)
|
||||||
|
- 영향 범위 파악
|
||||||
|
- 수정 방향 제안
|
||||||
|
|
||||||
|
### 4. 수정 계획 제시
|
||||||
|
사용자에게 수정 계획을 보여주고 승인을 받은 후 작업 진행:
|
||||||
|
- 수정할 파일 목록
|
||||||
|
- 변경 내용 요약
|
||||||
|
- 예상 영향
|
||||||
|
|
||||||
|
### 5. 작업 완료 후
|
||||||
|
- 변경 사항 요약
|
||||||
|
- `/create-mr` 실행 안내
|
||||||
|
|
||||||
|
## 필요 환경변수
|
||||||
|
- `GITEA_TOKEN`: Gitea API 접근 토큰
|
||||||
246
.claude/skills/init-project/SKILL.md
Normal file
246
.claude/skills/init-project/SKILL.md
Normal file
@ -0,0 +1,246 @@
|
|||||||
|
---
|
||||||
|
name: init-project
|
||||||
|
description: 팀 표준 워크플로우로 프로젝트를 초기화합니다
|
||||||
|
allowed-tools: "Bash, Read, Write, Edit, Glob, Grep"
|
||||||
|
argument-hint: "[project-type: java-maven|java-gradle|react-ts|auto]"
|
||||||
|
---
|
||||||
|
|
||||||
|
팀 표준 워크플로우에 따라 프로젝트를 초기화합니다.
|
||||||
|
프로젝트 타입: $ARGUMENTS (기본: auto — 자동 감지)
|
||||||
|
|
||||||
|
## 프로젝트 타입 자동 감지
|
||||||
|
|
||||||
|
$ARGUMENTS가 "auto"이거나 비어있으면 다음 순서로 감지:
|
||||||
|
1. `pom.xml` 존재 → **java-maven**
|
||||||
|
2. `build.gradle` 또는 `build.gradle.kts` 존재 → **java-gradle**
|
||||||
|
3. `package.json` + `tsconfig.json` 존재 → **react-ts**
|
||||||
|
4. 감지 실패 → 사용자에게 타입 선택 요청
|
||||||
|
|
||||||
|
## 수행 단계
|
||||||
|
|
||||||
|
### 1. 프로젝트 분석
|
||||||
|
- 빌드 파일, 설정 파일, 디렉토리 구조 파악
|
||||||
|
- 사용 중인 프레임워크, 라이브러리 감지
|
||||||
|
- 기존 `.claude/` 디렉토리 존재 여부 확인
|
||||||
|
- eslint, prettier, checkstyle, spotless 등 lint 도구 설치 여부 확인
|
||||||
|
|
||||||
|
### 2. CLAUDE.md 생성
|
||||||
|
프로젝트 루트에 CLAUDE.md를 생성하고 다음 내용 포함:
|
||||||
|
- 프로젝트 개요 (이름, 타입, 주요 기술 스택)
|
||||||
|
- 빌드/실행 명령어 (감지된 빌드 도구 기반)
|
||||||
|
- 테스트 실행 명령어
|
||||||
|
- lint 실행 명령어 (감지된 도구 기반)
|
||||||
|
- 프로젝트 디렉토리 구조 요약
|
||||||
|
- 팀 컨벤션 참조 (`.claude/rules/` 안내)
|
||||||
|
|
||||||
|
### Gitea 파일 다운로드 URL 패턴
|
||||||
|
⚠️ Gitea raw 파일은 반드시 **web raw URL**을 사용해야 합니다 (`/api/v1/` 경로 사용 불가):
|
||||||
|
```bash
|
||||||
|
GITEA_URL="${GITEA_URL:-https://gitea.gc-si.dev}"
|
||||||
|
# common 파일: ${GITEA_URL}/gc/template-common/raw/branch/develop/<파일경로>
|
||||||
|
# 타입별 파일: ${GITEA_URL}/gc/template-<타입>/raw/branch/develop/<파일경로>
|
||||||
|
# 예시:
|
||||||
|
curl -sf "${GITEA_URL}/gc/template-common/raw/branch/develop/.claude/rules/team-policy.md"
|
||||||
|
curl -sf "${GITEA_URL}/gc/template-react-ts/raw/branch/develop/.editorconfig"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. .claude/ 디렉토리 구성
|
||||||
|
이미 팀 표준 파일이 존재하면 건너뜀. 없는 경우 위의 URL 패턴으로 Gitea에서 다운로드:
|
||||||
|
- `.claude/settings.json` — 프로젝트 타입별 표준 권한 설정 + hooks 섹션 (4단계 참조)
|
||||||
|
- `.claude/rules/` — 팀 규칙 파일 (team-policy, git-workflow, code-style, naming, testing)
|
||||||
|
- `.claude/skills/` — 팀 스킬 (create-mr, fix-issue, sync-team-workflow, init-project)
|
||||||
|
|
||||||
|
### 4. Hook 스크립트 생성
|
||||||
|
`.claude/scripts/` 디렉토리를 생성하고 다음 스크립트 파일 생성 (chmod +x):
|
||||||
|
|
||||||
|
- `.claude/scripts/on-pre-compact.sh`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
# PreCompact hook: systemMessage만 지원 (hookSpecificOutput 사용 불가)
|
||||||
|
INPUT=$(cat)
|
||||||
|
cat <<RESP
|
||||||
|
{
|
||||||
|
"systemMessage": "컨텍스트 압축이 시작됩니다. 반드시 다음을 수행하세요:\n\n1. memory/MEMORY.md - 핵심 작업 상태 갱신 (200줄 이내)\n2. memory/project-snapshot.md - 변경된 패키지/타입 정보 업데이트\n3. memory/project-history.md - 이번 세션 변경사항 추가\n4. memory/api-types.md - API 인터페이스 변경이 있었다면 갱신\n5. 미완료 작업이 있다면 TodoWrite에 남기고 memory에도 기록"
|
||||||
|
}
|
||||||
|
RESP
|
||||||
|
```
|
||||||
|
|
||||||
|
- `.claude/scripts/on-post-compact.sh`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
INPUT=$(cat)
|
||||||
|
CWD=$(echo "$INPUT" | python3 -c "import sys,json;print(json.load(sys.stdin).get('cwd',''))" 2>/dev/null || echo "")
|
||||||
|
if [ -z "$CWD" ]; then
|
||||||
|
CWD=$(pwd)
|
||||||
|
fi
|
||||||
|
PROJECT_HASH=$(echo "$CWD" | sed 's|/|-|g')
|
||||||
|
MEMORY_DIR="$HOME/.claude/projects/$PROJECT_HASH/memory"
|
||||||
|
CONTEXT=""
|
||||||
|
if [ -f "$MEMORY_DIR/MEMORY.md" ]; then
|
||||||
|
SUMMARY=$(head -100 "$MEMORY_DIR/MEMORY.md" | python3 -c "import sys;print(sys.stdin.read().replace('\\\\','\\\\\\\\').replace('\"','\\\\\"').replace('\n','\\\\n'))" 2>/dev/null)
|
||||||
|
CONTEXT="컨텍스트가 압축되었습니다.\\n\\n[세션 요약]\\n${SUMMARY}"
|
||||||
|
fi
|
||||||
|
if [ -f "$MEMORY_DIR/project-snapshot.md" ]; then
|
||||||
|
SNAP=$(head -50 "$MEMORY_DIR/project-snapshot.md" | python3 -c "import sys;print(sys.stdin.read().replace('\\\\','\\\\\\\\').replace('\"','\\\\\"').replace('\n','\\\\n'))" 2>/dev/null)
|
||||||
|
CONTEXT="${CONTEXT}\\n\\n[프로젝트 최신 상태]\\n${SNAP}"
|
||||||
|
fi
|
||||||
|
if [ -n "$CONTEXT" ]; then
|
||||||
|
CONTEXT="${CONTEXT}\\n\\n위 내용을 참고하여 작업을 이어가세요. 상세 내용은 memory/ 디렉토리의 각 파일을 참조하세요."
|
||||||
|
echo "{\"hookSpecificOutput\":{\"additionalContext\":\"${CONTEXT}\"}}"
|
||||||
|
else
|
||||||
|
echo "{\"hookSpecificOutput\":{\"additionalContext\":\"컨텍스트가 압축되었습니다. memory 파일이 없으므로 사용자에게 이전 작업 내용을 확인하세요.\"}}"
|
||||||
|
fi
|
||||||
|
```
|
||||||
|
|
||||||
|
- `.claude/scripts/on-commit.sh`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
INPUT=$(cat)
|
||||||
|
COMMAND=$(echo "$INPUT" | python3 -c "import sys,json;print(json.load(sys.stdin).get('tool_input',{}).get('command',''))" 2>/dev/null || echo "")
|
||||||
|
if echo "$COMMAND" | grep -qE 'git commit'; then
|
||||||
|
cat <<RESP
|
||||||
|
{
|
||||||
|
"hookSpecificOutput": {
|
||||||
|
"additionalContext": "커밋이 감지되었습니다. 다음을 수행하세요:\n1. docs/CHANGELOG.md에 변경 내역 추가\n2. memory/project-snapshot.md에서 변경된 부분 업데이트\n3. memory/project-history.md에 이번 변경사항 추가\n4. API 인터페이스 변경 시 memory/api-types.md 갱신\n5. 프로젝트에 lint 설정이 있다면 lint 결과를 확인하고 문제를 수정"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RESP
|
||||||
|
else
|
||||||
|
echo '{}'
|
||||||
|
fi
|
||||||
|
```
|
||||||
|
|
||||||
|
`.claude/settings.json`에 hooks 섹션이 없으면 추가 (기존 settings.json의 내용에 병합):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"hooks": {
|
||||||
|
"SessionStart": [
|
||||||
|
{
|
||||||
|
"matcher": "compact",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "bash .claude/scripts/on-post-compact.sh",
|
||||||
|
"timeout": 10
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"PreCompact": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "bash .claude/scripts/on-pre-compact.sh",
|
||||||
|
"timeout": 30
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"PostToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": "Bash",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "bash .claude/scripts/on-commit.sh",
|
||||||
|
"timeout": 15
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Git Hooks 설정
|
||||||
|
```bash
|
||||||
|
git config core.hooksPath .githooks
|
||||||
|
```
|
||||||
|
`.githooks/` 디렉토리에 실행 권한 부여:
|
||||||
|
```bash
|
||||||
|
chmod +x .githooks/*
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. 프로젝트 타입별 추가 설정
|
||||||
|
|
||||||
|
#### java-maven
|
||||||
|
- `.sdkmanrc` 생성 (java=17.0.18-amzn 또는 프로젝트에 맞는 버전)
|
||||||
|
- `.mvn/settings.xml` Nexus 미러 설정 확인
|
||||||
|
- `mvn compile` 빌드 성공 확인
|
||||||
|
|
||||||
|
#### java-gradle
|
||||||
|
- `.sdkmanrc` 생성
|
||||||
|
- `gradle.properties.example` Nexus 설정 확인
|
||||||
|
- `./gradlew compileJava` 빌드 성공 확인
|
||||||
|
|
||||||
|
#### react-ts
|
||||||
|
- `.node-version` 생성 (프로젝트에 맞는 Node 버전)
|
||||||
|
- `.npmrc` Nexus 레지스트리 설정 확인
|
||||||
|
- `npm install && npm run build` 성공 확인
|
||||||
|
|
||||||
|
### 7. .gitignore 확인
|
||||||
|
다음 항목이 .gitignore에 포함되어 있는지 확인하고, 없으면 추가:
|
||||||
|
```
|
||||||
|
.claude/settings.local.json
|
||||||
|
.claude/CLAUDE.local.md
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
*.local
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8. Git exclude 설정
|
||||||
|
`.git/info/exclude` 파일을 읽고, 기존 내용을 보존하면서 하단에 추가:
|
||||||
|
|
||||||
|
```gitignore
|
||||||
|
|
||||||
|
# Claude Code 워크플로우 (로컬 전용)
|
||||||
|
docs/CHANGELOG.md
|
||||||
|
*.tmp
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9. Memory 초기화
|
||||||
|
프로젝트 memory 디렉토리의 위치를 확인하고 (보통 `~/.claude/projects/<project-hash>/memory/`) 다음 파일들을 생성:
|
||||||
|
|
||||||
|
- `memory/MEMORY.md` — 프로젝트 분석 결과 기반 핵심 요약 (200줄 이내)
|
||||||
|
- 현재 상태, 프로젝트 개요, 기술 스택, 주요 패키지 구조, 상세 참조 링크
|
||||||
|
- `memory/project-snapshot.md` — 디렉토리 구조, 패키지 구성, 주요 의존성, API 엔드포인트
|
||||||
|
- `memory/project-history.md` — "초기 팀 워크플로우 구성" 항목으로 시작
|
||||||
|
- `memory/api-types.md` — 주요 인터페이스/DTO/Entity 타입 요약
|
||||||
|
- `memory/decisions.md` — 빈 템플릿 (# 의사결정 기록)
|
||||||
|
- `memory/debugging.md` — 빈 템플릿 (# 디버깅 경험 & 패턴)
|
||||||
|
|
||||||
|
### 10. Lint 도구 확인
|
||||||
|
- TypeScript: eslint, prettier 설치 여부 확인. 미설치 시 사용자에게 설치 제안
|
||||||
|
- Java: checkstyle, spotless 등 설정 확인
|
||||||
|
- CLAUDE.md에 lint 실행 명령어가 이미 기록되었는지 확인
|
||||||
|
|
||||||
|
### 11. workflow-version.json 생성
|
||||||
|
Gitea API로 최신 팀 워크플로우 버전을 조회:
|
||||||
|
```bash
|
||||||
|
curl -sf --max-time 5 "https://gitea.gc-si.dev/gc/template-common/raw/branch/develop/workflow-version.json"
|
||||||
|
```
|
||||||
|
조회 성공 시 해당 `version` 값 사용, 실패 시 "1.0.0" 기본값 사용.
|
||||||
|
|
||||||
|
`.claude/workflow-version.json` 파일 생성:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"applied_global_version": "<조회된 버전>",
|
||||||
|
"applied_date": "<현재날짜>",
|
||||||
|
"project_type": "<감지된타입>",
|
||||||
|
"gitea_url": "https://gitea.gc-si.dev"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 12. 검증 및 요약
|
||||||
|
- 생성/수정된 파일 목록 출력
|
||||||
|
- `git config core.hooksPath` 확인
|
||||||
|
- 빌드 명령 실행 가능 확인
|
||||||
|
- Hook 스크립트 실행 권한 확인
|
||||||
|
- 다음 단계 안내:
|
||||||
|
- 개발 시작, 첫 커밋 방법
|
||||||
|
- 범용 스킬: `/api-registry`, `/changelog`, `/swagger-spec`
|
||||||
98
.claude/skills/sync-team-workflow/SKILL.md
Normal file
98
.claude/skills/sync-team-workflow/SKILL.md
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
---
|
||||||
|
name: sync-team-workflow
|
||||||
|
description: 팀 글로벌 워크플로우를 현재 프로젝트에 동기화합니다
|
||||||
|
allowed-tools: "Bash, Read, Write, Edit, Glob, Grep"
|
||||||
|
---
|
||||||
|
|
||||||
|
팀 글로벌 워크플로우의 최신 버전을 현재 프로젝트에 적용합니다.
|
||||||
|
|
||||||
|
## 수행 절차
|
||||||
|
|
||||||
|
### 1. 글로벌 버전 조회
|
||||||
|
Gitea API로 template-common 리포의 workflow-version.json 조회:
|
||||||
|
```bash
|
||||||
|
GITEA_URL=$(python3 -c "import json; print(json.load(open('.claude/workflow-version.json')).get('gitea_url', 'https://gitea.gc-si.dev'))" 2>/dev/null || echo "https://gitea.gc-si.dev")
|
||||||
|
|
||||||
|
curl -sf "${GITEA_URL}/gc/template-common/raw/branch/develop/workflow-version.json"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 버전 비교
|
||||||
|
로컬 `.claude/workflow-version.json`의 `applied_global_version` 필드와 비교:
|
||||||
|
- 버전 일치 → "최신 버전입니다" 안내 후 종료
|
||||||
|
- 버전 불일치 → 미적용 변경 항목 추출하여 표시
|
||||||
|
|
||||||
|
### 3. 프로젝트 타입 감지
|
||||||
|
자동 감지 순서:
|
||||||
|
1. `.claude/workflow-version.json`의 `project_type` 필드 확인
|
||||||
|
2. 없으면: `pom.xml` → java-maven, `build.gradle` → java-gradle, `package.json` → react-ts
|
||||||
|
|
||||||
|
### Gitea 파일 다운로드 URL 패턴
|
||||||
|
⚠️ Gitea raw 파일은 반드시 **web raw URL**을 사용해야 합니다 (`/api/v1/` 경로 사용 불가):
|
||||||
|
```bash
|
||||||
|
GITEA_URL="${GITEA_URL:-https://gitea.gc-si.dev}"
|
||||||
|
# common 파일: ${GITEA_URL}/gc/template-common/raw/branch/develop/<파일경로>
|
||||||
|
# 타입별 파일: ${GITEA_URL}/gc/template-<타입>/raw/branch/develop/<파일경로>
|
||||||
|
# 예시:
|
||||||
|
curl -sf "${GITEA_URL}/gc/template-common/raw/branch/develop/.claude/rules/team-policy.md"
|
||||||
|
curl -sf "${GITEA_URL}/gc/template-react-ts/raw/branch/develop/.editorconfig"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 파일 다운로드 및 적용
|
||||||
|
위의 URL 패턴으로 해당 타입 + common 템플릿 파일 다운로드:
|
||||||
|
|
||||||
|
#### 4-1. 규칙 파일 (덮어쓰기)
|
||||||
|
팀 규칙은 로컬 수정 불가 — 항상 글로벌 최신으로 교체:
|
||||||
|
```
|
||||||
|
.claude/rules/team-policy.md
|
||||||
|
.claude/rules/git-workflow.md
|
||||||
|
.claude/rules/code-style.md (타입별)
|
||||||
|
.claude/rules/naming.md (타입별)
|
||||||
|
.claude/rules/testing.md (타입별)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4-2. settings.json (부분 갱신)
|
||||||
|
- `deny` 목록: 글로벌 최신으로 교체
|
||||||
|
- `allow` 목록: 기존 사용자 커스텀 유지 + 글로벌 기본값 병합
|
||||||
|
- `hooks`: init-project SKILL.md의 hooks JSON 블록을 참조하여 교체 (없으면 추가)
|
||||||
|
- SessionStart(compact) → on-post-compact.sh
|
||||||
|
- PreCompact → on-pre-compact.sh
|
||||||
|
- PostToolUse(Bash) → on-commit.sh
|
||||||
|
|
||||||
|
#### 4-3. 스킬 파일 (덮어쓰기)
|
||||||
|
```
|
||||||
|
.claude/skills/create-mr/SKILL.md
|
||||||
|
.claude/skills/fix-issue/SKILL.md
|
||||||
|
.claude/skills/sync-team-workflow/SKILL.md
|
||||||
|
.claude/skills/init-project/SKILL.md
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4-4. Git Hooks (덮어쓰기 + 실행 권한)
|
||||||
|
```bash
|
||||||
|
chmod +x .githooks/*
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4-5. Hook 스크립트 갱신
|
||||||
|
init-project SKILL.md의 코드 블록에서 최신 스크립트를 추출하여 덮어쓰기:
|
||||||
|
```
|
||||||
|
.claude/scripts/on-pre-compact.sh
|
||||||
|
.claude/scripts/on-post-compact.sh
|
||||||
|
.claude/scripts/on-commit.sh
|
||||||
|
```
|
||||||
|
실행 권한 부여: `chmod +x .claude/scripts/*.sh`
|
||||||
|
|
||||||
|
### 5. 로컬 버전 업데이트
|
||||||
|
`.claude/workflow-version.json` 갱신:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"applied_global_version": "새버전",
|
||||||
|
"applied_date": "오늘날짜",
|
||||||
|
"project_type": "감지된타입",
|
||||||
|
"gitea_url": "https://gitea.gc-si.dev"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. 변경 보고
|
||||||
|
- `git diff`로 변경 내역 확인
|
||||||
|
- 업데이트된 파일 목록 출력
|
||||||
|
- 변경 로그(글로벌 workflow-version.json의 changes) 표시
|
||||||
|
- 필요한 추가 조치 안내 (빌드 확인, 의존성 업데이트 등)
|
||||||
6
.claude/workflow-version.json
Normal file
6
.claude/workflow-version.json
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"applied_global_version": "1.2.0",
|
||||||
|
"applied_date": "2026-02-14",
|
||||||
|
"project_type": "java-maven",
|
||||||
|
"gitea_url": "https://gitea.gc-si.dev"
|
||||||
|
}
|
||||||
33
.editorconfig
Normal file
33
.editorconfig
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[*.{java,kt}]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 4
|
||||||
|
|
||||||
|
[*.{js,jsx,ts,tsx,json,yml,yaml,css,scss,html}]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
trim_trailing_whitespace = false
|
||||||
|
|
||||||
|
[*.{sh,bash}]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 4
|
||||||
|
|
||||||
|
[Makefile]
|
||||||
|
indent_style = tab
|
||||||
|
|
||||||
|
[*.{gradle,groovy}]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 4
|
||||||
|
|
||||||
|
[*.xml]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 4
|
||||||
71
.githooks/commit-msg
Executable file
71
.githooks/commit-msg
Executable file
@ -0,0 +1,71 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#==============================================================================
|
||||||
|
# commit-msg hook
|
||||||
|
# Conventional Commits 형식 검증 (한/영 혼용 지원)
|
||||||
|
#==============================================================================
|
||||||
|
|
||||||
|
COMMIT_MSG_FILE="$1"
|
||||||
|
COMMIT_MSG=$(cat "$COMMIT_MSG_FILE")
|
||||||
|
|
||||||
|
# Merge 커밋은 검증 건너뜀
|
||||||
|
if echo "$COMMIT_MSG" | head -1 | grep -qE "^Merge "; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Revert 커밋은 검증 건너뜀
|
||||||
|
if echo "$COMMIT_MSG" | head -1 | grep -qE "^Revert "; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Conventional Commits 정규식
|
||||||
|
# type(scope): subject
|
||||||
|
# - type: feat|fix|docs|style|refactor|test|chore|ci|perf (필수)
|
||||||
|
# - scope: 괄호 제외 모든 문자 허용 — 한/영/숫자/특수문자 (선택)
|
||||||
|
# - subject: 1자 이상 (길이는 바이트 기반 별도 검증)
|
||||||
|
PATTERN='^(feat|fix|docs|style|refactor|test|chore|ci|perf)(\([^)]+\))?: .+$'
|
||||||
|
MAX_SUBJECT_BYTES=200 # UTF-8 한글(3byte) 허용: 72문자 ≈ 최대 216byte
|
||||||
|
|
||||||
|
FIRST_LINE=$(head -1 "$COMMIT_MSG_FILE")
|
||||||
|
|
||||||
|
if ! echo "$FIRST_LINE" | grep -qE "$PATTERN"; then
|
||||||
|
echo ""
|
||||||
|
echo "╔══════════════════════════════════════════════════════════════╗"
|
||||||
|
echo "║ 커밋 메시지가 Conventional Commits 형식에 맞지 않습니다 ║"
|
||||||
|
echo "╚══════════════════════════════════════════════════════════════╝"
|
||||||
|
echo ""
|
||||||
|
echo " 올바른 형식: type(scope): subject"
|
||||||
|
echo ""
|
||||||
|
echo " type (필수):"
|
||||||
|
echo " feat — 새로운 기능"
|
||||||
|
echo " fix — 버그 수정"
|
||||||
|
echo " docs — 문서 변경"
|
||||||
|
echo " style — 코드 포맷팅"
|
||||||
|
echo " refactor — 리팩토링"
|
||||||
|
echo " test — 테스트"
|
||||||
|
echo " chore — 빌드/설정 변경"
|
||||||
|
echo " ci — CI/CD 변경"
|
||||||
|
echo " perf — 성능 개선"
|
||||||
|
echo ""
|
||||||
|
echo " scope (선택): 한/영 모두 가능"
|
||||||
|
echo " subject (필수): 1~72자, 한/영 모두 가능"
|
||||||
|
echo ""
|
||||||
|
echo " 예시:"
|
||||||
|
echo " feat(auth): JWT 기반 로그인 구현"
|
||||||
|
echo " fix(배치): 야간 배치 타임아웃 수정"
|
||||||
|
echo " docs: README 업데이트"
|
||||||
|
echo " chore: Gradle 의존성 업데이트"
|
||||||
|
echo ""
|
||||||
|
echo " 현재 메시지: $FIRST_LINE"
|
||||||
|
echo ""
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 길이 검증 (바이트 기반 — UTF-8 한글 허용)
|
||||||
|
MSG_LEN=$(echo -n "$FIRST_LINE" | wc -c | tr -d ' ')
|
||||||
|
if [ "$MSG_LEN" -gt "$MAX_SUBJECT_BYTES" ]; then
|
||||||
|
echo ""
|
||||||
|
echo " ✗ 커밋 메시지가 너무 깁니다 (${MSG_LEN}바이트, 최대 ${MAX_SUBJECT_BYTES})"
|
||||||
|
echo " 현재 메시지: $FIRST_LINE"
|
||||||
|
echo ""
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
25
.githooks/post-checkout
Executable file
25
.githooks/post-checkout
Executable file
@ -0,0 +1,25 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#==============================================================================
|
||||||
|
# post-checkout hook
|
||||||
|
# 브랜치 체크아웃 시 core.hooksPath 자동 설정
|
||||||
|
# clone/checkout 후 .githooks 디렉토리가 있으면 자동으로 hooksPath 설정
|
||||||
|
#==============================================================================
|
||||||
|
|
||||||
|
# post-checkout 파라미터: prev_HEAD, new_HEAD, branch_flag
|
||||||
|
# branch_flag=1: 브랜치 체크아웃, 0: 파일 체크아웃
|
||||||
|
BRANCH_FLAG="$3"
|
||||||
|
|
||||||
|
# 파일 체크아웃은 건너뜀
|
||||||
|
if [ "$BRANCH_FLAG" = "0" ]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# .githooks 디렉토리 존재 확인
|
||||||
|
REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
|
||||||
|
if [ -d "${REPO_ROOT}/.githooks" ]; then
|
||||||
|
CURRENT_HOOKS_PATH=$(git config core.hooksPath 2>/dev/null || echo "")
|
||||||
|
if [ "$CURRENT_HOOKS_PATH" != ".githooks" ]; then
|
||||||
|
git config core.hooksPath .githooks
|
||||||
|
chmod +x "${REPO_ROOT}/.githooks/"* 2>/dev/null
|
||||||
|
fi
|
||||||
|
fi
|
||||||
33
.githooks/pre-commit
Executable file
33
.githooks/pre-commit
Executable file
@ -0,0 +1,33 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#==============================================================================
|
||||||
|
# pre-commit hook (Java Maven)
|
||||||
|
# Maven 컴파일 검증 — 컴파일 실패 시 커밋 차단
|
||||||
|
#==============================================================================
|
||||||
|
|
||||||
|
echo "pre-commit: Maven 컴파일 검증 중..."
|
||||||
|
|
||||||
|
# Maven Wrapper 사용 (없으면 mvn 사용)
|
||||||
|
if [ -f "./mvnw" ]; then
|
||||||
|
MVN="./mvnw"
|
||||||
|
elif command -v mvn &>/dev/null; then
|
||||||
|
MVN="mvn"
|
||||||
|
else
|
||||||
|
echo "경고: Maven이 설치되지 않았습니다. 컴파일 검증을 건너뜁니다."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 컴파일 검증 (테스트 제외, 프론트엔드 빌드 제외)
|
||||||
|
$MVN compile -q -DskipTests -Dskip.npm -Dskip.installnodenpm 2>&1
|
||||||
|
RESULT=$?
|
||||||
|
|
||||||
|
if [ $RESULT -ne 0 ]; then
|
||||||
|
echo ""
|
||||||
|
echo "╔══════════════════════════════════════════════════════════╗"
|
||||||
|
echo "║ 컴파일 실패! 커밋이 차단되었습니다. ║"
|
||||||
|
echo "║ 컴파일 오류를 수정한 후 다시 커밋해주세요. ║"
|
||||||
|
echo "╚══════════════════════════════════════════════════════════╝"
|
||||||
|
echo ""
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "pre-commit: 컴파일 성공"
|
||||||
16
.gitignore
vendored
16
.gitignore
vendored
@ -93,13 +93,15 @@ application-local.yml
|
|||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
logs/
|
logs/
|
||||||
docs/
|
|
||||||
*.log.*
|
*.log.*
|
||||||
|
|
||||||
# Session continuity files (for AI assistants)
|
# Frontend (Vite + React)
|
||||||
.claude/
|
frontend/node_modules/
|
||||||
CLAUDE.md
|
frontend/node/
|
||||||
BASEREADER_ENHANCEMENT_PLAN.md
|
src/main/resources/static/assets/
|
||||||
README.md
|
src/main/resources/static/index.html
|
||||||
|
src/main/resources/static/vite.svg
|
||||||
|
|
||||||
nul
|
# Claude Code (개인 파일만 무시, 팀 파일은 추적)
|
||||||
|
.claude/settings.local.json
|
||||||
|
.claude/scripts/
|
||||||
|
|||||||
22
.mvn/settings.xml
Normal file
22
.mvn/settings.xml
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<settings xmlns="http://maven.apache.org/SETTINGS/1.2.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.2.0
|
||||||
|
https://maven.apache.org/xsd/settings-1.2.0.xsd">
|
||||||
|
<servers>
|
||||||
|
<server>
|
||||||
|
<id>nexus</id>
|
||||||
|
<username>admin</username>
|
||||||
|
<password>Gcsc!8932</password>
|
||||||
|
</server>
|
||||||
|
</servers>
|
||||||
|
|
||||||
|
<mirrors>
|
||||||
|
<mirror>
|
||||||
|
<id>nexus</id>
|
||||||
|
<name>GC Nexus Repository</name>
|
||||||
|
<url>https://nexus.gc-si.dev/repository/maven-public/</url>
|
||||||
|
<mirrorOf>*</mirrorOf>
|
||||||
|
</mirror>
|
||||||
|
</mirrors>
|
||||||
|
</settings>
|
||||||
101
CLAUDE.md
Normal file
101
CLAUDE.md
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
# SNP-Batch-1 (snp-batch-validation)
|
||||||
|
|
||||||
|
해양 데이터 통합 배치 시스템. 외부 Maritime API에서 선박/항만/사건 데이터를 수집하여 PostgreSQL에 저장하고, AIS 실시간 위치정보를 캐시 기반으로 서비스.
|
||||||
|
|
||||||
|
## 기술 스택
|
||||||
|
- Java 17, Spring Boot 3.2.1, Spring Batch 5.1.0
|
||||||
|
- PostgreSQL (스키마: t_std_snp_data)
|
||||||
|
- Quartz Scheduler (JDBC Store)
|
||||||
|
- Spring Kafka (AIS Target → Kafka 파이프라인)
|
||||||
|
- WebFlux WebClient (외부 API 호출)
|
||||||
|
- Thymeleaf (배치 관리 Web GUI)
|
||||||
|
- Springdoc OpenAPI 2.3.0 (Swagger)
|
||||||
|
- Caffeine Cache, JTS (공간 연산)
|
||||||
|
- Lombok, Jackson
|
||||||
|
|
||||||
|
## 빌드 & 실행
|
||||||
|
```bash
|
||||||
|
# 빌드
|
||||||
|
sdk use java 17.0.18-amzn
|
||||||
|
mvn clean package -DskipTests
|
||||||
|
|
||||||
|
# 실행
|
||||||
|
mvn spring-boot:run
|
||||||
|
|
||||||
|
# 테스트
|
||||||
|
mvn test
|
||||||
|
```
|
||||||
|
|
||||||
|
## 서버 설정
|
||||||
|
- 포트: 8041
|
||||||
|
- Context Path: /snp-api
|
||||||
|
- Swagger UI: http://localhost:8041/snp-api/swagger-ui/index.html
|
||||||
|
|
||||||
|
## 디렉토리 구조
|
||||||
|
```
|
||||||
|
src/main/java/com/snp/batch/
|
||||||
|
├── SnpBatchApplication.java # 메인 애플리케이션
|
||||||
|
├── common/ # 공통 프레임워크
|
||||||
|
│ ├── batch/ # 배치 베이스 클래스 (config, entity, processor, reader, writer)
|
||||||
|
│ ├── util/ # 유틸 (JsonChangeDetector, SafeGetDataUtil)
|
||||||
|
│ └── web/ # Web 베이스 (ApiResponse, BaseController, BaseService)
|
||||||
|
├── global/ # 글로벌 설정 & 배치 관리
|
||||||
|
│ ├── config/ # AsyncConfig, QuartzConfig, SwaggerConfig, WebClientConfig
|
||||||
|
│ ├── controller/ # BatchController (/api/batch), WebViewController
|
||||||
|
│ ├── dto/ # Dashboard, JobExecution, Schedule DTO
|
||||||
|
│ ├── model/ # BatchLastExecution, JobScheduleEntity
|
||||||
|
│ ├── partition/ # 파티션 관리 (PartitionManagerTasklet)
|
||||||
|
│ ├── projection/ # DateRangeProjection
|
||||||
|
│ └── repository/ # BatchApiLog, BatchLastExecution, JobSchedule, Timeline
|
||||||
|
├── jobs/ # 배치 Job 모듈 (도메인별)
|
||||||
|
│ ├── aistarget/ # AIS Target (실시간 위치 + 캐시 + REST API + Kafka 발행)
|
||||||
|
│ ├── aistargetdbsync/ # AIS Target DB Sync (캐시→DB)
|
||||||
|
│ ├── common/ # 공통코드 (FlagCode, Stat5Code)
|
||||||
|
│ ├── compliance/ # 규정준수 (Compliance, CompanyCompliance)
|
||||||
|
│ ├── event/ # 해양사건 (Event, EventDetail, Cargo, HumanCasualty)
|
||||||
|
│ ├── movements/ # 선박이동 (다수 하위 Job)
|
||||||
|
│ ├── psc/ # PSC 검사
|
||||||
|
│ ├── risk/ # 리스크 분석
|
||||||
|
│ └── ship*/ # 선박정보 (ship001~ship028, 30+ 테이블)
|
||||||
|
└── service/ # BatchService, ScheduleService
|
||||||
|
```
|
||||||
|
|
||||||
|
## 배치 Job 패턴
|
||||||
|
각 Job은 `common/batch/` 베이스 클래스를 상속:
|
||||||
|
- **BaseJobConfig** → Job/Step 설정 (chunk-oriented)
|
||||||
|
- **BaseApiReader** → 외부 Maritime API 호출 (WebClient)
|
||||||
|
- **BaseProcessor** → DTO→Entity 변환
|
||||||
|
- **BaseWriter** → PostgreSQL Upsert
|
||||||
|
- **BaseEntity** → 공통 필드 (dataHash, lastModifiedDate 등)
|
||||||
|
|
||||||
|
## 주요 API 경로 (context-path: /snp-api)
|
||||||
|
|
||||||
|
### Batch Management (/api/batch)
|
||||||
|
| 메서드 | 경로 | 설명 |
|
||||||
|
|--------|------|------|
|
||||||
|
| POST | /jobs/{jobName}/execute | 배치 작업 실행 |
|
||||||
|
| GET | /jobs | 작업 목록 |
|
||||||
|
| GET | /jobs/{jobName}/executions | 실행 이력 |
|
||||||
|
| GET | /executions/{id}/detail | 실행 상세 (Step 포함) |
|
||||||
|
| POST | /executions/{id}/stop | 실행 중지 |
|
||||||
|
| GET/POST | /schedules | 스케줄 관리 (CRUD) |
|
||||||
|
| GET | /dashboard | 대시보드 |
|
||||||
|
| GET | /timeline | 타임라인 |
|
||||||
|
|
||||||
|
### AIS Target (/api/ais-target)
|
||||||
|
| 메서드 | 경로 | 설명 |
|
||||||
|
|--------|------|------|
|
||||||
|
| GET | /{mmsi} | MMSI로 최신 위치 |
|
||||||
|
| POST | /batch | 다건 MMSI 조회 |
|
||||||
|
| GET/POST | /search | 시간/공간 범위 검색 |
|
||||||
|
| POST | /search/filter | 조건 필터 검색 (SOG, COG 등) |
|
||||||
|
| POST | /search/polygon | 폴리곤 범위 검색 |
|
||||||
|
| POST | /search/wkt | WKT 형식 검색 |
|
||||||
|
| GET | /search/with-distance | 거리 포함 원형 검색 |
|
||||||
|
| GET | /{mmsi}/track | 항적 조회 |
|
||||||
|
| GET | /cache/stats | 캐시 통계 |
|
||||||
|
| DELETE | /cache | 캐시 초기화 |
|
||||||
|
|
||||||
|
## Lint/Format
|
||||||
|
- 별도 lint 도구 미설정 (checkstyle, spotless 없음)
|
||||||
|
- IDE 기본 포매터 사용
|
||||||
1602
DEVELOPMENT_GUIDE.md
1602
DEVELOPMENT_GUIDE.md
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
105
README.md
Normal file
105
README.md
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
# SNP-Batch (snp-batch-validation)
|
||||||
|
|
||||||
|
해양 데이터 통합 배치 시스템. Maritime API에서 선박/항만/사건 데이터를 수집하여 PostgreSQL에 저장하고, AIS 실시간 위치정보를 캐시 기반으로 서비스합니다.
|
||||||
|
|
||||||
|
## 기술 스택
|
||||||
|
|
||||||
|
- Java 17, Spring Boot 3.2.1, Spring Batch 5.1.0
|
||||||
|
- PostgreSQL, Quartz Scheduler, Caffeine Cache
|
||||||
|
- React 19 + Vite + Tailwind CSS 4 (관리 UI)
|
||||||
|
- frontend-maven-plugin (프론트엔드 빌드 통합)
|
||||||
|
|
||||||
|
## 사전 요구사항
|
||||||
|
|
||||||
|
| 항목 | 버전 | 비고 |
|
||||||
|
|------|------|------|
|
||||||
|
| JDK | 17 | `.sdkmanrc` 참조 (`sdk env`) |
|
||||||
|
| Maven | 3.9+ | |
|
||||||
|
| Node.js | 20+ | 프론트엔드 빌드용 |
|
||||||
|
| npm | 10+ | Node.js에 포함 |
|
||||||
|
|
||||||
|
## 빌드
|
||||||
|
|
||||||
|
> **주의**: frontend-maven-plugin의 Node 호환성 문제로, 프론트엔드와 백엔드를 분리하여 빌드합니다.
|
||||||
|
|
||||||
|
### 터미널
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 프론트엔드 빌드
|
||||||
|
cd frontend && npm install && npm run build && cd ..
|
||||||
|
|
||||||
|
# 2. Maven 패키징 (프론트엔드 빌드 스킵)
|
||||||
|
mvn clean package -DskipTests -Dskip.npm -Dskip.installnodenpm
|
||||||
|
```
|
||||||
|
|
||||||
|
빌드 결과: `target/snp-batch-validation-1.0.0.jar`
|
||||||
|
|
||||||
|
### VSCode
|
||||||
|
|
||||||
|
`Cmd+Shift+B` (기본 빌드 태스크) → 프론트엔드 빌드 + Maven 패키징 순차 실행
|
||||||
|
|
||||||
|
개별 태스크: `Cmd+Shift+P` → "Tasks: Run Task" → 태스크 선택
|
||||||
|
|
||||||
|
> 태스크 설정: [.vscode/tasks.json](.vscode/tasks.json)
|
||||||
|
|
||||||
|
### IntelliJ IDEA
|
||||||
|
|
||||||
|
1. **프론트엔드 빌드**: Terminal 탭에서 `cd frontend && npm run build`
|
||||||
|
2. **Maven 패키징**: Maven 패널 → Lifecycle → `package`
|
||||||
|
- VM Options: `-DskipTests -Dskip.npm -Dskip.installnodenpm`
|
||||||
|
- 또는 Run Configuration → Maven → Command line에 `clean package -DskipTests -Dskip.npm -Dskip.installnodenpm`
|
||||||
|
|
||||||
|
## 로컬 실행
|
||||||
|
|
||||||
|
### 터미널
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mvn spring-boot:run -Dspring-boot.run.profiles=local
|
||||||
|
```
|
||||||
|
|
||||||
|
### VSCode
|
||||||
|
|
||||||
|
Run/Debug 패널(F5) → "SNP-Batch (local)" 선택
|
||||||
|
|
||||||
|
> 실행 설정: [.vscode/launch.json](.vscode/launch.json)
|
||||||
|
|
||||||
|
### IntelliJ IDEA
|
||||||
|
|
||||||
|
Run Configuration → Spring Boot:
|
||||||
|
- Main class: `com.snp.batch.SnpBatchApplication`
|
||||||
|
- Active profiles: `local`
|
||||||
|
|
||||||
|
## 서버 배포
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 빌드 (위 빌드 절차 수행)
|
||||||
|
|
||||||
|
# 2. JAR 전송
|
||||||
|
scp target/snp-batch-validation-1.0.0.jar {서버}:{경로}/
|
||||||
|
|
||||||
|
# 3. 실행
|
||||||
|
java -jar snp-batch-validation-1.0.0.jar --spring.profiles.active=dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## 접속 정보
|
||||||
|
|
||||||
|
| 항목 | URL |
|
||||||
|
|------|-----|
|
||||||
|
| 관리 UI | `http://localhost:8041/snp-api/` |
|
||||||
|
| Swagger | `http://localhost:8041/snp-api/swagger-ui/index.html` |
|
||||||
|
|
||||||
|
## 프로파일
|
||||||
|
|
||||||
|
| 프로파일 | 용도 | DB |
|
||||||
|
|----------|------|----|
|
||||||
|
| `local` | 로컬 개발 | 개발 DB |
|
||||||
|
| `dev` | 개발 서버 | 개발 DB |
|
||||||
|
| `prod` | 운영 서버 | 운영 DB |
|
||||||
|
|
||||||
|
## Maven 빌드 플래그 요약
|
||||||
|
|
||||||
|
| 플래그 | 용도 |
|
||||||
|
|--------|------|
|
||||||
|
| `-DskipTests` | 테스트 스킵 |
|
||||||
|
| `-Dskip.npm` | npm install/build 스킵 |
|
||||||
|
| `-Dskip.installnodenpm` | Node/npm 자동 설치 스킵 |
|
||||||
517
SWAGGER_GUIDE.md
517
SWAGGER_GUIDE.md
@ -1,517 +0,0 @@
|
|||||||
# Swagger API 문서화 가이드
|
|
||||||
|
|
||||||
**작성일**: 2025-10-16
|
|
||||||
**버전**: 1.0.0
|
|
||||||
**프로젝트**: SNP Batch - Spring Batch 기반 데이터 통합 시스템
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📋 Swagger 설정 완료 사항
|
|
||||||
|
|
||||||
### ✅ 수정 완료 파일
|
|
||||||
1. **BaseController.java** - 공통 CRUD Controller 추상 클래스
|
|
||||||
- Java import alias 오류 수정 (`as SwaggerApiResponse` 제거)
|
|
||||||
- `@Operation` 어노테이션 내 `responses` 속성으로 통합
|
|
||||||
- 전체 경로로 어노테이션 사용: `@io.swagger.v3.oas.annotations.responses.ApiResponse`
|
|
||||||
|
|
||||||
2. **ProductWebController.java** - 샘플 제품 API Controller
|
|
||||||
- Java import alias 오류 수정
|
|
||||||
- 커스텀 엔드포인트 Swagger 어노테이션 수정
|
|
||||||
|
|
||||||
3. **SwaggerConfig.java** - Swagger/OpenAPI 3.0 설정
|
|
||||||
- 서버 포트 동적 설정 (`@Value("${server.port:8081}")`)
|
|
||||||
- 상세한 API 문서 설명 추가
|
|
||||||
- Markdown 형식 설명 추가
|
|
||||||
|
|
||||||
4. **BatchController.java** - 배치 관리 API (이미 올바르게 구현됨)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🌐 Swagger UI 접속 정보
|
|
||||||
|
|
||||||
### 접속 URL
|
|
||||||
```
|
|
||||||
Swagger UI: http://localhost:8081/swagger-ui/index.html
|
|
||||||
API 문서 (JSON): http://localhost:8081/v3/api-docs
|
|
||||||
API 문서 (YAML): http://localhost:8081/v3/api-docs.yaml
|
|
||||||
```
|
|
||||||
|
|
||||||
### 제공되는 API 그룹
|
|
||||||
|
|
||||||
> **참고**: BaseController는 추상 클래스이므로 별도의 API 그룹으로 표시되지 않습니다.
|
|
||||||
> 상속받는 Controller(예: ProductWebController)의 `@Tag`로 모든 CRUD 엔드포인트가 그룹화됩니다.
|
|
||||||
|
|
||||||
#### 1. **Batch Management API** (`/api/batch`)
|
|
||||||
배치 작업 실행 및 스케줄 관리
|
|
||||||
|
|
||||||
**엔드포인트**:
|
|
||||||
- `POST /api/batch/jobs/{jobName}/execute` - 배치 작업 실행
|
|
||||||
- `GET /api/batch/jobs` - 배치 작업 목록 조회
|
|
||||||
- `GET /api/batch/jobs/{jobName}/executions` - 실행 이력 조회
|
|
||||||
- `POST /api/batch/executions/{executionId}/stop` - 실행 중지
|
|
||||||
- `GET /api/batch/schedules` - 스케줄 목록 조회
|
|
||||||
- `POST /api/batch/schedules` - 스케줄 생성
|
|
||||||
- `PUT /api/batch/schedules/{jobName}` - 스케줄 수정
|
|
||||||
- `DELETE /api/batch/schedules/{jobName}` - 스케줄 삭제
|
|
||||||
- `PATCH /api/batch/schedules/{jobName}/toggle` - 스케줄 활성화/비활성화
|
|
||||||
- `GET /api/batch/dashboard` - 대시보드 데이터
|
|
||||||
- `GET /api/batch/timeline` - 타임라인 데이터
|
|
||||||
|
|
||||||
#### 2. **Product API** (`/api/products`)
|
|
||||||
샘플 제품 데이터 CRUD (BaseController 상속)
|
|
||||||
|
|
||||||
**모든 엔드포인트가 "Product API" 그룹으로 통합 표시됩니다.**
|
|
||||||
|
|
||||||
**공통 CRUD 엔드포인트** (BaseController에서 상속):
|
|
||||||
- `POST /api/products` - 제품 생성
|
|
||||||
- `GET /api/products/{id}` - 제품 조회 (ID)
|
|
||||||
- `GET /api/products` - 전체 제품 조회
|
|
||||||
- `GET /api/products/page?offset=0&limit=20` - 페이징 조회
|
|
||||||
- `PUT /api/products/{id}` - 제품 수정
|
|
||||||
- `DELETE /api/products/{id}` - 제품 삭제
|
|
||||||
- `GET /api/products/{id}/exists` - 존재 여부 확인
|
|
||||||
|
|
||||||
**커스텀 엔드포인트**:
|
|
||||||
- `GET /api/products/by-product-id/{productId}` - 제품 코드로 조회
|
|
||||||
- `GET /api/products/stats/active-count` - 활성 제품 개수
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🛠️ 애플리케이션 실행 및 테스트
|
|
||||||
|
|
||||||
### 1. 애플리케이션 빌드 및 실행
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Maven 빌드 (IntelliJ IDEA에서)
|
|
||||||
mvn clean package -DskipTests
|
|
||||||
|
|
||||||
# 애플리케이션 실행
|
|
||||||
mvn spring-boot:run
|
|
||||||
```
|
|
||||||
|
|
||||||
또는 IntelliJ IDEA에서:
|
|
||||||
1. `SnpBatchApplication.java` 파일 열기
|
|
||||||
2. 메인 메서드 왼쪽의 ▶ 아이콘 클릭
|
|
||||||
3. "Run 'SnpBatchApplication'" 선택
|
|
||||||
|
|
||||||
### 2. Swagger UI 접속
|
|
||||||
|
|
||||||
브라우저에서 다음 URL 접속:
|
|
||||||
```
|
|
||||||
http://localhost:8081/swagger-ui/index.html
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. API 테스트 예시
|
|
||||||
|
|
||||||
#### 예시 1: 배치 작업 목록 조회
|
|
||||||
```http
|
|
||||||
GET http://localhost:8081/api/batch/jobs
|
|
||||||
```
|
|
||||||
|
|
||||||
**예상 응답**:
|
|
||||||
```json
|
|
||||||
[
|
|
||||||
"sampleProductImportJob",
|
|
||||||
"shipDataImportJob"
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 예시 2: 배치 작업 실행
|
|
||||||
```http
|
|
||||||
POST http://localhost:8081/api/batch/jobs/sampleProductImportJob/execute
|
|
||||||
```
|
|
||||||
|
|
||||||
**예상 응답**:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"message": "Job started successfully",
|
|
||||||
"executionId": 1
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 예시 3: 제품 생성 (샘플)
|
|
||||||
```http
|
|
||||||
POST http://localhost:8081/api/products
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
{
|
|
||||||
"productId": "TEST-001",
|
|
||||||
"productName": "테스트 제품",
|
|
||||||
"category": "Electronics",
|
|
||||||
"price": 99.99,
|
|
||||||
"stockQuantity": 50,
|
|
||||||
"isActive": true,
|
|
||||||
"rating": 4.5
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**예상 응답**:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"message": "Product created successfully",
|
|
||||||
"data": {
|
|
||||||
"id": 1,
|
|
||||||
"productId": "TEST-001",
|
|
||||||
"productName": "테스트 제품",
|
|
||||||
"category": "Electronics",
|
|
||||||
"price": 99.99,
|
|
||||||
"stockQuantity": 50,
|
|
||||||
"isActive": true,
|
|
||||||
"rating": 4.5,
|
|
||||||
"createdAt": "2025-10-16T10:30:00",
|
|
||||||
"updatedAt": "2025-10-16T10:30:00"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 예시 4: 페이징 조회
|
|
||||||
```http
|
|
||||||
GET http://localhost:8081/api/products/page?offset=0&limit=10
|
|
||||||
```
|
|
||||||
|
|
||||||
**예상 응답**:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"message": "Retrieved 10 items (total: 100)",
|
|
||||||
"data": [
|
|
||||||
{ "id": 1, "productName": "Product 1", ... },
|
|
||||||
{ "id": 2, "productName": "Product 2", ... },
|
|
||||||
...
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📚 Swagger 어노테이션 가이드
|
|
||||||
|
|
||||||
### BaseController에서 사용된 패턴
|
|
||||||
|
|
||||||
#### ❌ 잘못된 사용법 (Java에서는 불가능)
|
|
||||||
```java
|
|
||||||
// Kotlin의 import alias는 Java에서 지원되지 않음
|
|
||||||
import io.swagger.v3.oas.annotations.responses.ApiResponse as SwaggerApiResponse;
|
|
||||||
|
|
||||||
@ApiResponses(value = {
|
|
||||||
@SwaggerApiResponse(responseCode = "200", description = "성공")
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
#### ✅ 올바른 사용법 (수정 완료)
|
|
||||||
```java
|
|
||||||
// import alias 제거
|
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
|
||||||
import io.swagger.v3.oas.annotations.Parameter;
|
|
||||||
|
|
||||||
@Operation(
|
|
||||||
summary = "리소스 생성",
|
|
||||||
description = "새로운 리소스를 생성합니다",
|
|
||||||
responses = {
|
|
||||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
|
||||||
responseCode = "200",
|
|
||||||
description = "생성 성공"
|
|
||||||
),
|
|
||||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
|
||||||
responseCode = "500",
|
|
||||||
description = "서버 오류"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
@PostMapping
|
|
||||||
public ResponseEntity<ApiResponse<D>> create(
|
|
||||||
@Parameter(description = "생성할 리소스 데이터", required = true)
|
|
||||||
@RequestBody D dto) {
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 주요 어노테이션 설명
|
|
||||||
|
|
||||||
#### 1. `@Tag` - API 그룹화
|
|
||||||
```java
|
|
||||||
@Tag(name = "Product API", description = "제품 관리 API")
|
|
||||||
public class ProductWebController extends BaseController<ProductWebDto, Long> {
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2. `@Operation` - 엔드포인트 문서화
|
|
||||||
```java
|
|
||||||
@Operation(
|
|
||||||
summary = "짧은 설명 (목록에 표시)",
|
|
||||||
description = "상세 설명 (확장 시 표시)",
|
|
||||||
responses = { /* 응답 정의 */ }
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3. `@Parameter` - 파라미터 설명
|
|
||||||
```java
|
|
||||||
@Parameter(
|
|
||||||
description = "파라미터 설명",
|
|
||||||
required = true,
|
|
||||||
example = "예시 값"
|
|
||||||
)
|
|
||||||
@PathVariable String id
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 4. `@io.swagger.v3.oas.annotations.responses.ApiResponse` - 응답 정의
|
|
||||||
```java
|
|
||||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
|
||||||
responseCode = "200",
|
|
||||||
description = "성공 메시지",
|
|
||||||
content = @Content(
|
|
||||||
mediaType = "application/json",
|
|
||||||
schema = @Schema(implementation = ProductDto.class)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 신규 Controller 개발 시 Swagger 적용 가이드
|
|
||||||
|
|
||||||
### 1. BaseController를 상속하는 경우
|
|
||||||
|
|
||||||
```java
|
|
||||||
@RestController
|
|
||||||
@RequestMapping("/api/myresource")
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
@Tag(name = "My Resource API", description = "나의 리소스 관리 API")
|
|
||||||
public class MyResourceController extends BaseController<MyResourceDto, Long> {
|
|
||||||
|
|
||||||
private final MyResourceService myResourceService;
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected BaseService<?, MyResourceDto, Long> getService() {
|
|
||||||
return myResourceService;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected String getResourceName() {
|
|
||||||
return "MyResource";
|
|
||||||
}
|
|
||||||
|
|
||||||
// BaseController가 제공하는 CRUD 엔드포인트 자동 생성:
|
|
||||||
// POST /api/myresource
|
|
||||||
// GET /api/myresource/{id}
|
|
||||||
// GET /api/myresource
|
|
||||||
// GET /api/myresource/page
|
|
||||||
// PUT /api/myresource/{id}
|
|
||||||
// DELETE /api/myresource/{id}
|
|
||||||
// GET /api/myresource/{id}/exists
|
|
||||||
|
|
||||||
// 커스텀 엔드포인트 추가 시:
|
|
||||||
@Operation(
|
|
||||||
summary = "커스텀 조회",
|
|
||||||
description = "특정 조건으로 리소스를 조회합니다",
|
|
||||||
responses = {
|
|
||||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
|
||||||
responseCode = "200",
|
|
||||||
description = "조회 성공"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
@GetMapping("/custom/{key}")
|
|
||||||
public ResponseEntity<ApiResponse<MyResourceDto>> customEndpoint(
|
|
||||||
@Parameter(description = "커스텀 키", required = true)
|
|
||||||
@PathVariable String key) {
|
|
||||||
// 구현...
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 독립적인 Controller를 작성하는 경우
|
|
||||||
|
|
||||||
```java
|
|
||||||
@RestController
|
|
||||||
@RequestMapping("/api/custom")
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
@Slf4j
|
|
||||||
@Tag(name = "Custom API", description = "커스텀 API")
|
|
||||||
public class CustomController {
|
|
||||||
|
|
||||||
@Operation(
|
|
||||||
summary = "커스텀 작업",
|
|
||||||
description = "특정 작업을 수행합니다",
|
|
||||||
responses = {
|
|
||||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
|
||||||
responseCode = "200",
|
|
||||||
description = "작업 성공"
|
|
||||||
),
|
|
||||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
|
||||||
responseCode = "500",
|
|
||||||
description = "서버 오류"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
@PostMapping("/action")
|
|
||||||
public ResponseEntity<Map<String, Object>> customAction(
|
|
||||||
@Parameter(description = "액션 파라미터", required = true)
|
|
||||||
@RequestBody Map<String, String> params) {
|
|
||||||
// 구현...
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔍 Swagger UI 화면 구성
|
|
||||||
|
|
||||||
### 메인 화면
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────┐
|
|
||||||
│ SNP Batch REST API │
|
|
||||||
│ Version: v1.0.0 │
|
|
||||||
│ Spring Batch 기반 데이터 통합 시스템 REST API │
|
|
||||||
├─────────────────────────────────────────────────┤
|
|
||||||
│ Servers: │
|
|
||||||
│ ▼ http://localhost:8081 (로컬 개발 서버) │
|
|
||||||
├─────────────────────────────────────────────────┤
|
|
||||||
│ │
|
|
||||||
│ ▼ Batch Management API │
|
|
||||||
│ POST /api/batch/jobs/{jobName}/execute │
|
|
||||||
│ GET /api/batch/jobs │
|
|
||||||
│ ... │
|
|
||||||
│ │
|
|
||||||
│ ▼ Product API (9개 엔드포인트 통합 표시) │
|
|
||||||
│ POST /api/products │
|
|
||||||
│ GET /api/products/{id} │
|
|
||||||
│ GET /api/products │
|
|
||||||
│ GET /api/products/page │
|
|
||||||
│ PUT /api/products/{id} │
|
|
||||||
│ DELETE /api/products/{id} │
|
|
||||||
│ GET /api/products/{id}/exists │
|
|
||||||
│ GET /api/products/by-product-id/{...} │
|
|
||||||
│ GET /api/products/stats/active-count │
|
|
||||||
│ │
|
|
||||||
│ (Base API 그룹은 표시되지 않음) │
|
|
||||||
│ │
|
|
||||||
└─────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### API 실행 화면 예시
|
|
||||||
각 엔드포인트 클릭 시:
|
|
||||||
- **Parameters**: 파라미터 입력 필드
|
|
||||||
- **Request body**: JSON 요청 본문 에디터
|
|
||||||
- **Try it out**: 실제 API 호출 버튼
|
|
||||||
- **Responses**: 응답 코드 및 예시
|
|
||||||
- **Curl**: curl 명령어 생성
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ⚠️ 문제 해결
|
|
||||||
|
|
||||||
### 1. Swagger UI 접속 불가
|
|
||||||
**증상**: `http://localhost:8081/swagger-ui/index.html` 접속 시 404 오류
|
|
||||||
|
|
||||||
**해결**:
|
|
||||||
1. 애플리케이션이 실행 중인지 확인
|
|
||||||
2. 포트 번호 확인 (`application.yml`의 `server.port`)
|
|
||||||
3. 다음 URL 시도:
|
|
||||||
- `http://localhost:8081/swagger-ui.html`
|
|
||||||
- `http://localhost:8081/swagger-ui/`
|
|
||||||
|
|
||||||
### 2. API 실행 시 401/403 오류
|
|
||||||
**증상**: "Try it out" 클릭 시 인증 오류
|
|
||||||
|
|
||||||
**해결**:
|
|
||||||
- 현재 인증이 설정되지 않음 (기본 허용)
|
|
||||||
- Spring Security 추가 시 Swagger 경로 허용 필요:
|
|
||||||
```java
|
|
||||||
.authorizeHttpRequests(auth -> auth
|
|
||||||
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
|
|
||||||
.anyRequest().authenticated()
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 특정 엔드포인트가 보이지 않음
|
|
||||||
**증상**: Controller는 작성했지만 Swagger UI에 표시되지 않음
|
|
||||||
|
|
||||||
**해결**:
|
|
||||||
1. `@RestController` 어노테이션 확인
|
|
||||||
2. `@RequestMapping` 경로 확인
|
|
||||||
3. Controller가 `com.snp.batch` 패키지 하위에 있는지 확인
|
|
||||||
4. 애플리케이션 재시작
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 설정 파일
|
|
||||||
|
|
||||||
### application.yml (Swagger 관련 설정)
|
|
||||||
```yaml
|
|
||||||
server:
|
|
||||||
port: 8081 # Swagger UI 접속 포트
|
|
||||||
|
|
||||||
# Springdoc OpenAPI 설정 (필요 시 추가)
|
|
||||||
springdoc:
|
|
||||||
api-docs:
|
|
||||||
path: /v3/api-docs # OpenAPI JSON 경로
|
|
||||||
swagger-ui:
|
|
||||||
path: /swagger-ui.html # Swagger UI 경로
|
|
||||||
enabled: true
|
|
||||||
operations-sorter: alpha # 엔드포인트 정렬 (alpha, method)
|
|
||||||
tags-sorter: alpha # 태그 정렬
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎓 추가 학습 자료
|
|
||||||
|
|
||||||
### Swagger 어노테이션 공식 문서
|
|
||||||
- [OpenAPI 3.0 Annotations](https://github.com/swagger-api/swagger-core/wiki/Swagger-2.X---Annotations)
|
|
||||||
- [Springdoc OpenAPI](https://springdoc.org/)
|
|
||||||
|
|
||||||
### 관련 파일 위치
|
|
||||||
```
|
|
||||||
src/main/java/com/snp/batch/
|
|
||||||
├── common/web/controller/BaseController.java # 공통 CRUD Base
|
|
||||||
├── global/config/SwaggerConfig.java # Swagger 설정
|
|
||||||
├── global/controller/BatchController.java # Batch API
|
|
||||||
└── jobs/sample/web/controller/ProductWebController.java # Product API
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ 체크리스트
|
|
||||||
|
|
||||||
애플리케이션 실행 전 확인:
|
|
||||||
- [ ] Maven 빌드 성공
|
|
||||||
- [ ] `application.yml` 설정 확인
|
|
||||||
- [ ] PostgreSQL 데이터베이스 연결 확인
|
|
||||||
- [ ] 포트 8081 사용 가능 여부 확인
|
|
||||||
|
|
||||||
Swagger 테스트 확인:
|
|
||||||
- [ ] Swagger UI 접속 성공
|
|
||||||
- [ ] Batch Management API 표시 확인
|
|
||||||
- [ ] Product API 표시 확인
|
|
||||||
- [ ] "Try it out" 기능 동작 확인
|
|
||||||
- [ ] API 응답 정상 확인
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📚 관련 문서
|
|
||||||
|
|
||||||
### 핵심 문서
|
|
||||||
- **[README.md](README.md)** - 프로젝트 개요 및 빠른 시작 가이드
|
|
||||||
- **[DEVELOPMENT_GUIDE.md](DEVELOPMENT_GUIDE.md)** - 신규 Job 개발 가이드 및 Base 클래스 사용법
|
|
||||||
- **[CLAUDE.md](CLAUDE.md)** - 프로젝트 형상관리 문서 (세션 연속성)
|
|
||||||
|
|
||||||
### 아키텍처 문서
|
|
||||||
- **[docs/architecture/ARCHITECTURE.md](docs/architecture/ARCHITECTURE.md)** - 프로젝트 아키텍처 상세 설계
|
|
||||||
- **[docs/architecture/PROJECT_STRUCTURE.md](docs/architecture/PROJECT_STRUCTURE.md)** - Job 중심 패키지 구조 가이드
|
|
||||||
|
|
||||||
### 구현 가이드
|
|
||||||
- **[docs/guides/PROXY_SERVICE_GUIDE.md](docs/guides/PROXY_SERVICE_GUIDE.md)** - 외부 API 프록시 패턴 구현 가이드
|
|
||||||
- **[docs/guides/SHIP_API_EXAMPLE.md](docs/guides/SHIP_API_EXAMPLE.md)** - Maritime API 연동 실전 예제
|
|
||||||
|
|
||||||
### 보안 문서
|
|
||||||
- **[docs/security/README.md](docs/security/README.md)** - 보안 전략 개요 (계획 단계)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**최종 업데이트**: 2025-10-16
|
|
||||||
**작성자**: Claude Code
|
|
||||||
**버전**: 1.1.0
|
|
||||||
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
73
frontend/README.md
Normal file
73
frontend/README.md
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
# React + TypeScript + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||||
|
|
||||||
|
## React Compiler
|
||||||
|
|
||||||
|
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||||
|
|
||||||
|
## Expanding the ESLint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
|
||||||
|
// Remove tseslint.configs.recommended and replace with this
|
||||||
|
tseslint.configs.recommendedTypeChecked,
|
||||||
|
// Alternatively, use this for stricter rules
|
||||||
|
tseslint.configs.strictTypeChecked,
|
||||||
|
// Optionally, add this for stylistic rules
|
||||||
|
tseslint.configs.stylisticTypeChecked,
|
||||||
|
|
||||||
|
// Other configs...
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// eslint.config.js
|
||||||
|
import reactX from 'eslint-plugin-react-x'
|
||||||
|
import reactDom from 'eslint-plugin-react-dom'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
// Enable lint rules for React
|
||||||
|
reactX.configs['recommended-typescript'],
|
||||||
|
// Enable lint rules for React DOM
|
||||||
|
reactDom.configs.recommended,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
23
frontend/eslint.config.js
Normal file
23
frontend/eslint.config.js
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
tseslint.configs.recommended,
|
||||||
|
reactHooks.configs.flat.recommended,
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>frontend</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
3935
frontend/package-lock.json
generated
Normal file
3935
frontend/package-lock.json
generated
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
33
frontend/package.json
Normal file
33
frontend/package.json
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-dom": "^19.2.0",
|
||||||
|
"react-router-dom": "^7.13.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.1",
|
||||||
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
|
"@types/node": "^24.10.1",
|
||||||
|
"@types/react": "^19.2.7",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
|
"eslint": "^9.39.1",
|
||||||
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
|
"globals": "^16.5.0",
|
||||||
|
"tailwindcss": "^4.1.18",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"typescript-eslint": "^8.48.0",
|
||||||
|
"vite": "^7.3.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
53
frontend/src/App.tsx
Normal file
53
frontend/src/App.tsx
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import { lazy, Suspense } from 'react';
|
||||||
|
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||||
|
import { ToastProvider, useToastContext } from './contexts/ToastContext';
|
||||||
|
import { ThemeProvider } from './contexts/ThemeContext';
|
||||||
|
import Navbar from './components/Navbar';
|
||||||
|
import ToastContainer from './components/Toast';
|
||||||
|
import LoadingSpinner from './components/LoadingSpinner';
|
||||||
|
|
||||||
|
const Dashboard = lazy(() => import('./pages/Dashboard'));
|
||||||
|
const Jobs = lazy(() => import('./pages/Jobs'));
|
||||||
|
const Executions = lazy(() => import('./pages/Executions'));
|
||||||
|
const ExecutionDetail = lazy(() => import('./pages/ExecutionDetail'));
|
||||||
|
const Recollects = lazy(() => import('./pages/Recollects'));
|
||||||
|
const RecollectDetail = lazy(() => import('./pages/RecollectDetail'));
|
||||||
|
const Schedules = lazy(() => import('./pages/Schedules'));
|
||||||
|
const Timeline = lazy(() => import('./pages/Timeline'));
|
||||||
|
|
||||||
|
function AppLayout() {
|
||||||
|
const { toasts, removeToast } = useToastContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-wing-bg text-wing-text">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 py-6">
|
||||||
|
<Navbar />
|
||||||
|
<Suspense fallback={<LoadingSpinner />}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Dashboard />} />
|
||||||
|
<Route path="/jobs" element={<Jobs />} />
|
||||||
|
<Route path="/executions" element={<Executions />} />
|
||||||
|
<Route path="/executions/:id" element={<ExecutionDetail />} />
|
||||||
|
<Route path="/recollects" element={<Recollects />} />
|
||||||
|
<Route path="/recollects/:id" element={<RecollectDetail />} />
|
||||||
|
<Route path="/schedules" element={<Schedules />} />
|
||||||
|
<Route path="/schedule-timeline" element={<Timeline />} />
|
||||||
|
</Routes>
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
<ToastContainer toasts={toasts} onRemove={removeToast} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<ThemeProvider>
|
||||||
|
<BrowserRouter basename="/snp-api">
|
||||||
|
<ToastProvider>
|
||||||
|
<AppLayout />
|
||||||
|
</ToastProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
445
frontend/src/api/batchApi.ts
Normal file
445
frontend/src/api/batchApi.ts
Normal file
@ -0,0 +1,445 @@
|
|||||||
|
const BASE = import.meta.env.DEV ? '/snp-api/api/batch' : '/snp-api/api/batch';
|
||||||
|
|
||||||
|
async function fetchJson<T>(url: string): Promise<T> {
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) throw new Error(`API Error: ${res.status} ${res.statusText}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postJson<T>(url: string, body?: unknown): Promise<T> {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`API Error: ${res.status} ${res.statusText}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Dashboard ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface DashboardStats {
|
||||||
|
totalSchedules: number;
|
||||||
|
activeSchedules: number;
|
||||||
|
inactiveSchedules: number;
|
||||||
|
totalJobs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RunningJob {
|
||||||
|
jobName: string;
|
||||||
|
executionId: number;
|
||||||
|
status: string;
|
||||||
|
startTime: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecentExecution {
|
||||||
|
executionId: number;
|
||||||
|
jobName: string;
|
||||||
|
status: string;
|
||||||
|
startTime: string;
|
||||||
|
endTime: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecentFailure {
|
||||||
|
executionId: number;
|
||||||
|
jobName: string;
|
||||||
|
status: string;
|
||||||
|
startTime: string;
|
||||||
|
endTime: string | null;
|
||||||
|
exitMessage: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FailureStats {
|
||||||
|
last24h: number;
|
||||||
|
last7d: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardResponse {
|
||||||
|
stats: DashboardStats;
|
||||||
|
runningJobs: RunningJob[];
|
||||||
|
recentExecutions: RecentExecution[];
|
||||||
|
recentFailures: RecentFailure[];
|
||||||
|
staleExecutionCount: number;
|
||||||
|
failureStats: FailureStats;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Job Execution ────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface JobExecutionDto {
|
||||||
|
executionId: number;
|
||||||
|
jobName: string;
|
||||||
|
status: string;
|
||||||
|
startTime: string;
|
||||||
|
endTime: string | null;
|
||||||
|
exitCode: string | null;
|
||||||
|
exitMessage: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiCallInfo {
|
||||||
|
apiUrl: string;
|
||||||
|
method: string;
|
||||||
|
parameters: Record<string, unknown> | null;
|
||||||
|
totalCalls: number;
|
||||||
|
completedCalls: number;
|
||||||
|
lastCallTime: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StepExecutionDto {
|
||||||
|
stepExecutionId: number;
|
||||||
|
stepName: string;
|
||||||
|
status: string;
|
||||||
|
startTime: string;
|
||||||
|
endTime: string | null;
|
||||||
|
readCount: number;
|
||||||
|
writeCount: number;
|
||||||
|
commitCount: number;
|
||||||
|
rollbackCount: number;
|
||||||
|
readSkipCount: number;
|
||||||
|
processSkipCount: number;
|
||||||
|
writeSkipCount: number;
|
||||||
|
filterCount: number;
|
||||||
|
exitCode: string;
|
||||||
|
exitMessage: string | null;
|
||||||
|
duration: number | null;
|
||||||
|
apiCallInfo: ApiCallInfo | null;
|
||||||
|
apiLogSummary: StepApiLogSummary | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiLogEntryDto {
|
||||||
|
logId: number;
|
||||||
|
requestUri: string;
|
||||||
|
httpMethod: string;
|
||||||
|
statusCode: number | null;
|
||||||
|
responseTimeMs: number | null;
|
||||||
|
responseCount: number | null;
|
||||||
|
errorMessage: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StepApiLogSummary {
|
||||||
|
totalCalls: number;
|
||||||
|
successCount: number;
|
||||||
|
errorCount: number;
|
||||||
|
avgResponseMs: number;
|
||||||
|
maxResponseMs: number;
|
||||||
|
minResponseMs: number;
|
||||||
|
totalResponseMs: number;
|
||||||
|
totalRecordCount: number;
|
||||||
|
logs: ApiLogEntryDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JobExecutionDetailDto {
|
||||||
|
executionId: number;
|
||||||
|
jobName: string;
|
||||||
|
status: string;
|
||||||
|
startTime: string;
|
||||||
|
endTime: string | null;
|
||||||
|
exitCode: string;
|
||||||
|
exitMessage: string | null;
|
||||||
|
jobParameters: Record<string, string>;
|
||||||
|
jobInstanceId: number;
|
||||||
|
duration: number | null;
|
||||||
|
readCount: number;
|
||||||
|
writeCount: number;
|
||||||
|
skipCount: number;
|
||||||
|
filterCount: number;
|
||||||
|
stepExecutions: StepExecutionDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Schedule ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ScheduleResponse {
|
||||||
|
id: number;
|
||||||
|
jobName: string;
|
||||||
|
cronExpression: string;
|
||||||
|
description: string | null;
|
||||||
|
active: boolean;
|
||||||
|
nextFireTime: string | null;
|
||||||
|
previousFireTime: string | null;
|
||||||
|
triggerState: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScheduleRequest {
|
||||||
|
jobName: string;
|
||||||
|
cronExpression: string;
|
||||||
|
description?: string;
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Timeline ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface PeriodInfo {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExecutionInfo {
|
||||||
|
executionId: number | null;
|
||||||
|
status: string;
|
||||||
|
startTime: string | null;
|
||||||
|
endTime: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScheduleTimeline {
|
||||||
|
jobName: string;
|
||||||
|
executions: Record<string, ExecutionInfo | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimelineResponse {
|
||||||
|
periodLabel: string;
|
||||||
|
periods: PeriodInfo[];
|
||||||
|
schedules: ScheduleTimeline[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── F4: Execution Search ─────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ExecutionSearchResponse {
|
||||||
|
executions: JobExecutionDto[];
|
||||||
|
totalCount: number;
|
||||||
|
page: number;
|
||||||
|
size: number;
|
||||||
|
totalPages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── F7: Job Detail ───────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface LastExecution {
|
||||||
|
executionId: number;
|
||||||
|
status: string;
|
||||||
|
startTime: string;
|
||||||
|
endTime: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JobDetailDto {
|
||||||
|
jobName: string;
|
||||||
|
lastExecution: LastExecution | null;
|
||||||
|
scheduleCron: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── F8: Statistics ───────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface DailyStat {
|
||||||
|
date: string;
|
||||||
|
successCount: number;
|
||||||
|
failedCount: number;
|
||||||
|
otherCount: number;
|
||||||
|
avgDurationMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExecutionStatisticsDto {
|
||||||
|
dailyStats: DailyStat[];
|
||||||
|
totalExecutions: number;
|
||||||
|
totalSuccess: number;
|
||||||
|
totalFailed: number;
|
||||||
|
avgDurationMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Recollection History ─────────────────────────────────────
|
||||||
|
|
||||||
|
export interface RecollectionHistoryDto {
|
||||||
|
historyId: number;
|
||||||
|
apiKey: string;
|
||||||
|
apiKeyName: string | null;
|
||||||
|
jobName: string;
|
||||||
|
jobExecutionId: number | null;
|
||||||
|
rangeFromDate: string;
|
||||||
|
rangeToDate: string;
|
||||||
|
executionStatus: string;
|
||||||
|
executionStartTime: string | null;
|
||||||
|
executionEndTime: string | null;
|
||||||
|
durationMs: number | null;
|
||||||
|
readCount: number | null;
|
||||||
|
writeCount: number | null;
|
||||||
|
skipCount: number | null;
|
||||||
|
apiCallCount: number | null;
|
||||||
|
executor: string | null;
|
||||||
|
recollectionReason: string | null;
|
||||||
|
failureReason: string | null;
|
||||||
|
hasOverlap: boolean | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecollectionSearchResponse {
|
||||||
|
content: RecollectionHistoryDto[];
|
||||||
|
totalElements: number;
|
||||||
|
number: number;
|
||||||
|
size: number;
|
||||||
|
totalPages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecollectionStatsResponse {
|
||||||
|
totalCount: number;
|
||||||
|
completedCount: number;
|
||||||
|
failedCount: number;
|
||||||
|
runningCount: number;
|
||||||
|
overlapCount: number;
|
||||||
|
recentHistories: RecollectionHistoryDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiStatsDto {
|
||||||
|
callCount: number;
|
||||||
|
totalMs: number;
|
||||||
|
avgMs: number;
|
||||||
|
maxMs: number;
|
||||||
|
minMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecollectionDetailResponse {
|
||||||
|
history: RecollectionHistoryDto;
|
||||||
|
overlappingHistories: RecollectionHistoryDto[];
|
||||||
|
apiStats: ApiStatsDto | null;
|
||||||
|
collectionPeriod: CollectionPeriodDto | null;
|
||||||
|
stepExecutions: StepExecutionDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CollectionPeriodDto {
|
||||||
|
apiKey: string;
|
||||||
|
apiKeyName: string | null;
|
||||||
|
jobName: string | null;
|
||||||
|
orderSeq: number | null;
|
||||||
|
rangeFromDate: string | null;
|
||||||
|
rangeToDate: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── API Functions ────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const batchApi = {
|
||||||
|
getDashboard: () =>
|
||||||
|
fetchJson<DashboardResponse>(`${BASE}/dashboard`),
|
||||||
|
|
||||||
|
getJobs: () =>
|
||||||
|
fetchJson<string[]>(`${BASE}/jobs`),
|
||||||
|
|
||||||
|
getJobsDetail: () =>
|
||||||
|
fetchJson<JobDetailDto[]>(`${BASE}/jobs/detail`),
|
||||||
|
|
||||||
|
executeJob: (jobName: string, params?: Record<string, string>) => {
|
||||||
|
const qs = params ? '?' + new URLSearchParams(params).toString() : '';
|
||||||
|
return postJson<{ success: boolean; message: string; executionId?: number }>(
|
||||||
|
`${BASE}/jobs/${jobName}/execute${qs}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
getJobExecutions: (jobName: string) =>
|
||||||
|
fetchJson<JobExecutionDto[]>(`${BASE}/jobs/${jobName}/executions`),
|
||||||
|
|
||||||
|
getRecentExecutions: (limit = 50) =>
|
||||||
|
fetchJson<JobExecutionDto[]>(`${BASE}/executions/recent?limit=${limit}`),
|
||||||
|
|
||||||
|
getExecutionDetail: (id: number) =>
|
||||||
|
fetchJson<JobExecutionDetailDto>(`${BASE}/executions/${id}/detail`),
|
||||||
|
|
||||||
|
stopExecution: (id: number) =>
|
||||||
|
postJson<{ success: boolean; message: string }>(`${BASE}/executions/${id}/stop`),
|
||||||
|
|
||||||
|
// F1: Abandon
|
||||||
|
getStaleExecutions: (thresholdMinutes = 60) =>
|
||||||
|
fetchJson<JobExecutionDto[]>(`${BASE}/executions/stale?thresholdMinutes=${thresholdMinutes}`),
|
||||||
|
|
||||||
|
abandonExecution: (id: number) =>
|
||||||
|
postJson<{ success: boolean; message: string }>(`${BASE}/executions/${id}/abandon`),
|
||||||
|
|
||||||
|
abandonAllStale: (thresholdMinutes = 60) =>
|
||||||
|
postJson<{ success: boolean; message: string; abandonedCount?: number }>(
|
||||||
|
`${BASE}/executions/stale/abandon-all?thresholdMinutes=${thresholdMinutes}`),
|
||||||
|
|
||||||
|
// F4: Search
|
||||||
|
searchExecutions: (params: {
|
||||||
|
jobNames?: string[];
|
||||||
|
status?: string;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
page?: number;
|
||||||
|
size?: number;
|
||||||
|
}) => {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (params.jobNames && params.jobNames.length > 0) qs.set('jobNames', params.jobNames.join(','));
|
||||||
|
if (params.status) qs.set('status', params.status);
|
||||||
|
if (params.startDate) qs.set('startDate', params.startDate);
|
||||||
|
if (params.endDate) qs.set('endDate', params.endDate);
|
||||||
|
qs.set('page', String(params.page ?? 0));
|
||||||
|
qs.set('size', String(params.size ?? 50));
|
||||||
|
return fetchJson<ExecutionSearchResponse>(`${BASE}/executions/search?${qs.toString()}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// F8: Statistics
|
||||||
|
getStatistics: (days = 30) =>
|
||||||
|
fetchJson<ExecutionStatisticsDto>(`${BASE}/statistics?days=${days}`),
|
||||||
|
|
||||||
|
getJobStatistics: (jobName: string, days = 30) =>
|
||||||
|
fetchJson<ExecutionStatisticsDto>(`${BASE}/statistics/${jobName}?days=${days}`),
|
||||||
|
|
||||||
|
// Schedule
|
||||||
|
getSchedules: () =>
|
||||||
|
fetchJson<{ schedules: ScheduleResponse[]; count: number }>(`${BASE}/schedules`),
|
||||||
|
|
||||||
|
getSchedule: (jobName: string) =>
|
||||||
|
fetchJson<ScheduleResponse>(`${BASE}/schedules/${jobName}`),
|
||||||
|
|
||||||
|
createSchedule: (data: ScheduleRequest) =>
|
||||||
|
postJson<{ success: boolean; message: string; data?: ScheduleResponse }>(`${BASE}/schedules`, data),
|
||||||
|
|
||||||
|
updateSchedule: (jobName: string, data: { cronExpression: string; description?: string }) =>
|
||||||
|
postJson<{ success: boolean; message: string; data?: ScheduleResponse }>(
|
||||||
|
`${BASE}/schedules/${jobName}/update`, data),
|
||||||
|
|
||||||
|
deleteSchedule: (jobName: string) =>
|
||||||
|
postJson<{ success: boolean; message: string }>(`${BASE}/schedules/${jobName}/delete`),
|
||||||
|
|
||||||
|
toggleSchedule: (jobName: string, active: boolean) =>
|
||||||
|
postJson<{ success: boolean; message: string; data?: ScheduleResponse }>(
|
||||||
|
`${BASE}/schedules/${jobName}/toggle`, { active }),
|
||||||
|
|
||||||
|
// Timeline
|
||||||
|
getTimeline: (view: string, date: string) =>
|
||||||
|
fetchJson<TimelineResponse>(`${BASE}/timeline?view=${view}&date=${date}`),
|
||||||
|
|
||||||
|
getPeriodExecutions: (jobName: string, view: string, periodKey: string) =>
|
||||||
|
fetchJson<JobExecutionDto[]>(
|
||||||
|
`${BASE}/timeline/period-executions?jobName=${jobName}&view=${view}&periodKey=${periodKey}`),
|
||||||
|
|
||||||
|
// Recollection
|
||||||
|
searchRecollections: (params: {
|
||||||
|
apiKey?: string;
|
||||||
|
jobName?: string;
|
||||||
|
status?: string;
|
||||||
|
fromDate?: string;
|
||||||
|
toDate?: string;
|
||||||
|
page?: number;
|
||||||
|
size?: number;
|
||||||
|
}) => {
|
||||||
|
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);
|
||||||
|
qs.set('page', String(params.page ?? 0));
|
||||||
|
qs.set('size', String(params.size ?? 20));
|
||||||
|
return fetchJson<RecollectionSearchResponse>(`${BASE}/recollection-histories?${qs.toString()}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
getRecollectionDetail: (historyId: number) =>
|
||||||
|
fetchJson<RecollectionDetailResponse>(`${BASE}/recollection-histories/${historyId}`),
|
||||||
|
|
||||||
|
getRecollectionStats: () =>
|
||||||
|
fetchJson<RecollectionStatsResponse>(`${BASE}/recollection-histories/stats`),
|
||||||
|
|
||||||
|
getCollectionPeriods: () =>
|
||||||
|
fetchJson<CollectionPeriodDto[]>(`${BASE}/collection-periods`),
|
||||||
|
|
||||||
|
resetCollectionPeriod: (apiKey: string) =>
|
||||||
|
postJson<{ success: boolean; message: string }>(`${BASE}/collection-periods/${apiKey}/reset`),
|
||||||
|
|
||||||
|
updateCollectionPeriod: (apiKey: string, body: { rangeFromDate: string; rangeToDate: string }) => {
|
||||||
|
return fetch(`${BASE}/collection-periods/${apiKey}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}).then(async (res) => {
|
||||||
|
if (!res.ok) throw new Error(`API Error: ${res.status} ${res.statusText}`);
|
||||||
|
return res.json() as Promise<{ success: boolean; message: string }>;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
74
frontend/src/components/BarChart.tsx
Normal file
74
frontend/src/components/BarChart.tsx
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
interface BarValue {
|
||||||
|
color: string;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BarData {
|
||||||
|
label: string;
|
||||||
|
values: BarValue[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: BarData[];
|
||||||
|
height?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BarChart({ data, height = 200 }: Props) {
|
||||||
|
const maxTotal = Math.max(...data.map((d) => d.values.reduce((sum, v) => sum + v.value, 0)), 1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="flex items-end gap-1" style={{ height }}>
|
||||||
|
{data.map((bar, i) => {
|
||||||
|
const total = bar.values.reduce((sum, v) => sum + v.value, 0);
|
||||||
|
const ratio = total / maxTotal;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={i} className="flex-1 flex flex-col justify-end h-full min-w-0">
|
||||||
|
<div
|
||||||
|
className="w-full rounded-t overflow-hidden"
|
||||||
|
style={{ height: `${ratio * 100}%` }}
|
||||||
|
title={bar.values.map((v) => `${v.color}: ${v.value}`).join(', ')}
|
||||||
|
>
|
||||||
|
{bar.values
|
||||||
|
.filter((v) => v.value > 0)
|
||||||
|
.map((v, j) => {
|
||||||
|
const segmentRatio = total > 0 ? (v.value / total) * 100 : 0;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={j}
|
||||||
|
className={colorToClass(v.color)}
|
||||||
|
style={{ height: `${segmentRatio}%` }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1 mt-1">
|
||||||
|
{data.map((bar, i) => (
|
||||||
|
<div key={i} className="flex-1 min-w-0">
|
||||||
|
<p className="text-[10px] text-gray-500 text-center truncate" title={bar.label}>
|
||||||
|
{bar.label}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function colorToClass(color: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
green: 'bg-green-500',
|
||||||
|
red: 'bg-red-500',
|
||||||
|
gray: 'bg-gray-400',
|
||||||
|
blue: 'bg-blue-500',
|
||||||
|
yellow: 'bg-yellow-500',
|
||||||
|
orange: 'bg-orange-500',
|
||||||
|
indigo: 'bg-indigo-500',
|
||||||
|
};
|
||||||
|
return map[color] ?? color;
|
||||||
|
}
|
||||||
49
frontend/src/components/ConfirmModal.tsx
Normal file
49
frontend/src/components/ConfirmModal.tsx
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
title?: string;
|
||||||
|
message: string;
|
||||||
|
confirmLabel?: string;
|
||||||
|
cancelLabel?: string;
|
||||||
|
confirmColor?: string;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ConfirmModal({
|
||||||
|
open,
|
||||||
|
title = '확인',
|
||||||
|
message,
|
||||||
|
confirmLabel = '확인',
|
||||||
|
cancelLabel = '취소',
|
||||||
|
confirmColor = 'bg-wing-accent hover:bg-wing-accent/80',
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
}: Props) {
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-wing-overlay" onClick={onCancel}>
|
||||||
|
<div
|
||||||
|
className="bg-wing-surface rounded-xl shadow-2xl p-6 max-w-md w-full mx-4"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h3 className="text-lg font-semibold text-wing-text mb-2">{title}</h3>
|
||||||
|
<p className="text-wing-muted text-sm mb-6 whitespace-pre-line">{message}</p>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onClick={onCancel}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-wing-text bg-wing-card rounded-lg hover:bg-wing-hover transition-colors"
|
||||||
|
>
|
||||||
|
{cancelLabel}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onConfirm}
|
||||||
|
className={`px-4 py-2 text-sm font-medium text-white rounded-lg transition-colors ${confirmColor}`}
|
||||||
|
>
|
||||||
|
{confirmLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
frontend/src/components/EmptyState.tsx
Normal file
15
frontend/src/components/EmptyState.tsx
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
interface Props {
|
||||||
|
icon?: string;
|
||||||
|
message: string;
|
||||||
|
sub?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EmptyState({ icon = '📭', message, sub }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-wing-muted">
|
||||||
|
<span className="text-4xl mb-3">{icon}</span>
|
||||||
|
<p className="text-sm font-medium">{message}</p>
|
||||||
|
{sub && <p className="text-xs mt-1">{sub}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
frontend/src/components/InfoModal.tsx
Normal file
35
frontend/src/components/InfoModal.tsx
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
title?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function InfoModal({
|
||||||
|
open,
|
||||||
|
title = '정보',
|
||||||
|
children,
|
||||||
|
onClose,
|
||||||
|
}: Props) {
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-wing-overlay" onClick={onClose}>
|
||||||
|
<div
|
||||||
|
className="bg-wing-surface rounded-xl shadow-2xl p-6 max-w-lg w-full mx-4"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h3 className="text-lg font-semibold text-wing-text mb-4">{title}</h3>
|
||||||
|
<div className="text-wing-muted text-sm mb-6">{children}</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-wing-text bg-wing-card rounded-lg hover:bg-wing-hover transition-colors"
|
||||||
|
>
|
||||||
|
닫기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
frontend/src/components/LoadingSpinner.tsx
Normal file
7
frontend/src/components/LoadingSpinner.tsx
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
export default function LoadingSpinner({ className = '' }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<div className={`flex items-center justify-center py-12 ${className}`}>
|
||||||
|
<div className="w-8 h-8 border-4 border-wing-accent/30 border-t-wing-accent rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
frontend/src/components/Navbar.tsx
Normal file
55
frontend/src/components/Navbar.tsx
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import { Link, useLocation } from 'react-router-dom';
|
||||||
|
import { useThemeContext } from '../contexts/ThemeContext';
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ path: '/', label: '대시보드', icon: '📊' },
|
||||||
|
{ path: '/jobs', label: '작업', icon: '⚙️' },
|
||||||
|
{ path: '/executions', label: '실행 이력', icon: '📋' },
|
||||||
|
{ path: '/recollects', label: '재수집 이력', icon: '🔄' },
|
||||||
|
{ path: '/schedules', label: '스케줄', icon: '🕐' },
|
||||||
|
{ path: '/schedule-timeline', label: '타임라인', icon: '📅' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Navbar() {
|
||||||
|
const location = useLocation();
|
||||||
|
const { theme, toggle } = useThemeContext();
|
||||||
|
|
||||||
|
const isActive = (path: string) => {
|
||||||
|
if (path === '/') return location.pathname === '/';
|
||||||
|
return location.pathname.startsWith(path);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="bg-wing-glass-dense backdrop-blur-sm shadow-md rounded-xl mb-6 px-4 py-3 border border-wing-border">
|
||||||
|
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||||
|
<Link to="/" className="text-lg font-bold text-wing-accent no-underline">
|
||||||
|
S&P 배치 관리
|
||||||
|
</Link>
|
||||||
|
<div className="flex gap-1 flex-wrap items-center">
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.path}
|
||||||
|
to={item.path}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-sm font-medium no-underline transition-colors
|
||||||
|
${isActive(item.path)
|
||||||
|
? 'bg-wing-accent text-white'
|
||||||
|
: 'text-wing-muted hover:bg-wing-hover hover:text-wing-accent'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="mr-1">{item.icon}</span>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
onClick={toggle}
|
||||||
|
className="ml-2 px-2.5 py-1.5 rounded-lg text-sm bg-wing-card text-wing-muted
|
||||||
|
hover:text-wing-text border border-wing-border transition-colors"
|
||||||
|
title={theme === 'dark' ? '라이트 모드' : '다크 모드'}
|
||||||
|
>
|
||||||
|
{theme === 'dark' ? '☀️' : '🌙'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
frontend/src/components/StatusBadge.tsx
Normal file
40
frontend/src/components/StatusBadge.tsx
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
const STATUS_CONFIG: Record<string, { bg: string; text: string; label: string }> = {
|
||||||
|
COMPLETED: { bg: 'bg-emerald-100 text-emerald-700', text: '완료', label: '✓' },
|
||||||
|
FAILED: { bg: 'bg-red-100 text-red-700', text: '실패', label: '✕' },
|
||||||
|
STARTED: { bg: 'bg-blue-100 text-blue-700', text: '실행중', label: '↻' },
|
||||||
|
STARTING: { bg: 'bg-cyan-100 text-cyan-700', text: '시작중', label: '⏳' },
|
||||||
|
STOPPED: { bg: 'bg-amber-100 text-amber-700', text: '중지됨', label: '⏸' },
|
||||||
|
STOPPING: { bg: 'bg-orange-100 text-orange-700', text: '중지중', label: '⏸' },
|
||||||
|
ABANDONED: { bg: 'bg-gray-100 text-gray-700', text: '포기됨', label: '—' },
|
||||||
|
SCHEDULED: { bg: 'bg-violet-100 text-violet-700', text: '예정', label: '🕐' },
|
||||||
|
UNKNOWN: { bg: 'bg-gray-100 text-gray-500', text: '알수없음', label: '?' },
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
status: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StatusBadge({ status, className = '' }: Props) {
|
||||||
|
const config = STATUS_CONFIG[status] || STATUS_CONFIG.UNKNOWN;
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-semibold ${config.bg} ${className}`}>
|
||||||
|
<span>{config.label}</span>
|
||||||
|
{config.text}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
|
export function getStatusColor(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'COMPLETED': return '#10b981';
|
||||||
|
case 'FAILED': return '#ef4444';
|
||||||
|
case 'STARTED': return '#3b82f6';
|
||||||
|
case 'STARTING': return '#06b6d4';
|
||||||
|
case 'STOPPED': return '#f59e0b';
|
||||||
|
case 'STOPPING': return '#f97316';
|
||||||
|
case 'SCHEDULED': return '#8b5cf6';
|
||||||
|
default: return '#6b7280';
|
||||||
|
}
|
||||||
|
}
|
||||||
37
frontend/src/components/Toast.tsx
Normal file
37
frontend/src/components/Toast.tsx
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import type { Toast as ToastType } from '../hooks/useToast';
|
||||||
|
|
||||||
|
const TYPE_STYLES: Record<ToastType['type'], string> = {
|
||||||
|
success: 'bg-emerald-500',
|
||||||
|
error: 'bg-red-500',
|
||||||
|
warning: 'bg-amber-500',
|
||||||
|
info: 'bg-blue-500',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
toasts: ToastType[];
|
||||||
|
onRemove: (id: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ToastContainer({ toasts, onRemove }: Props) {
|
||||||
|
if (toasts.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed top-4 right-4 z-50 flex flex-col gap-2 max-w-sm">
|
||||||
|
{toasts.map((toast) => (
|
||||||
|
<div
|
||||||
|
key={toast.id}
|
||||||
|
className={`${TYPE_STYLES[toast.type]} text-white px-4 py-3 rounded-lg shadow-lg
|
||||||
|
flex items-center justify-between gap-3 animate-slide-in`}
|
||||||
|
>
|
||||||
|
<span className="text-sm">{toast.message}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => onRemove(toast.id)}
|
||||||
|
className="text-white/80 hover:text-white text-lg leading-none"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
frontend/src/contexts/ThemeContext.tsx
Normal file
26
frontend/src/contexts/ThemeContext.tsx
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { createContext, useContext, type ReactNode } from 'react';
|
||||||
|
import { useTheme } from '../hooks/useTheme';
|
||||||
|
|
||||||
|
interface ThemeContextValue {
|
||||||
|
theme: 'dark' | 'light';
|
||||||
|
toggle: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ThemeContext = createContext<ThemeContextValue>({
|
||||||
|
theme: 'dark',
|
||||||
|
toggle: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||||
|
const value = useTheme();
|
||||||
|
return (
|
||||||
|
<ThemeContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</ThemeContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
|
export function useThemeContext() {
|
||||||
|
return useContext(ThemeContext);
|
||||||
|
}
|
||||||
29
frontend/src/contexts/ToastContext.tsx
Normal file
29
frontend/src/contexts/ToastContext.tsx
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { createContext, useContext, type ReactNode } from 'react';
|
||||||
|
import { useToast, type Toast } from '../hooks/useToast';
|
||||||
|
|
||||||
|
interface ToastContextValue {
|
||||||
|
toasts: Toast[];
|
||||||
|
showToast: (message: string, type?: Toast['type']) => void;
|
||||||
|
removeToast: (id: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ToastContext = createContext<ToastContextValue | null>(null);
|
||||||
|
|
||||||
|
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||||
|
const { toasts, showToast, removeToast } = useToast();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastContext.Provider value={{ toasts, showToast, removeToast }}>
|
||||||
|
{children}
|
||||||
|
</ToastContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
|
export function useToastContext(): ToastContextValue {
|
||||||
|
const ctx = useContext(ToastContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('useToastContext must be used within a ToastProvider');
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
53
frontend/src/hooks/usePoller.ts
Normal file
53
frontend/src/hooks/usePoller.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 주기적 폴링 훅
|
||||||
|
* - 마운트 시 즉시 1회 실행 후 intervalMs 주기로 반복
|
||||||
|
* - 탭 비활성(document.hidden) 시 자동 중단, 활성화 시 즉시 재개
|
||||||
|
* - deps 변경 시 타이머 재설정
|
||||||
|
*/
|
||||||
|
export function usePoller(
|
||||||
|
fn: () => Promise<void> | void,
|
||||||
|
intervalMs: number,
|
||||||
|
deps: unknown[] = [],
|
||||||
|
) {
|
||||||
|
const fnRef = useRef(fn);
|
||||||
|
fnRef.current = fn;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let timer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
const run = () => {
|
||||||
|
fnRef.current();
|
||||||
|
};
|
||||||
|
|
||||||
|
const start = () => {
|
||||||
|
run();
|
||||||
|
timer = setInterval(run, intervalMs);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stop = () => {
|
||||||
|
if (timer) {
|
||||||
|
clearInterval(timer);
|
||||||
|
timer = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVisibility = () => {
|
||||||
|
if (document.hidden) {
|
||||||
|
stop();
|
||||||
|
} else {
|
||||||
|
start();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
start();
|
||||||
|
document.addEventListener('visibilitychange', handleVisibility);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
stop();
|
||||||
|
document.removeEventListener('visibilitychange', handleVisibility);
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [intervalMs, ...deps]);
|
||||||
|
}
|
||||||
27
frontend/src/hooks/useTheme.ts
Normal file
27
frontend/src/hooks/useTheme.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
|
||||||
|
type Theme = 'dark' | 'light';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'snp-batch-theme';
|
||||||
|
|
||||||
|
function getInitialTheme(): Theme {
|
||||||
|
if (typeof window === 'undefined') return 'dark';
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (stored === 'light' || stored === 'dark') return stored;
|
||||||
|
return 'dark';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTheme() {
|
||||||
|
const [theme, setTheme] = useState<Theme>(getInitialTheme);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.setAttribute('data-theme', theme);
|
||||||
|
localStorage.setItem(STORAGE_KEY, theme);
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
const toggle = useCallback(() => {
|
||||||
|
setTheme((prev) => (prev === 'dark' ? 'light' : 'dark'));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { theme, toggle } as const;
|
||||||
|
}
|
||||||
27
frontend/src/hooks/useToast.ts
Normal file
27
frontend/src/hooks/useToast.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
|
||||||
|
export interface Toast {
|
||||||
|
id: number;
|
||||||
|
message: string;
|
||||||
|
type: 'success' | 'error' | 'warning' | 'info';
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextId = 0;
|
||||||
|
|
||||||
|
export function useToast() {
|
||||||
|
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||||
|
|
||||||
|
const showToast = useCallback((message: string, type: Toast['type'] = 'info') => {
|
||||||
|
const id = nextId++;
|
||||||
|
setToasts((prev) => [...prev, { id, message, type }]);
|
||||||
|
setTimeout(() => {
|
||||||
|
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||||
|
}, 5000);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const removeToast = useCallback((id: number) => {
|
||||||
|
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { toasts, showToast, removeToast };
|
||||||
|
}
|
||||||
3
frontend/src/index.css
Normal file
3
frontend/src/index.css
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@import "./theme/tokens.css";
|
||||||
|
@import "./theme/base.css";
|
||||||
10
frontend/src/main.tsx
Normal file
10
frontend/src/main.tsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App.tsx'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
507
frontend/src/pages/Dashboard.tsx
Normal file
507
frontend/src/pages/Dashboard.tsx
Normal file
@ -0,0 +1,507 @@
|
|||||||
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
batchApi,
|
||||||
|
type DashboardResponse,
|
||||||
|
type DashboardStats,
|
||||||
|
type ExecutionStatisticsDto,
|
||||||
|
} from '../api/batchApi';
|
||||||
|
import { usePoller } from '../hooks/usePoller';
|
||||||
|
import { useToastContext } from '../contexts/ToastContext';
|
||||||
|
import StatusBadge from '../components/StatusBadge';
|
||||||
|
import EmptyState from '../components/EmptyState';
|
||||||
|
import LoadingSpinner from '../components/LoadingSpinner';
|
||||||
|
import BarChart from '../components/BarChart';
|
||||||
|
import { formatDateTime, calculateDuration } from '../utils/formatters';
|
||||||
|
|
||||||
|
const POLLING_INTERVAL = 5000;
|
||||||
|
|
||||||
|
interface StatCardProps {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
gradient: string;
|
||||||
|
to?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({ label, value, gradient, to }: StatCardProps) {
|
||||||
|
const content = (
|
||||||
|
<div
|
||||||
|
className={`${gradient} rounded-xl shadow-md p-6 text-white
|
||||||
|
hover:shadow-lg hover:-translate-y-0.5 transition-all cursor-pointer`}
|
||||||
|
>
|
||||||
|
<p className="text-3xl font-bold">{value}</p>
|
||||||
|
<p className="text-sm mt-1 opacity-90">{label}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (to) {
|
||||||
|
return <Link to={to} className="no-underline">{content}</Link>;
|
||||||
|
}
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Dashboard() {
|
||||||
|
const { showToast } = useToastContext();
|
||||||
|
const [dashboard, setDashboard] = useState<DashboardResponse | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
// Execute Job modal
|
||||||
|
const [showExecuteModal, setShowExecuteModal] = useState(false);
|
||||||
|
const [jobs, setJobs] = useState<string[]>([]);
|
||||||
|
const [selectedJob, setSelectedJob] = useState('');
|
||||||
|
const [executing, setExecuting] = useState(false);
|
||||||
|
const [startDate, setStartDate] = useState('');
|
||||||
|
const [stopDate, setStopDate] = useState('');
|
||||||
|
const [abandoning, setAbandoning] = useState(false);
|
||||||
|
const [statistics, setStatistics] = useState<ExecutionStatisticsDto | null>(null);
|
||||||
|
|
||||||
|
const loadStatistics = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await batchApi.getStatistics(30);
|
||||||
|
setStatistics(data);
|
||||||
|
} catch {
|
||||||
|
/* 통계 로드 실패는 무시 */
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadStatistics();
|
||||||
|
}, [loadStatistics]);
|
||||||
|
|
||||||
|
const loadDashboard = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await batchApi.getDashboard();
|
||||||
|
setDashboard(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Dashboard load failed:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
usePoller(loadDashboard, POLLING_INTERVAL);
|
||||||
|
|
||||||
|
const handleOpenExecuteModal = async () => {
|
||||||
|
try {
|
||||||
|
const jobList = await batchApi.getJobs();
|
||||||
|
setJobs(jobList);
|
||||||
|
setSelectedJob(jobList[0] ?? '');
|
||||||
|
setShowExecuteModal(true);
|
||||||
|
} catch (err) {
|
||||||
|
showToast('작업 목록을 불러올 수 없습니다.', 'error');
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExecuteJob = async () => {
|
||||||
|
if (!selectedJob) return;
|
||||||
|
setExecuting(true);
|
||||||
|
try {
|
||||||
|
const params: Record<string, string> = {};
|
||||||
|
if (startDate) params.startDate = startDate;
|
||||||
|
if (stopDate) params.stopDate = stopDate;
|
||||||
|
const result = await batchApi.executeJob(
|
||||||
|
selectedJob,
|
||||||
|
Object.keys(params).length > 0 ? params : undefined,
|
||||||
|
);
|
||||||
|
showToast(
|
||||||
|
result.message || `${selectedJob} 실행 요청 완료`,
|
||||||
|
'success',
|
||||||
|
);
|
||||||
|
setShowExecuteModal(false);
|
||||||
|
setStartDate('');
|
||||||
|
setStopDate('');
|
||||||
|
await loadDashboard();
|
||||||
|
} catch (err) {
|
||||||
|
showToast(`실행 실패: ${err instanceof Error ? err.message : '알 수 없는 오류'}`, 'error');
|
||||||
|
} finally {
|
||||||
|
setExecuting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAbandonAllStale = async () => {
|
||||||
|
setAbandoning(true);
|
||||||
|
try {
|
||||||
|
const result = await batchApi.abandonAllStale();
|
||||||
|
showToast(
|
||||||
|
result.message || `${result.abandonedCount ?? 0}건 강제 종료 완료`,
|
||||||
|
'success',
|
||||||
|
);
|
||||||
|
await loadDashboard();
|
||||||
|
} catch (err) {
|
||||||
|
showToast(
|
||||||
|
`강제 종료 실패: ${err instanceof Error ? err.message : '알 수 없는 오류'}`,
|
||||||
|
'error',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setAbandoning(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) return <LoadingSpinner />;
|
||||||
|
|
||||||
|
const stats: DashboardStats = dashboard?.stats ?? {
|
||||||
|
totalSchedules: 0,
|
||||||
|
activeSchedules: 0,
|
||||||
|
inactiveSchedules: 0,
|
||||||
|
totalJobs: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const runningJobs = dashboard?.runningJobs ?? [];
|
||||||
|
const recentExecutions = dashboard?.recentExecutions ?? [];
|
||||||
|
const recentFailures = dashboard?.recentFailures ?? [];
|
||||||
|
const staleExecutionCount = dashboard?.staleExecutionCount ?? 0;
|
||||||
|
const failureStats = dashboard?.failureStats ?? { last24h: 0, last7d: 0 };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||||
|
<h1 className="text-2xl font-bold text-wing-text">대시보드</h1>
|
||||||
|
<button
|
||||||
|
onClick={handleOpenExecuteModal}
|
||||||
|
className="px-4 py-2 bg-wing-accent text-white font-semibold rounded-lg shadow
|
||||||
|
hover:bg-wing-accent/80 hover:shadow-lg transition-all text-sm"
|
||||||
|
>
|
||||||
|
작업 즉시 실행
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* F1: Stale Execution Warning Banner */}
|
||||||
|
{staleExecutionCount > 0 && (
|
||||||
|
<div className="flex items-center justify-between bg-amber-100 border border-amber-300 rounded-xl px-5 py-3">
|
||||||
|
<span className="text-amber-800 font-medium text-sm">
|
||||||
|
{staleExecutionCount}건의 오래된 실행 중 작업이 있습니다
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={handleAbandonAllStale}
|
||||||
|
disabled={abandoning}
|
||||||
|
className="px-4 py-1.5 text-sm font-medium text-white bg-amber-600 rounded-lg
|
||||||
|
hover:bg-amber-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{abandoning ? '처리 중...' : '전체 강제 종료'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stats Cards */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4">
|
||||||
|
<StatCard
|
||||||
|
label="전체 스케줄"
|
||||||
|
value={stats.totalSchedules}
|
||||||
|
gradient="bg-gradient-to-br from-blue-500 to-blue-600"
|
||||||
|
to="/schedules"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="활성 스케줄"
|
||||||
|
value={stats.activeSchedules}
|
||||||
|
gradient="bg-gradient-to-br from-emerald-500 to-emerald-600"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="비활성 스케줄"
|
||||||
|
value={stats.inactiveSchedules}
|
||||||
|
gradient="bg-gradient-to-br from-amber-500 to-amber-600"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="전체 작업"
|
||||||
|
value={stats.totalJobs}
|
||||||
|
gradient="bg-gradient-to-br from-violet-500 to-violet-600"
|
||||||
|
to="/jobs"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="실패 (24h)"
|
||||||
|
value={failureStats.last24h}
|
||||||
|
gradient="bg-gradient-to-br from-red-500 to-red-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Navigation */}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Link
|
||||||
|
to="/jobs"
|
||||||
|
className="px-3 py-1.5 bg-wing-card-alpha text-wing-text rounded-lg text-sm hover:bg-wing-hover transition-colors no-underline"
|
||||||
|
>
|
||||||
|
작업 관리
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/executions"
|
||||||
|
className="px-3 py-1.5 bg-wing-card-alpha text-wing-text rounded-lg text-sm hover:bg-wing-hover transition-colors no-underline"
|
||||||
|
>
|
||||||
|
실행 이력
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/schedules"
|
||||||
|
className="px-3 py-1.5 bg-wing-card-alpha text-wing-text rounded-lg text-sm hover:bg-wing-hover transition-colors no-underline"
|
||||||
|
>
|
||||||
|
스케줄 관리
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/schedule-timeline"
|
||||||
|
className="px-3 py-1.5 bg-wing-card-alpha text-wing-text rounded-lg text-sm hover:bg-wing-hover transition-colors no-underline"
|
||||||
|
>
|
||||||
|
타임라인
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Running Jobs */}
|
||||||
|
<section className="bg-wing-surface rounded-xl shadow-md p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-wing-text mb-4">
|
||||||
|
실행 중인 작업
|
||||||
|
{runningJobs.length > 0 && (
|
||||||
|
<span className="ml-2 text-sm font-normal text-wing-accent">
|
||||||
|
({runningJobs.length}건)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</h2>
|
||||||
|
{runningJobs.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon="💤"
|
||||||
|
message="현재 실행 중인 작업이 없습니다."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<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">작업명</th>
|
||||||
|
<th className="pb-2 font-medium">실행 ID</th>
|
||||||
|
<th className="pb-2 font-medium">시작 시간</th>
|
||||||
|
<th className="pb-2 font-medium">상태</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{runningJobs.map((job) => (
|
||||||
|
<tr key={job.executionId} className="border-b border-wing-border/50">
|
||||||
|
<td className="py-3 font-medium text-wing-text">{job.jobName}</td>
|
||||||
|
<td className="py-3 text-wing-muted">#{job.executionId}</td>
|
||||||
|
<td className="py-3 text-wing-muted">{formatDateTime(job.startTime)}</td>
|
||||||
|
<td className="py-3">
|
||||||
|
<StatusBadge status={job.status} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Recent Executions */}
|
||||||
|
<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="/executions"
|
||||||
|
className="text-sm text-wing-accent hover:text-wing-accent no-underline"
|
||||||
|
>
|
||||||
|
전체 보기 →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
{recentExecutions.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon="📋"
|
||||||
|
message="실행 이력이 없습니다."
|
||||||
|
sub="작업을 실행하면 여기에 표시됩니다."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<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>
|
||||||
|
<th className="pb-2 font-medium">상태</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{recentExecutions.slice(0, 5).map((exec) => (
|
||||||
|
<tr key={exec.executionId} className="border-b border-wing-border/50">
|
||||||
|
<td className="py-3">
|
||||||
|
<Link
|
||||||
|
to={`/executions/${exec.executionId}`}
|
||||||
|
className="text-wing-accent hover:text-wing-accent no-underline font-medium"
|
||||||
|
>
|
||||||
|
#{exec.executionId}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 text-wing-text">{exec.jobName}</td>
|
||||||
|
<td className="py-3 text-wing-muted">{formatDateTime(exec.startTime)}</td>
|
||||||
|
<td className="py-3 text-wing-muted">{formatDateTime(exec.endTime)}</td>
|
||||||
|
<td className="py-3 text-wing-muted">
|
||||||
|
{calculateDuration(exec.startTime, exec.endTime)}
|
||||||
|
</td>
|
||||||
|
<td className="py-3">
|
||||||
|
<StatusBadge status={exec.status} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* F6: Recent Failures */}
|
||||||
|
{recentFailures.length > 0 && (
|
||||||
|
<section className="bg-wing-surface rounded-xl shadow-md p-6 border border-red-200">
|
||||||
|
<h2 className="text-lg font-semibold text-red-700 mb-4">
|
||||||
|
최근 실패 이력
|
||||||
|
<span className="ml-2 text-sm font-normal text-red-500">
|
||||||
|
({recentFailures.length}건)
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-red-200 text-left text-red-500">
|
||||||
|
<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>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{recentFailures.map((fail) => (
|
||||||
|
<tr key={fail.executionId} className="border-b border-red-100">
|
||||||
|
<td className="py-3">
|
||||||
|
<Link
|
||||||
|
to={`/executions/${fail.executionId}`}
|
||||||
|
className="text-red-600 hover:text-red-800 no-underline font-medium"
|
||||||
|
>
|
||||||
|
#{fail.executionId}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 text-wing-text">{fail.jobName}</td>
|
||||||
|
<td className="py-3 text-wing-muted">{formatDateTime(fail.startTime)}</td>
|
||||||
|
<td className="py-3 text-wing-muted" title={fail.exitMessage ?? ''}>
|
||||||
|
{fail.exitMessage
|
||||||
|
? fail.exitMessage.length > 50
|
||||||
|
? `${fail.exitMessage.slice(0, 50)}...`
|
||||||
|
: fail.exitMessage
|
||||||
|
: '-'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* F8: Execution Statistics Chart */}
|
||||||
|
{statistics && statistics.dailyStats.length > 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">
|
||||||
|
실행 통계 (최근 30일)
|
||||||
|
</h2>
|
||||||
|
<div className="flex gap-4 text-xs text-wing-muted">
|
||||||
|
<span>
|
||||||
|
전체 <strong className="text-wing-text">{statistics.totalExecutions}</strong>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
성공 <strong className="text-emerald-600">{statistics.totalSuccess}</strong>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
실패 <strong className="text-red-600">{statistics.totalFailed}</strong>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<BarChart
|
||||||
|
data={statistics.dailyStats.map((d) => ({
|
||||||
|
label: d.date.slice(5),
|
||||||
|
values: [
|
||||||
|
{ color: 'green', value: d.successCount },
|
||||||
|
{ color: 'red', value: d.failedCount },
|
||||||
|
{ color: 'gray', value: d.otherCount },
|
||||||
|
],
|
||||||
|
}))}
|
||||||
|
height={180}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-4 mt-3 text-xs text-wing-muted justify-end">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span className="w-3 h-3 rounded-sm bg-emerald-500" /> 성공
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span className="w-3 h-3 rounded-sm bg-red-500" /> 실패
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span className="w-3 h-3 rounded-sm bg-gray-400" /> 기타
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Execute Job Modal */}
|
||||||
|
{showExecuteModal && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-wing-overlay"
|
||||||
|
onClick={() => setShowExecuteModal(false)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="bg-wing-surface rounded-xl shadow-2xl p-6 max-w-md w-full mx-4"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h3 className="text-lg font-semibold text-wing-text mb-4">작업 즉시 실행</h3>
|
||||||
|
<label className="block text-sm font-medium text-wing-text mb-2">
|
||||||
|
실행할 작업 선택
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={selectedJob}
|
||||||
|
onChange={(e) => setSelectedJob(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-wing-border rounded-lg text-sm
|
||||||
|
focus:ring-2 focus:ring-wing-accent focus:border-wing-accent mb-4"
|
||||||
|
>
|
||||||
|
{jobs.map((job) => (
|
||||||
|
<option key={job} value={job}>{job}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<label className="block text-sm font-medium text-wing-text mb-1">
|
||||||
|
시작일시 <span className="text-wing-muted font-normal">(선택)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={startDate}
|
||||||
|
onChange={(e) => setStartDate(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-wing-border rounded-lg text-sm
|
||||||
|
focus:ring-2 focus:ring-wing-accent focus:border-wing-accent mb-3"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label className="block text-sm font-medium text-wing-text mb-1">
|
||||||
|
종료일시 <span className="text-wing-muted font-normal">(선택)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={stopDate}
|
||||||
|
onChange={(e) => setStopDate(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-wing-border rounded-lg text-sm
|
||||||
|
focus:ring-2 focus:ring-wing-accent focus:border-wing-accent mb-4"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowExecuteModal(false)}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-wing-text bg-wing-card rounded-lg
|
||||||
|
hover:bg-wing-hover transition-colors"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleExecuteJob}
|
||||||
|
disabled={executing || !selectedJob}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-white bg-wing-accent rounded-lg
|
||||||
|
hover:bg-wing-accent/80 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{executing ? '실행 중...' : '실행'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
335
frontend/src/pages/ExecutionDetail.tsx
Normal file
335
frontend/src/pages/ExecutionDetail.tsx
Normal file
@ -0,0 +1,335 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { useParams, useSearchParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { batchApi, type JobExecutionDetailDto, type StepExecutionDto } from '../api/batchApi';
|
||||||
|
import { formatDateTime, formatDuration, calculateDuration } from '../utils/formatters';
|
||||||
|
import { usePoller } from '../hooks/usePoller';
|
||||||
|
import StatusBadge from '../components/StatusBadge';
|
||||||
|
import EmptyState from '../components/EmptyState';
|
||||||
|
import LoadingSpinner from '../components/LoadingSpinner';
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StepCardProps {
|
||||||
|
step: StepExecutionDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StepCard({ step }: StepCardProps) {
|
||||||
|
const stats = [
|
||||||
|
{ label: '읽기', value: step.readCount },
|
||||||
|
{ label: '쓰기', value: step.writeCount },
|
||||||
|
{ label: '커밋', value: step.commitCount },
|
||||||
|
{ label: '롤백', value: step.rollbackCount },
|
||||||
|
{ label: '읽기 건너뜀', value: step.readSkipCount },
|
||||||
|
{ label: '처리 건너뜀', value: step.processSkipCount },
|
||||||
|
{ label: '쓰기 건너뜀', value: step.writeSkipCount },
|
||||||
|
{ label: '필터', value: step.filterCount },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-wing-surface rounded-xl shadow-md p-6">
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h3 className="text-base font-semibold text-wing-text">
|
||||||
|
{step.stepName}
|
||||||
|
</h3>
|
||||||
|
<StatusBadge status={step.status} />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-wing-muted">
|
||||||
|
{step.duration != null
|
||||||
|
? formatDuration(step.duration)
|
||||||
|
: calculateDuration(step.startTime, step.endTime)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-sm mb-3">
|
||||||
|
<div className="text-wing-muted">
|
||||||
|
시작: <span className="text-wing-text">{formatDateTime(step.startTime)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-wing-muted">
|
||||||
|
종료: <span className="text-wing-text">{formatDateTime(step.endTime)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||||
|
{stats.map(({ label, value }) => (
|
||||||
|
<div
|
||||||
|
key={label}
|
||||||
|
className="rounded-lg bg-wing-card px-3 py-2 text-center"
|
||||||
|
>
|
||||||
|
<p className="text-lg font-bold text-wing-text">
|
||||||
|
{value.toLocaleString()}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-wing-muted">{label}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* API 호출 정보 */}
|
||||||
|
{step.apiCallInfo && (
|
||||||
|
<div className="mt-4 rounded-lg bg-blue-50 border border-blue-200 p-3">
|
||||||
|
<p className="text-xs font-medium text-blue-700 mb-2">API 호출 정보</p>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 text-xs">
|
||||||
|
<div>
|
||||||
|
<span className="text-blue-500">URL:</span>{' '}
|
||||||
|
<span className="text-blue-900 font-mono break-all">{step.apiCallInfo.apiUrl}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-blue-500">Method:</span>{' '}
|
||||||
|
<span className="text-blue-900 font-semibold">{step.apiCallInfo.method}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-blue-500">호출:</span>{' '}
|
||||||
|
<span className="text-blue-900">{step.apiCallInfo.completedCalls} / {step.apiCallInfo.totalCalls}</span>
|
||||||
|
</div>
|
||||||
|
{step.apiCallInfo.lastCallTime && (
|
||||||
|
<div>
|
||||||
|
<span className="text-blue-500">최종:</span>{' '}
|
||||||
|
<span className="text-blue-900">{step.apiCallInfo.lastCallTime}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step.exitMessage && (
|
||||||
|
<div className="mt-4 rounded-lg bg-red-50 border border-red-200 p-3">
|
||||||
|
<p className="text-xs font-medium text-red-700 mb-1">Exit Message</p>
|
||||||
|
<p className="text-xs text-red-600 whitespace-pre-wrap break-words">
|
||||||
|
{step.exitMessage}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ExecutionDetail() {
|
||||||
|
const { id: paramId } = useParams<{ id: string }>();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const executionId = paramId
|
||||||
|
? Number(paramId)
|
||||||
|
: Number(searchParams.get('id'));
|
||||||
|
|
||||||
|
const [detail, setDetail] = useState<JobExecutionDetailDto | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const isRunning = detail
|
||||||
|
? detail.status === 'STARTED' || detail.status === 'STARTING'
|
||||||
|
: false;
|
||||||
|
|
||||||
|
const loadDetail = useCallback(async () => {
|
||||||
|
if (!executionId || isNaN(executionId)) {
|
||||||
|
setError('유효하지 않은 실행 ID입니다.');
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const data = await batchApi.getExecutionDetail(executionId);
|
||||||
|
setDetail(data);
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
setError(
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: '실행 상세 정보를 불러오지 못했습니다.',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [executionId]);
|
||||||
|
|
||||||
|
/* 실행중인 경우 5초 폴링, 완료 후에는 1회 로드로 충분하지만 폴링 유지 */
|
||||||
|
usePoller(loadDetail, isRunning ? POLLING_INTERVAL_MS : 30_000, [
|
||||||
|
executionId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (loading) return <LoadingSpinner />;
|
||||||
|
|
||||||
|
if (error || !detail) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/executions')}
|
||||||
|
className="inline-flex items-center gap-1 text-sm text-wing-muted hover:text-wing-text transition-colors"
|
||||||
|
>
|
||||||
|
<span>←</span> 목록으로
|
||||||
|
</button>
|
||||||
|
<EmptyState
|
||||||
|
icon="⚠"
|
||||||
|
message={error || '실행 정보를 찾을 수 없습니다.'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const jobParams = Object.entries(detail.jobParameters);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 상단 내비게이션 */}
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
className="inline-flex items-center gap-1 text-sm text-wing-muted hover:text-wing-text transition-colors"
|
||||||
|
>
|
||||||
|
<span>←</span> 목록으로
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Job 기본 정보 */}
|
||||||
|
<div className="bg-wing-surface rounded-xl shadow-md p-6">
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-wing-text">
|
||||||
|
실행 #{detail.executionId}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-wing-muted">
|
||||||
|
{detail.jobName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<StatusBadge status={detail.status} className="text-sm" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 text-sm">
|
||||||
|
<InfoItem label="시작시간" value={formatDateTime(detail.startTime)} />
|
||||||
|
<InfoItem label="종료시간" value={formatDateTime(detail.endTime)} />
|
||||||
|
<InfoItem
|
||||||
|
label="소요시간"
|
||||||
|
value={
|
||||||
|
detail.duration != null
|
||||||
|
? formatDuration(detail.duration)
|
||||||
|
: calculateDuration(detail.startTime, detail.endTime)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<InfoItem label="Exit Code" value={detail.exitCode} />
|
||||||
|
{detail.exitMessage && (
|
||||||
|
<div className="sm:col-span-2 lg:col-span-3">
|
||||||
|
<InfoItem label="Exit Message" value={detail.exitMessage} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 실행 통계 카드 4개 */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<StatCard
|
||||||
|
label="읽기 (Read)"
|
||||||
|
value={detail.readCount}
|
||||||
|
gradient="bg-gradient-to-br from-blue-500 to-blue-600"
|
||||||
|
icon="📥"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="쓰기 (Write)"
|
||||||
|
value={detail.writeCount}
|
||||||
|
gradient="bg-gradient-to-br from-emerald-500 to-emerald-600"
|
||||||
|
icon="📤"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="건너뜀 (Skip)"
|
||||||
|
value={detail.skipCount}
|
||||||
|
gradient="bg-gradient-to-br from-amber-500 to-amber-600"
|
||||||
|
icon="⏭"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="필터 (Filter)"
|
||||||
|
value={detail.filterCount}
|
||||||
|
gradient="bg-gradient-to-br from-purple-500 to-purple-600"
|
||||||
|
icon="🔍"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Job Parameters */}
|
||||||
|
{jobParams.length > 0 && (
|
||||||
|
<div className="bg-wing-surface rounded-xl shadow-md p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-wing-text mb-4">
|
||||||
|
Job Parameters
|
||||||
|
</h2>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm text-left">
|
||||||
|
<thead className="bg-wing-card text-xs uppercase text-wing-muted">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 font-medium">Key</th>
|
||||||
|
<th className="px-6 py-3 font-medium">Value</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-wing-border/50">
|
||||||
|
{jobParams.map(([key, value]) => (
|
||||||
|
<tr
|
||||||
|
key={key}
|
||||||
|
className="hover:bg-wing-hover transition-colors"
|
||||||
|
>
|
||||||
|
<td className="px-6 py-3 font-mono text-wing-text">
|
||||||
|
{key}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-3 text-wing-muted break-all">
|
||||||
|
{value}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 실행 정보 */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-wing-text mb-4">
|
||||||
|
Step 실행 정보
|
||||||
|
<span className="ml-2 text-sm font-normal text-wing-muted">
|
||||||
|
({detail.stepExecutions.length}개)
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
{detail.stepExecutions.length === 0 ? (
|
||||||
|
<EmptyState message="Step 실행 정보가 없습니다." />
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{detail.stepExecutions.map((step) => (
|
||||||
|
<StepCard
|
||||||
|
key={step.stepExecutionId}
|
||||||
|
step={step}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
587
frontend/src/pages/Executions.tsx
Normal file
587
frontend/src/pages/Executions.tsx
Normal file
@ -0,0 +1,587 @@
|
|||||||
|
import { useState, useMemo, useCallback } from 'react';
|
||||||
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
import { batchApi, type JobExecutionDto, type ExecutionSearchResponse } from '../api/batchApi';
|
||||||
|
import { formatDateTime, calculateDuration } from '../utils/formatters';
|
||||||
|
import { usePoller } from '../hooks/usePoller';
|
||||||
|
import { useToastContext } from '../contexts/ToastContext';
|
||||||
|
import StatusBadge from '../components/StatusBadge';
|
||||||
|
import ConfirmModal from '../components/ConfirmModal';
|
||||||
|
import InfoModal from '../components/InfoModal';
|
||||||
|
import EmptyState from '../components/EmptyState';
|
||||||
|
import LoadingSpinner from '../components/LoadingSpinner';
|
||||||
|
|
||||||
|
type StatusFilter = 'ALL' | 'COMPLETED' | 'FAILED' | 'STARTED' | 'STOPPED';
|
||||||
|
|
||||||
|
const STATUS_FILTERS: { value: StatusFilter; label: string }[] = [
|
||||||
|
{ value: 'ALL', label: '전체' },
|
||||||
|
{ value: 'COMPLETED', label: '완료' },
|
||||||
|
{ value: 'FAILED', label: '실패' },
|
||||||
|
{ value: 'STARTED', label: '실행중' },
|
||||||
|
{ value: 'STOPPED', label: '중지됨' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const POLLING_INTERVAL_MS = 5000;
|
||||||
|
const RECENT_LIMIT = 50;
|
||||||
|
const PAGE_SIZE = 50;
|
||||||
|
|
||||||
|
export default function Executions() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
|
const jobFromQuery = searchParams.get('job') || '';
|
||||||
|
|
||||||
|
const [jobs, setJobs] = useState<string[]>([]);
|
||||||
|
const [executions, setExecutions] = useState<JobExecutionDto[]>([]);
|
||||||
|
const [selectedJobs, setSelectedJobs] = useState<string[]>(jobFromQuery ? [jobFromQuery] : []);
|
||||||
|
const [jobDropdownOpen, setJobDropdownOpen] = useState(false);
|
||||||
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>('ALL');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [stopTarget, setStopTarget] = useState<JobExecutionDto | null>(null);
|
||||||
|
|
||||||
|
// F1: 강제 종료
|
||||||
|
const [abandonTarget, setAbandonTarget] = useState<JobExecutionDto | null>(null);
|
||||||
|
|
||||||
|
// F4: 날짜 범위 필터 + 페이지네이션
|
||||||
|
const [startDate, setStartDate] = useState('');
|
||||||
|
const [endDate, setEndDate] = useState('');
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
const [totalPages, setTotalPages] = useState(0);
|
||||||
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
|
const [useSearch, setUseSearch] = useState(false);
|
||||||
|
|
||||||
|
// F9: 실패 로그 뷰어
|
||||||
|
const [failLogTarget, setFailLogTarget] = useState<JobExecutionDto | null>(null);
|
||||||
|
|
||||||
|
const { showToast } = useToastContext();
|
||||||
|
|
||||||
|
const loadJobs = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await batchApi.getJobs();
|
||||||
|
setJobs(data);
|
||||||
|
} catch {
|
||||||
|
/* Job 목록 로드 실패는 무시 */
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadSearchExecutions = useCallback(async (targetPage: number) => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const params: {
|
||||||
|
jobNames?: string[];
|
||||||
|
status?: string;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
page?: number;
|
||||||
|
size?: number;
|
||||||
|
} = {
|
||||||
|
page: targetPage,
|
||||||
|
size: PAGE_SIZE,
|
||||||
|
};
|
||||||
|
if (selectedJobs.length > 0) params.jobNames = selectedJobs;
|
||||||
|
if (statusFilter !== 'ALL') params.status = statusFilter;
|
||||||
|
if (startDate) params.startDate = `${startDate}T00:00:00`;
|
||||||
|
if (endDate) params.endDate = `${endDate}T23:59:59`;
|
||||||
|
|
||||||
|
const data: ExecutionSearchResponse = await batchApi.searchExecutions(params);
|
||||||
|
setExecutions(data.executions);
|
||||||
|
setTotalPages(data.totalPages);
|
||||||
|
setTotalCount(data.totalCount);
|
||||||
|
setPage(data.page);
|
||||||
|
} catch {
|
||||||
|
setExecutions([]);
|
||||||
|
setTotalPages(0);
|
||||||
|
setTotalCount(0);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [selectedJobs, statusFilter, startDate, endDate]);
|
||||||
|
|
||||||
|
const loadExecutions = useCallback(async () => {
|
||||||
|
// 검색 모드에서는 폴링하지 않음 (검색 버튼 클릭 시에만 1회 조회)
|
||||||
|
if (useSearch) return;
|
||||||
|
try {
|
||||||
|
let data: JobExecutionDto[];
|
||||||
|
if (selectedJobs.length === 1) {
|
||||||
|
data = await batchApi.getJobExecutions(selectedJobs[0]);
|
||||||
|
} else if (selectedJobs.length > 1) {
|
||||||
|
// 복수 Job 선택 시 search API 사용
|
||||||
|
const result = await batchApi.searchExecutions({
|
||||||
|
jobNames: selectedJobs, size: RECENT_LIMIT,
|
||||||
|
});
|
||||||
|
data = result.executions;
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
data = await batchApi.getRecentExecutions(RECENT_LIMIT);
|
||||||
|
} catch {
|
||||||
|
data = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setExecutions(data);
|
||||||
|
} catch {
|
||||||
|
setExecutions([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [selectedJobs, useSearch, page, loadSearchExecutions]);
|
||||||
|
|
||||||
|
/* 마운트 시 Job 목록 1회 로드 */
|
||||||
|
usePoller(loadJobs, 60_000, []);
|
||||||
|
|
||||||
|
/* 실행 이력 5초 폴링 */
|
||||||
|
usePoller(loadExecutions, POLLING_INTERVAL_MS, [selectedJobs, useSearch, page]);
|
||||||
|
|
||||||
|
const filteredExecutions = useMemo(() => {
|
||||||
|
// 검색 모드에서는 서버 필터링 사용
|
||||||
|
if (useSearch) return executions;
|
||||||
|
if (statusFilter === 'ALL') return executions;
|
||||||
|
return executions.filter((e) => e.status === statusFilter);
|
||||||
|
}, [executions, statusFilter, useSearch]);
|
||||||
|
|
||||||
|
const toggleJob = (jobName: string) => {
|
||||||
|
setSelectedJobs((prev) => {
|
||||||
|
const next = prev.includes(jobName)
|
||||||
|
? prev.filter((j) => j !== jobName)
|
||||||
|
: [...prev, jobName];
|
||||||
|
if (next.length === 1) {
|
||||||
|
setSearchParams({ job: next[0] });
|
||||||
|
} else {
|
||||||
|
setSearchParams({});
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setLoading(true);
|
||||||
|
if (useSearch) {
|
||||||
|
setPage(0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearSelectedJobs = () => {
|
||||||
|
setSelectedJobs([]);
|
||||||
|
setSearchParams({});
|
||||||
|
setLoading(true);
|
||||||
|
if (useSearch) {
|
||||||
|
setPage(0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStop = async () => {
|
||||||
|
if (!stopTarget) return;
|
||||||
|
try {
|
||||||
|
const result = await batchApi.stopExecution(stopTarget.executionId);
|
||||||
|
showToast(result.message || '실행이 중지되었습니다.', 'success');
|
||||||
|
} catch (err) {
|
||||||
|
showToast(
|
||||||
|
err instanceof Error ? err.message : '중지 요청에 실패했습니다.',
|
||||||
|
'error',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setStopTarget(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// F1: 강제 종료 핸들러
|
||||||
|
const handleAbandon = async () => {
|
||||||
|
if (!abandonTarget) return;
|
||||||
|
try {
|
||||||
|
const result = await batchApi.abandonExecution(abandonTarget.executionId);
|
||||||
|
showToast(result.message || '실행이 강제 종료되었습니다.', 'success');
|
||||||
|
} catch (err) {
|
||||||
|
showToast(
|
||||||
|
err instanceof Error ? err.message : '강제 종료 요청에 실패했습니다.',
|
||||||
|
'error',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setAbandonTarget(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// F4: 검색 핸들러
|
||||||
|
const handleSearch = async () => {
|
||||||
|
setUseSearch(true);
|
||||||
|
setPage(0);
|
||||||
|
await loadSearchExecutions(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
// F4: 초기화 핸들러
|
||||||
|
const handleResetSearch = () => {
|
||||||
|
setUseSearch(false);
|
||||||
|
setStartDate('');
|
||||||
|
setEndDate('');
|
||||||
|
setPage(0);
|
||||||
|
setTotalPages(0);
|
||||||
|
setTotalCount(0);
|
||||||
|
setLoading(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// F4: 페이지 이동 핸들러
|
||||||
|
const handlePageChange = (newPage: number) => {
|
||||||
|
if (newPage < 0 || newPage >= totalPages) return;
|
||||||
|
setPage(newPage);
|
||||||
|
loadSearchExecutions(newPage);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isRunning = (status: string) =>
|
||||||
|
status === 'STARTED' || status === 'STARTING';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-wing-text">실행 이력</h1>
|
||||||
|
<p className="mt-1 text-sm text-wing-muted">
|
||||||
|
배치 작업 실행 이력을 조회하고 관리합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 필터 영역 */}
|
||||||
|
<div className="bg-wing-surface rounded-xl shadow-md p-6">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Job 멀티 선택 */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<label className="text-sm font-medium text-wing-text shrink-0">
|
||||||
|
작업 선택
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setJobDropdownOpen((v) => !v)}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg border border-wing-border bg-wing-surface hover:bg-wing-hover transition-colors"
|
||||||
|
>
|
||||||
|
{selectedJobs.length === 0
|
||||||
|
? `전체 (최근 ${RECENT_LIMIT}건)`
|
||||||
|
: `${selectedJobs.length}개 선택됨`}
|
||||||
|
<svg className={`w-4 h-4 text-wing-muted transition-transform ${jobDropdownOpen ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>
|
||||||
|
</button>
|
||||||
|
{jobDropdownOpen && (
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 z-10" onClick={() => setJobDropdownOpen(false)} />
|
||||||
|
<div className="absolute z-20 mt-1 w-72 max-h-60 overflow-y-auto bg-wing-surface border border-wing-border rounded-lg shadow-lg">
|
||||||
|
{jobs.map((job) => (
|
||||||
|
<label
|
||||||
|
key={job}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 text-sm cursor-pointer hover:bg-wing-hover transition-colors"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedJobs.includes(job)}
|
||||||
|
onChange={() => toggleJob(job)}
|
||||||
|
className="rounded border-wing-border text-wing-accent focus:ring-wing-accent"
|
||||||
|
/>
|
||||||
|
<span className="text-wing-text truncate">{job}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{selectedJobs.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={clearSelectedJobs}
|
||||||
|
className="text-xs text-wing-muted hover:text-wing-accent transition-colors"
|
||||||
|
>
|
||||||
|
초기화
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* 선택된 Job 칩 */}
|
||||||
|
{selectedJobs.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{selectedJobs.map((job) => (
|
||||||
|
<span
|
||||||
|
key={job}
|
||||||
|
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-medium bg-wing-accent/15 text-wing-accent rounded-full"
|
||||||
|
>
|
||||||
|
{job}
|
||||||
|
<button
|
||||||
|
onClick={() => toggleJob(job)}
|
||||||
|
className="hover:text-wing-text transition-colors"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 상태 필터 버튼 그룹 */}
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{STATUS_FILTERS.map(({ value, label }) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
onClick={() => {
|
||||||
|
setStatusFilter(value);
|
||||||
|
if (useSearch) {
|
||||||
|
setPage(0);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${
|
||||||
|
statusFilter === value
|
||||||
|
? 'bg-wing-accent text-white shadow-sm'
|
||||||
|
: 'bg-wing-card text-wing-muted hover:bg-wing-hover'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* F4: 날짜 범위 필터 */}
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center mt-4 pt-4 border-t border-wing-border/50">
|
||||||
|
<label className="text-sm font-medium text-wing-text shrink-0">
|
||||||
|
기간 검색
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={startDate}
|
||||||
|
onChange={(e) => setStartDate(e.target.value)}
|
||||||
|
className="block rounded-lg border border-wing-border bg-wing-surface px-3 py-2 text-sm shadow-sm focus:border-wing-accent focus:ring-1 focus:ring-wing-accent"
|
||||||
|
/>
|
||||||
|
<span className="text-wing-muted text-sm">~</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={endDate}
|
||||||
|
onChange={(e) => setEndDate(e.target.value)}
|
||||||
|
className="block rounded-lg border border-wing-border bg-wing-surface px-3 py-2 text-sm shadow-sm focus:border-wing-accent focus:ring-1 focus:ring-wing-accent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleSearch}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-white bg-wing-accent rounded-lg hover:bg-wing-accent/80 transition-colors shadow-sm"
|
||||||
|
>
|
||||||
|
검색
|
||||||
|
</button>
|
||||||
|
{useSearch && (
|
||||||
|
<button
|
||||||
|
onClick={handleResetSearch}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-wing-text bg-wing-card rounded-lg hover:bg-wing-hover transition-colors"
|
||||||
|
>
|
||||||
|
초기화
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 실행 이력 테이블 */}
|
||||||
|
<div className="bg-wing-surface rounded-xl shadow-md overflow-hidden">
|
||||||
|
{loading ? (
|
||||||
|
<LoadingSpinner />
|
||||||
|
) : filteredExecutions.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
message="실행 이력이 없습니다."
|
||||||
|
sub={
|
||||||
|
statusFilter !== 'ALL'
|
||||||
|
? '다른 상태 필터를 선택해 보세요.'
|
||||||
|
: selectedJobs.length > 0
|
||||||
|
? '선택한 작업의 실행 이력이 없습니다.'
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm text-left">
|
||||||
|
<thead className="bg-wing-card text-xs uppercase text-wing-muted">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 font-medium">실행 ID</th>
|
||||||
|
<th className="px-6 py-3 font-medium">작업명</th>
|
||||||
|
<th className="px-6 py-3 font-medium">상태</th>
|
||||||
|
<th className="px-6 py-3 font-medium">시작시간</th>
|
||||||
|
<th className="px-6 py-3 font-medium">종료시간</th>
|
||||||
|
<th className="px-6 py-3 font-medium">소요시간</th>
|
||||||
|
<th className="px-6 py-3 font-medium text-right">
|
||||||
|
액션
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-wing-border/50">
|
||||||
|
{filteredExecutions.map((exec) => (
|
||||||
|
<tr
|
||||||
|
key={exec.executionId}
|
||||||
|
className="hover:bg-wing-hover transition-colors"
|
||||||
|
>
|
||||||
|
<td className="px-6 py-4 font-mono text-wing-text">
|
||||||
|
#{exec.executionId}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-wing-text">
|
||||||
|
{exec.jobName}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
{/* F9: FAILED 상태 클릭 시 실패 로그 모달 */}
|
||||||
|
{exec.status === 'FAILED' ? (
|
||||||
|
<button
|
||||||
|
onClick={() => setFailLogTarget(exec)}
|
||||||
|
className="cursor-pointer"
|
||||||
|
title="클릭하여 실패 로그 확인"
|
||||||
|
>
|
||||||
|
<StatusBadge status={exec.status} />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<StatusBadge status={exec.status} />
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-wing-muted whitespace-nowrap">
|
||||||
|
{formatDateTime(exec.startTime)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-wing-muted whitespace-nowrap">
|
||||||
|
{formatDateTime(exec.endTime)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-wing-muted whitespace-nowrap">
|
||||||
|
{calculateDuration(
|
||||||
|
exec.startTime,
|
||||||
|
exec.endTime,
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-right">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
{isRunning(exec.status) ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
setStopTarget(exec)
|
||||||
|
}
|
||||||
|
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-red-700 bg-red-50 rounded-lg hover:bg-red-100 transition-colors"
|
||||||
|
>
|
||||||
|
중지
|
||||||
|
</button>
|
||||||
|
{/* F1: 강제 종료 버튼 */}
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
setAbandonTarget(exec)
|
||||||
|
}
|
||||||
|
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-amber-700 bg-amber-50 rounded-lg hover:bg-amber-100 transition-colors"
|
||||||
|
>
|
||||||
|
강제 종료
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
navigate(
|
||||||
|
`/executions/${exec.executionId}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-wing-accent bg-wing-accent/10 rounded-lg hover:bg-wing-accent/15 transition-colors"
|
||||||
|
>
|
||||||
|
상세
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 결과 건수 표시 + F4: 페이지네이션 */}
|
||||||
|
{!loading && filteredExecutions.length > 0 && (
|
||||||
|
<div className="px-6 py-3 bg-wing-card border-t border-wing-border/50 flex items-center justify-between">
|
||||||
|
<div className="text-xs text-wing-muted">
|
||||||
|
{useSearch ? (
|
||||||
|
<>총 {totalCount}건</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
총 {filteredExecutions.length}건
|
||||||
|
{statusFilter !== 'ALL' && (
|
||||||
|
<span className="ml-1">
|
||||||
|
(전체 {executions.length}건 중)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* F4: 페이지네이션 UI */}
|
||||||
|
{useSearch && totalPages > 1 && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handlePageChange(page - 1)}
|
||||||
|
disabled={page === 0}
|
||||||
|
className="px-3 py-1.5 text-xs font-medium rounded-lg transition-colors disabled:opacity-40 disabled:cursor-not-allowed bg-wing-card text-wing-muted hover:bg-wing-hover"
|
||||||
|
>
|
||||||
|
이전
|
||||||
|
</button>
|
||||||
|
<span className="text-xs text-wing-muted">
|
||||||
|
{page + 1} / {totalPages}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => handlePageChange(page + 1)}
|
||||||
|
disabled={page >= totalPages - 1}
|
||||||
|
className="px-3 py-1.5 text-xs font-medium rounded-lg transition-colors disabled:opacity-40 disabled:cursor-not-allowed bg-wing-card text-wing-muted hover:bg-wing-hover"
|
||||||
|
>
|
||||||
|
다음
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 중지 확인 모달 */}
|
||||||
|
<ConfirmModal
|
||||||
|
open={stopTarget !== null}
|
||||||
|
title="실행 중지"
|
||||||
|
message={
|
||||||
|
stopTarget
|
||||||
|
? `실행 #${stopTarget.executionId} (${stopTarget.jobName})을 중지하시겠습니까?`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
confirmLabel="중지"
|
||||||
|
confirmColor="bg-red-600 hover:bg-red-700"
|
||||||
|
onConfirm={handleStop}
|
||||||
|
onCancel={() => setStopTarget(null)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* F1: 강제 종료 확인 모달 */}
|
||||||
|
<ConfirmModal
|
||||||
|
open={abandonTarget !== null}
|
||||||
|
title="강제 종료"
|
||||||
|
message={
|
||||||
|
abandonTarget
|
||||||
|
? `실행 #${abandonTarget.executionId} (${abandonTarget.jobName})을 강제 종료하시겠습니까?\n\n강제 종료는 실행 상태를 ABANDONED로 변경합니다.`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
confirmLabel="강제 종료"
|
||||||
|
confirmColor="bg-amber-600 hover:bg-amber-700"
|
||||||
|
onConfirm={handleAbandon}
|
||||||
|
onCancel={() => setAbandonTarget(null)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* F9: 실패 로그 뷰어 모달 */}
|
||||||
|
<InfoModal
|
||||||
|
open={failLogTarget !== null}
|
||||||
|
title={
|
||||||
|
failLogTarget
|
||||||
|
? `실패 로그 - #${failLogTarget.executionId} (${failLogTarget.jobName})`
|
||||||
|
: '실패 로그'
|
||||||
|
}
|
||||||
|
onClose={() => setFailLogTarget(null)}
|
||||||
|
>
|
||||||
|
{failLogTarget && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-xs font-semibold text-wing-muted uppercase mb-1">
|
||||||
|
Exit Code
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-wing-text font-mono bg-wing-card px-3 py-2 rounded-lg">
|
||||||
|
{failLogTarget.exitCode || '-'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-xs font-semibold text-wing-muted uppercase mb-1">
|
||||||
|
Exit Message
|
||||||
|
</h4>
|
||||||
|
<pre className="text-sm text-wing-text font-mono bg-wing-card px-3 py-2 rounded-lg whitespace-pre-wrap break-words max-h-64 overflow-y-auto">
|
||||||
|
{failLogTarget.exitMessage || '메시지 없음'}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</InfoModal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
273
frontend/src/pages/Jobs.tsx
Normal file
273
frontend/src/pages/Jobs.tsx
Normal file
@ -0,0 +1,273 @@
|
|||||||
|
import { useState, useCallback, useMemo } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { batchApi } from '../api/batchApi';
|
||||||
|
import type { JobDetailDto } from '../api/batchApi';
|
||||||
|
import { usePoller } from '../hooks/usePoller';
|
||||||
|
import { useToastContext } from '../contexts/ToastContext';
|
||||||
|
import StatusBadge from '../components/StatusBadge';
|
||||||
|
import EmptyState from '../components/EmptyState';
|
||||||
|
import LoadingSpinner from '../components/LoadingSpinner';
|
||||||
|
import { formatDateTime } from '../utils/formatters';
|
||||||
|
|
||||||
|
const POLLING_INTERVAL = 30000;
|
||||||
|
|
||||||
|
export default function Jobs() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { showToast } = useToastContext();
|
||||||
|
|
||||||
|
const [jobs, setJobs] = useState<JobDetailDto[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
|
||||||
|
// Execute modal
|
||||||
|
const [executeModalOpen, setExecuteModalOpen] = useState(false);
|
||||||
|
const [targetJob, setTargetJob] = useState('');
|
||||||
|
const [executing, setExecuting] = useState(false);
|
||||||
|
const [startDate, setStartDate] = useState('');
|
||||||
|
const [stopDate, setStopDate] = useState('');
|
||||||
|
|
||||||
|
const loadJobs = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await batchApi.getJobsDetail();
|
||||||
|
setJobs(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Jobs load failed:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
usePoller(loadJobs, POLLING_INTERVAL);
|
||||||
|
|
||||||
|
const filteredJobs = useMemo(() => {
|
||||||
|
if (!searchTerm.trim()) return jobs;
|
||||||
|
const term = searchTerm.toLowerCase();
|
||||||
|
return jobs.filter((job) => job.jobName.toLowerCase().includes(term));
|
||||||
|
}, [jobs, searchTerm]);
|
||||||
|
|
||||||
|
const handleExecuteClick = (jobName: string) => {
|
||||||
|
setTargetJob(jobName);
|
||||||
|
setStartDate('');
|
||||||
|
setStopDate('');
|
||||||
|
setExecuteModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmExecute = async () => {
|
||||||
|
if (!targetJob) return;
|
||||||
|
setExecuting(true);
|
||||||
|
try {
|
||||||
|
const params: Record<string, string> = {};
|
||||||
|
if (startDate) params.startDate = startDate;
|
||||||
|
if (stopDate) params.stopDate = stopDate;
|
||||||
|
|
||||||
|
const result = await batchApi.executeJob(
|
||||||
|
targetJob,
|
||||||
|
Object.keys(params).length > 0 ? params : undefined,
|
||||||
|
);
|
||||||
|
showToast(
|
||||||
|
result.message || `${targetJob} 실행 요청 완료`,
|
||||||
|
'success',
|
||||||
|
);
|
||||||
|
setExecuteModalOpen(false);
|
||||||
|
} catch (err) {
|
||||||
|
showToast(
|
||||||
|
`실행 실패: ${err instanceof Error ? err.message : '알 수 없는 오류'}`,
|
||||||
|
'error',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setExecuting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleViewHistory = (jobName: string) => {
|
||||||
|
navigate(`/executions?job=${encodeURIComponent(jobName)}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) return <LoadingSpinner />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||||
|
<h1 className="text-2xl font-bold text-wing-text">배치 작업 목록</h1>
|
||||||
|
<span className="text-sm text-wing-muted">
|
||||||
|
총 {jobs.length}개 작업
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search Filter */}
|
||||||
|
<div className="bg-wing-surface rounded-xl shadow-md p-4">
|
||||||
|
<div className="relative">
|
||||||
|
<span className="absolute inset-y-0 left-3 flex items-center text-wing-muted">
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="작업명 검색..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="w-full pl-10 pr-4 py-2 border border-wing-border rounded-lg text-sm
|
||||||
|
focus:ring-2 focus:ring-wing-accent focus:border-wing-accent outline-none"
|
||||||
|
/>
|
||||||
|
{searchTerm && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSearchTerm('')}
|
||||||
|
className="absolute inset-y-0 right-3 flex items-center text-wing-muted hover:text-wing-text"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{searchTerm && (
|
||||||
|
<p className="mt-2 text-xs text-wing-muted">
|
||||||
|
{filteredJobs.length}개 작업 검색됨
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Job Cards Grid */}
|
||||||
|
{filteredJobs.length === 0 ? (
|
||||||
|
<div className="bg-wing-surface rounded-xl shadow-md p-6">
|
||||||
|
<EmptyState
|
||||||
|
icon="🔍"
|
||||||
|
message={searchTerm ? '검색 결과가 없습니다.' : '등록된 작업이 없습니다.'}
|
||||||
|
sub={searchTerm ? '다른 검색어를 입력해 보세요.' : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{filteredJobs.map((job) => (
|
||||||
|
<div
|
||||||
|
key={job.jobName}
|
||||||
|
className="bg-wing-surface rounded-xl shadow-md p-6
|
||||||
|
hover:shadow-lg hover:-translate-y-0.5 transition-all"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<h3 className="text-sm font-semibold text-wing-text break-all leading-tight">
|
||||||
|
{job.jobName}
|
||||||
|
</h3>
|
||||||
|
{job.lastExecution && (
|
||||||
|
<StatusBadge status={job.lastExecution.status} className="ml-2 shrink-0" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Job detail info */}
|
||||||
|
<div className="mb-4 space-y-1">
|
||||||
|
{job.lastExecution ? (
|
||||||
|
<p className="text-xs text-wing-muted">
|
||||||
|
마지막 실행: {formatDateTime(job.lastExecution.startTime)}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-wing-muted text-xs">실행 이력 없음</p>
|
||||||
|
)}
|
||||||
|
{job.scheduleCron && (
|
||||||
|
<p className="text-xs text-wing-muted">
|
||||||
|
스케줄:{' '}
|
||||||
|
<span className="font-mono text-xs bg-wing-card px-2 py-0.5 rounded">
|
||||||
|
{job.scheduleCron}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleExecuteClick(job.jobName)}
|
||||||
|
className="flex-1 px-3 py-2 text-xs font-medium text-white bg-wing-accent rounded-lg
|
||||||
|
hover:bg-wing-accent/80 transition-colors"
|
||||||
|
>
|
||||||
|
실행
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleViewHistory(job.jobName)}
|
||||||
|
className="flex-1 px-3 py-2 text-xs font-medium text-wing-accent bg-wing-accent/10 rounded-lg
|
||||||
|
hover:bg-wing-accent/15 transition-colors"
|
||||||
|
>
|
||||||
|
이력 보기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Execute Modal (custom with date params) */}
|
||||||
|
{executeModalOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-wing-overlay"
|
||||||
|
onClick={() => setExecuteModalOpen(false)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="bg-wing-surface rounded-xl shadow-2xl p-6 max-w-md w-full mx-4"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h3 className="text-lg font-semibold text-wing-text mb-2">작업 실행 확인</h3>
|
||||||
|
<p className="text-wing-muted text-sm mb-4">
|
||||||
|
"{targetJob}" 작업을 실행하시겠습니까?
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Date parameters */}
|
||||||
|
<div className="space-y-3 mb-6">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-wing-text mb-1">
|
||||||
|
시작일시 (선택)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={startDate}
|
||||||
|
onChange={(e) => setStartDate(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-wing-border rounded-lg text-sm
|
||||||
|
focus:ring-2 focus:ring-wing-accent focus:border-wing-accent outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-wing-text mb-1">
|
||||||
|
종료일시 (선택)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={stopDate}
|
||||||
|
onChange={(e) => setStopDate(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-wing-border rounded-lg text-sm
|
||||||
|
focus:ring-2 focus:ring-wing-accent focus:border-wing-accent outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{(startDate || stopDate) && (
|
||||||
|
<p className="text-xs text-wing-muted">
|
||||||
|
날짜를 지정하면 해당 범위의 데이터를 수집합니다.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setExecuteModalOpen(false)}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-wing-text bg-wing-card rounded-lg
|
||||||
|
hover:bg-wing-hover transition-colors"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleConfirmExecute}
|
||||||
|
disabled={executing}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-white bg-wing-accent rounded-lg
|
||||||
|
hover:bg-wing-accent/80 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{executing ? '실행 중...' : '실행'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
495
frontend/src/pages/RecollectDetail.tsx
Normal file
495
frontend/src/pages/RecollectDetail.tsx
Normal file
@ -0,0 +1,495 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
batchApi,
|
||||||
|
type RecollectionDetailResponse,
|
||||||
|
type StepExecutionDto,
|
||||||
|
} from '../api/batchApi';
|
||||||
|
import { formatDateTime, formatDuration, calculateDuration } from '../utils/formatters';
|
||||||
|
import { usePoller } from '../hooks/usePoller';
|
||||||
|
import StatusBadge from '../components/StatusBadge';
|
||||||
|
import EmptyState from '../components/EmptyState';
|
||||||
|
import LoadingSpinner from '../components/LoadingSpinner';
|
||||||
|
|
||||||
|
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 StepCard({ step }: { step: StepExecutionDto }) {
|
||||||
|
const [logsOpen, setLogsOpen] = useState(false);
|
||||||
|
|
||||||
|
const stats = [
|
||||||
|
{ label: '읽기', value: step.readCount },
|
||||||
|
{ label: '쓰기', value: step.writeCount },
|
||||||
|
{ label: '커밋', value: step.commitCount },
|
||||||
|
{ label: '롤백', value: step.rollbackCount },
|
||||||
|
{ label: '읽기 건너뜀', value: step.readSkipCount },
|
||||||
|
{ label: '처리 건너뜀', value: step.processSkipCount },
|
||||||
|
{ label: '쓰기 건너뜀', value: step.writeSkipCount },
|
||||||
|
{ label: '필터', value: step.filterCount },
|
||||||
|
];
|
||||||
|
|
||||||
|
const summary = step.apiLogSummary;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-wing-surface rounded-xl shadow-md p-6">
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h3 className="text-base font-semibold text-wing-text">
|
||||||
|
{step.stepName}
|
||||||
|
</h3>
|
||||||
|
<StatusBadge status={step.status} />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-wing-muted">
|
||||||
|
{step.duration != null
|
||||||
|
? formatDuration(step.duration)
|
||||||
|
: calculateDuration(step.startTime, step.endTime)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-sm mb-3">
|
||||||
|
<div className="text-wing-muted">
|
||||||
|
시작: <span className="text-wing-text">{formatDateTime(step.startTime)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-wing-muted">
|
||||||
|
종료: <span className="text-wing-text">{formatDateTime(step.endTime)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||||
|
{stats.map(({ label, value }) => (
|
||||||
|
<div
|
||||||
|
key={label}
|
||||||
|
className="rounded-lg bg-wing-card px-3 py-2 text-center"
|
||||||
|
>
|
||||||
|
<p className="text-lg font-bold text-wing-text">
|
||||||
|
{value.toLocaleString()}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-wing-muted">{label}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* API 호출 로그 요약 (batch_api_log 기반) */}
|
||||||
|
{summary && (
|
||||||
|
<div className="mt-4 rounded-lg bg-blue-50 border border-blue-200 p-3">
|
||||||
|
<p className="text-xs font-medium text-blue-700 mb-2">API 호출 정보</p>
|
||||||
|
<div className="grid grid-cols-3 sm:grid-cols-6 gap-2">
|
||||||
|
<div className="rounded bg-white px-2 py-1.5 text-center">
|
||||||
|
<p className="text-sm font-bold text-wing-text">{summary.totalCalls.toLocaleString()}</p>
|
||||||
|
<p className="text-[10px] text-wing-muted">총 호출</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded bg-white px-2 py-1.5 text-center">
|
||||||
|
<p className="text-sm font-bold text-emerald-600">{summary.successCount.toLocaleString()}</p>
|
||||||
|
<p className="text-[10px] text-wing-muted">성공</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded bg-white px-2 py-1.5 text-center">
|
||||||
|
<p className={`text-sm font-bold ${summary.errorCount > 0 ? 'text-red-500' : 'text-wing-text'}`}>
|
||||||
|
{summary.errorCount.toLocaleString()}
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-wing-muted">에러</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded bg-white px-2 py-1.5 text-center">
|
||||||
|
<p className="text-sm font-bold text-blue-600">{Math.round(summary.avgResponseMs).toLocaleString()}</p>
|
||||||
|
<p className="text-[10px] text-wing-muted">평균(ms)</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded bg-white px-2 py-1.5 text-center">
|
||||||
|
<p className="text-sm font-bold text-red-500">{summary.maxResponseMs.toLocaleString()}</p>
|
||||||
|
<p className="text-[10px] text-wing-muted">최대(ms)</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded bg-white px-2 py-1.5 text-center">
|
||||||
|
<p className="text-sm font-bold text-emerald-500">{summary.minResponseMs.toLocaleString()}</p>
|
||||||
|
<p className="text-[10px] text-wing-muted">최소(ms)</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 펼침/접기 개별 로그 */}
|
||||||
|
{summary.logs.length > 0 && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setLogsOpen((v) => !v)}
|
||||||
|
className="inline-flex items-center gap-1 text-xs font-medium text-blue-600 hover:text-blue-800 transition-colors"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className={`w-3 h-3 transition-transform ${logsOpen ? 'rotate-90' : ''}`}
|
||||||
|
fill="none" viewBox="0 0 24 24" stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
개별 호출 로그 ({summary.logs.length}건)
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{logsOpen && (
|
||||||
|
<div className="mt-2 overflow-x-auto max-h-64 overflow-y-auto">
|
||||||
|
<table className="w-full text-xs text-left">
|
||||||
|
<thead className="bg-blue-100 text-blue-700 sticky top-0">
|
||||||
|
<tr>
|
||||||
|
<th className="px-2 py-1.5 font-medium">#</th>
|
||||||
|
<th className="px-2 py-1.5 font-medium">URI</th>
|
||||||
|
<th className="px-2 py-1.5 font-medium">Method</th>
|
||||||
|
<th className="px-2 py-1.5 font-medium">상태</th>
|
||||||
|
<th className="px-2 py-1.5 font-medium text-right">응답(ms)</th>
|
||||||
|
<th className="px-2 py-1.5 font-medium text-right">건수</th>
|
||||||
|
<th className="px-2 py-1.5 font-medium">시간</th>
|
||||||
|
<th className="px-2 py-1.5 font-medium">에러</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-blue-100">
|
||||||
|
{summary.logs.map((log, idx) => {
|
||||||
|
const isError = (log.statusCode != null && log.statusCode >= 400) || log.errorMessage;
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={log.logId}
|
||||||
|
className={isError ? 'bg-red-50' : 'bg-white hover:bg-blue-50'}
|
||||||
|
>
|
||||||
|
<td className="px-2 py-1.5 text-blue-500">{idx + 1}</td>
|
||||||
|
<td className="px-2 py-1.5 font-mono text-blue-900 max-w-[200px] truncate" title={log.requestUri}>
|
||||||
|
{log.requestUri}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1.5 font-semibold text-blue-900">{log.httpMethod}</td>
|
||||||
|
<td className="px-2 py-1.5">
|
||||||
|
<span className={`font-semibold ${
|
||||||
|
log.statusCode == null ? 'text-gray-400'
|
||||||
|
: log.statusCode < 300 ? 'text-emerald-600'
|
||||||
|
: log.statusCode < 400 ? 'text-amber-600'
|
||||||
|
: 'text-red-600'
|
||||||
|
}`}>
|
||||||
|
{log.statusCode ?? '-'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1.5 text-right text-blue-900">
|
||||||
|
{log.responseTimeMs?.toLocaleString() ?? '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1.5 text-right text-blue-900">
|
||||||
|
{log.responseCount?.toLocaleString() ?? '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1.5 text-blue-600 whitespace-nowrap">
|
||||||
|
{formatDateTime(log.createdAt)}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1.5 text-red-500 max-w-[150px] truncate" title={log.errorMessage || ''}>
|
||||||
|
{log.errorMessage || '-'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step.exitMessage && (
|
||||||
|
<div className="mt-4 rounded-lg bg-red-50 border border-red-200 p-3">
|
||||||
|
<p className="text-xs font-medium text-red-700 mb-1">Exit Message</p>
|
||||||
|
<p className="text-xs text-red-600 whitespace-pre-wrap break-words">
|
||||||
|
{step.exitMessage}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RecollectDetail() {
|
||||||
|
const { id: paramId } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const historyId = paramId ? Number(paramId) : NaN;
|
||||||
|
|
||||||
|
const [data, setData] = useState<RecollectionDetailResponse | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const isRunning = data
|
||||||
|
? data.history.executionStatus === 'STARTED'
|
||||||
|
: false;
|
||||||
|
|
||||||
|
const loadDetail = useCallback(async () => {
|
||||||
|
if (!historyId || isNaN(historyId)) {
|
||||||
|
setError('유효하지 않은 이력 ID입니다.');
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await batchApi.getRecollectionDetail(historyId);
|
||||||
|
setData(result);
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
setError(
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: '재수집 상세 정보를 불러오지 못했습니다.',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [historyId]);
|
||||||
|
|
||||||
|
usePoller(loadDetail, isRunning ? POLLING_INTERVAL_MS : 30_000, [historyId]);
|
||||||
|
|
||||||
|
if (loading) return <LoadingSpinner />;
|
||||||
|
|
||||||
|
if (error || !data) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/recollects')}
|
||||||
|
className="inline-flex items-center gap-1 text-sm text-wing-muted hover:text-wing-text transition-colors"
|
||||||
|
>
|
||||||
|
<span>←</span> 목록으로
|
||||||
|
</button>
|
||||||
|
<EmptyState
|
||||||
|
icon="⚠"
|
||||||
|
message={error || '재수집 이력을 찾을 수 없습니다.'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { history, overlappingHistories, apiStats, stepExecutions } = data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 상단 내비게이션 */}
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
className="inline-flex items-center gap-1 text-sm text-wing-muted hover:text-wing-text transition-colors"
|
||||||
|
>
|
||||||
|
<span>←</span> 목록으로
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 기본 정보 카드 */}
|
||||||
|
<div className="bg-wing-surface rounded-xl shadow-md p-6">
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-wing-text">
|
||||||
|
재수집 #{history.historyId}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-wing-muted">
|
||||||
|
{history.apiKeyName || history.apiKey} · {history.jobName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<StatusBadge status={history.executionStatus} className="text-sm" />
|
||||||
|
{history.hasOverlap && (
|
||||||
|
<span className="inline-flex items-center px-2 py-0.5 text-xs font-medium bg-amber-100 text-amber-700 rounded-full">
|
||||||
|
기간 중복
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 text-sm">
|
||||||
|
<InfoItem label="실행자" value={history.executor || '-'} />
|
||||||
|
<InfoItem label="재수집 배치 실행일시" value={formatDateTime(history.executionStartTime)} />
|
||||||
|
<InfoItem label="재수집 배치 종료일시" value={formatDateTime(history.executionEndTime)} />
|
||||||
|
<InfoItem label="소요시간" value={formatDuration(history.durationMs)} />
|
||||||
|
<InfoItem label="재수집 사유" value={history.recollectionReason || '-'} />
|
||||||
|
{history.jobExecutionId && (
|
||||||
|
<InfoItem label="Job Execution ID" value={String(history.jobExecutionId)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 수집 기간 정보 */}
|
||||||
|
<div className="bg-wing-surface rounded-xl shadow-md p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-wing-text mb-4">
|
||||||
|
재수집 기간
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 text-sm">
|
||||||
|
<InfoItem label="재수집 시작일시" value={formatDateTime(history.rangeFromDate)} />
|
||||||
|
<InfoItem label="재수집 종료일시" value={formatDateTime(history.rangeToDate)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 처리 통계 카드 */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<StatCard
|
||||||
|
label="읽기 (Read)"
|
||||||
|
value={history.readCount ?? 0}
|
||||||
|
gradient="bg-gradient-to-br from-blue-500 to-blue-600"
|
||||||
|
icon="📥"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="쓰기 (Write)"
|
||||||
|
value={history.writeCount ?? 0}
|
||||||
|
gradient="bg-gradient-to-br from-emerald-500 to-emerald-600"
|
||||||
|
icon="📤"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="건너뜀 (Skip)"
|
||||||
|
value={history.skipCount ?? 0}
|
||||||
|
gradient="bg-gradient-to-br from-amber-500 to-amber-600"
|
||||||
|
icon="⏭"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="API 호출"
|
||||||
|
value={history.apiCallCount ?? 0}
|
||||||
|
gradient="bg-gradient-to-br from-purple-500 to-purple-600"
|
||||||
|
icon="🌐"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* API 응답시간 통계 */}
|
||||||
|
{apiStats && (
|
||||||
|
<div className="bg-wing-surface rounded-xl shadow-md p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-wing-text mb-4">
|
||||||
|
API 응답시간 통계
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-5 gap-4">
|
||||||
|
<div className="rounded-lg bg-wing-card px-4 py-3 text-center">
|
||||||
|
<p className="text-2xl font-bold text-wing-text">
|
||||||
|
{apiStats.callCount.toLocaleString()}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-wing-muted mt-1">총 호출수</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-wing-card px-4 py-3 text-center">
|
||||||
|
<p className="text-2xl font-bold text-wing-text">
|
||||||
|
{apiStats.totalMs.toLocaleString()}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-wing-muted mt-1">총 응답시간(ms)</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-wing-card px-4 py-3 text-center">
|
||||||
|
<p className="text-2xl font-bold text-blue-600">
|
||||||
|
{Math.round(apiStats.avgMs).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-wing-muted mt-1">평균(ms)</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-wing-card px-4 py-3 text-center">
|
||||||
|
<p className="text-2xl font-bold text-red-500">
|
||||||
|
{apiStats.maxMs.toLocaleString()}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-wing-muted mt-1">최대(ms)</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-wing-card px-4 py-3 text-center">
|
||||||
|
<p className="text-2xl font-bold text-emerald-500">
|
||||||
|
{apiStats.minMs.toLocaleString()}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-wing-muted mt-1">최소(ms)</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 실패 사유 */}
|
||||||
|
{history.executionStatus === 'FAILED' && history.failureReason && (
|
||||||
|
<div className="bg-wing-surface rounded-xl shadow-md p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-red-600 mb-3">
|
||||||
|
실패 사유
|
||||||
|
</h2>
|
||||||
|
<pre className="text-sm text-wing-text font-mono bg-red-50 border border-red-200 px-4 py-3 rounded-lg whitespace-pre-wrap break-words max-h-64 overflow-y-auto">
|
||||||
|
{history.failureReason}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 기간 중복 이력 */}
|
||||||
|
{overlappingHistories.length > 0 && (
|
||||||
|
<div className="bg-wing-surface rounded-xl shadow-md p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-amber-600 mb-4">
|
||||||
|
기간 중복 이력
|
||||||
|
<span className="ml-2 text-sm font-normal text-wing-muted">
|
||||||
|
({overlappingHistories.length}건)
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm text-left">
|
||||||
|
<thead className="bg-wing-card text-xs uppercase text-wing-muted">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 font-medium">이력 ID</th>
|
||||||
|
<th className="px-4 py-3 font-medium">작업명</th>
|
||||||
|
<th className="px-4 py-3 font-medium">수집 시작일</th>
|
||||||
|
<th className="px-4 py-3 font-medium">수집 종료일</th>
|
||||||
|
<th className="px-4 py-3 font-medium">상태</th>
|
||||||
|
<th className="px-4 py-3 font-medium">실행자</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-wing-border/50">
|
||||||
|
{overlappingHistories.map((oh) => (
|
||||||
|
<tr
|
||||||
|
key={oh.historyId}
|
||||||
|
className="hover:bg-wing-hover transition-colors cursor-pointer"
|
||||||
|
onClick={() => navigate(`/recollects/${oh.historyId}`)}
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3 font-mono text-wing-text">
|
||||||
|
#{oh.historyId}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-wing-text">
|
||||||
|
{oh.apiKeyName || oh.apiKey}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-wing-muted text-xs">
|
||||||
|
{formatDateTime(oh.rangeFromDate)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-wing-muted text-xs">
|
||||||
|
{formatDateTime(oh.rangeToDate)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<StatusBadge status={oh.executionStatus} />
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-wing-muted">
|
||||||
|
{oh.executor || '-'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 실행 정보 */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-wing-text mb-4">
|
||||||
|
Step 실행 정보
|
||||||
|
<span className="ml-2 text-sm font-normal text-wing-muted">
|
||||||
|
({stepExecutions.length}개)
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
{stepExecutions.length === 0 ? (
|
||||||
|
<EmptyState message="Step 실행 정보가 없습니다." />
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{stepExecutions.map((step) => (
|
||||||
|
<StepCard key={step.stepExecutionId} step={step} />
|
||||||
|
))}
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
809
frontend/src/pages/Recollects.tsx
Normal file
809
frontend/src/pages/Recollects.tsx
Normal file
@ -0,0 +1,809 @@
|
|||||||
|
import { useState, useMemo, useCallback } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
batchApi,
|
||||||
|
type RecollectionHistoryDto,
|
||||||
|
type RecollectionSearchResponse,
|
||||||
|
type CollectionPeriodDto,
|
||||||
|
} from '../api/batchApi';
|
||||||
|
import { formatDateTime, formatDuration } from '../utils/formatters';
|
||||||
|
import { usePoller } from '../hooks/usePoller';
|
||||||
|
import { useToastContext } from '../contexts/ToastContext';
|
||||||
|
import StatusBadge from '../components/StatusBadge';
|
||||||
|
import InfoModal from '../components/InfoModal';
|
||||||
|
import EmptyState from '../components/EmptyState';
|
||||||
|
import LoadingSpinner from '../components/LoadingSpinner';
|
||||||
|
|
||||||
|
type StatusFilter = 'ALL' | 'COMPLETED' | 'FAILED' | 'STARTED';
|
||||||
|
|
||||||
|
const STATUS_FILTERS: { value: StatusFilter; label: string }[] = [
|
||||||
|
{ value: 'ALL', label: '전체' },
|
||||||
|
{ value: 'COMPLETED', label: '완료' },
|
||||||
|
{ value: 'FAILED', label: '실패' },
|
||||||
|
{ value: 'STARTED', label: '실행중' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const POLLING_INTERVAL_MS = 10_000;
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
|
/** datetime 문자열에서 date input용 값 추출 (YYYY-MM-DD) */
|
||||||
|
function toDateInput(dt: string | null): string {
|
||||||
|
if (!dt) return '';
|
||||||
|
return dt.substring(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** datetime 문자열에서 time input용 값 추출 (HH:mm) */
|
||||||
|
function toTimeInput(dt: string | null): string {
|
||||||
|
if (!dt) return '00:00';
|
||||||
|
const t = dt.substring(11, 16);
|
||||||
|
return t || '00:00';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** date + time을 ISO datetime 문자열로 결합 */
|
||||||
|
function toIsoDateTime(date: string, time: string): string {
|
||||||
|
return `${date}T${time || '00:00'}:00`;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PeriodEdit {
|
||||||
|
fromDate: string;
|
||||||
|
fromTime: string;
|
||||||
|
toDate: string;
|
||||||
|
toTime: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 기간 프리셋 정의 (시간 단위) */
|
||||||
|
const DURATION_PRESETS = [
|
||||||
|
{ label: '6시간', hours: 6 },
|
||||||
|
{ label: '12시간', hours: 12 },
|
||||||
|
{ label: '하루', hours: 24 },
|
||||||
|
{ label: '일주일', hours: 168 },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** 시작 날짜+시간에 시간(hours)을 더해 종료 날짜+시간을 반환 */
|
||||||
|
function addHoursToDateTime(
|
||||||
|
date: string,
|
||||||
|
time: string,
|
||||||
|
hours: number,
|
||||||
|
): { toDate: string; toTime: string } {
|
||||||
|
if (!date) return { toDate: '', toTime: '00:00' };
|
||||||
|
const dt = new Date(`${date}T${time || '00:00'}:00`);
|
||||||
|
dt.setTime(dt.getTime() + hours * 60 * 60 * 1000);
|
||||||
|
const y = dt.getFullYear();
|
||||||
|
const m = String(dt.getMonth() + 1).padStart(2, '0');
|
||||||
|
const d = String(dt.getDate()).padStart(2, '0');
|
||||||
|
const hh = String(dt.getHours()).padStart(2, '0');
|
||||||
|
const mm = String(dt.getMinutes()).padStart(2, '0');
|
||||||
|
return { toDate: `${y}-${m}-${d}`, toTime: `${hh}:${mm}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Recollects() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { showToast } = useToastContext();
|
||||||
|
const [periods, setPeriods] = useState<CollectionPeriodDto[]>([]);
|
||||||
|
const [histories, setHistories] = useState<RecollectionHistoryDto[]>([]);
|
||||||
|
const [selectedApiKey, setSelectedApiKey] = useState('');
|
||||||
|
const [apiDropdownOpen, setApiDropdownOpen] = useState(false);
|
||||||
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>('ALL');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
// 날짜 범위 필터 + 페이지네이션
|
||||||
|
const [startDate, setStartDate] = useState('');
|
||||||
|
const [endDate, setEndDate] = useState('');
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
const [totalPages, setTotalPages] = useState(0);
|
||||||
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
|
const [useSearch, setUseSearch] = useState(false);
|
||||||
|
|
||||||
|
// 실패 로그 모달
|
||||||
|
const [failLogTarget, setFailLogTarget] = useState<RecollectionHistoryDto | null>(null);
|
||||||
|
|
||||||
|
// 수집 기간 관리 패널
|
||||||
|
const [periodPanelOpen, setPeriodPanelOpen] = useState(false);
|
||||||
|
const [selectedPeriodKey, setSelectedPeriodKey] = useState<string>('');
|
||||||
|
const [periodDropdownOpen, setPeriodDropdownOpen] = useState(false);
|
||||||
|
const [periodEdits, setPeriodEdits] = useState<Record<string, PeriodEdit>>({});
|
||||||
|
const [savingApiKey, setSavingApiKey] = useState<string | null>(null);
|
||||||
|
const [executingApiKey, setExecutingApiKey] = useState<string | null>(null);
|
||||||
|
const [manualToDate, setManualToDate] = useState<Record<string, boolean>>({});
|
||||||
|
const [selectedDuration, setSelectedDuration] = useState<Record<string, number | null>>({});
|
||||||
|
|
||||||
|
const getPeriodEdit = (p: CollectionPeriodDto): PeriodEdit => {
|
||||||
|
if (periodEdits[p.apiKey]) return periodEdits[p.apiKey];
|
||||||
|
return {
|
||||||
|
fromDate: toDateInput(p.rangeFromDate),
|
||||||
|
fromTime: toTimeInput(p.rangeFromDate),
|
||||||
|
toDate: toDateInput(p.rangeToDate),
|
||||||
|
toTime: toTimeInput(p.rangeToDate),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatePeriodEdit = (apiKey: string, field: keyof PeriodEdit, value: string) => {
|
||||||
|
const current = periodEdits[apiKey] || getPeriodEdit(periods.find((p) => p.apiKey === apiKey)!);
|
||||||
|
setPeriodEdits((prev) => ({ ...prev, [apiKey]: { ...current, [field]: value } }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyDurationPreset = (apiKey: string, hours: number) => {
|
||||||
|
const p = periods.find((pp) => pp.apiKey === apiKey);
|
||||||
|
if (!p) return;
|
||||||
|
const edit = periodEdits[apiKey] || getPeriodEdit(p);
|
||||||
|
if (!edit.fromDate) {
|
||||||
|
showToast('재수집 시작일시를 먼저 선택해 주세요.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { toDate, toTime } = addHoursToDateTime(edit.fromDate, edit.fromTime, hours);
|
||||||
|
setSelectedDuration((prev) => ({ ...prev, [apiKey]: hours }));
|
||||||
|
setPeriodEdits((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[apiKey]: { ...edit, toDate, toTime },
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFromDateChange = (apiKey: string, field: 'fromDate' | 'fromTime', value: string) => {
|
||||||
|
const p = periods.find((pp) => pp.apiKey === apiKey);
|
||||||
|
if (!p) return;
|
||||||
|
const edit = periodEdits[apiKey] || getPeriodEdit(p);
|
||||||
|
const updated = { ...edit, [field]: value };
|
||||||
|
// 기간 프리셋이 선택된 상태면 종료일시도 자동 갱신
|
||||||
|
const dur = selectedDuration[apiKey];
|
||||||
|
if (dur != null && !manualToDate[apiKey]) {
|
||||||
|
const { toDate, toTime } = addHoursToDateTime(updated.fromDate, updated.fromTime, dur);
|
||||||
|
updated.toDate = toDate;
|
||||||
|
updated.toTime = toTime;
|
||||||
|
}
|
||||||
|
setPeriodEdits((prev) => ({ ...prev, [apiKey]: updated }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResetPeriod = async (p: CollectionPeriodDto) => {
|
||||||
|
setSavingApiKey(p.apiKey);
|
||||||
|
try {
|
||||||
|
await batchApi.resetCollectionPeriod(p.apiKey);
|
||||||
|
showToast(`${p.apiKeyName || p.apiKey} 수집 기간이 초기화되었습니다.`, 'success');
|
||||||
|
setPeriodEdits((prev) => { const next = { ...prev }; delete next[p.apiKey]; return next; });
|
||||||
|
setSelectedDuration((prev) => ({ ...prev, [p.apiKey]: null }));
|
||||||
|
setManualToDate((prev) => ({ ...prev, [p.apiKey]: false }));
|
||||||
|
await loadPeriods();
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err instanceof Error ? err.message : '수집 기간 초기화에 실패했습니다.', 'error');
|
||||||
|
} finally {
|
||||||
|
setSavingApiKey(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSavePeriod = async (p: CollectionPeriodDto) => {
|
||||||
|
const edit = getPeriodEdit(p);
|
||||||
|
if (!edit.fromDate || !edit.toDate) {
|
||||||
|
showToast('시작일과 종료일을 모두 입력해 주세요.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const from = toIsoDateTime(edit.fromDate, edit.fromTime);
|
||||||
|
const to = toIsoDateTime(edit.toDate, edit.toTime);
|
||||||
|
const now = new Date().toISOString().substring(0, 19);
|
||||||
|
if (from >= now) {
|
||||||
|
showToast('재수집 시작일시는 현재 시간보다 이전이어야 합니다.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (to >= now) {
|
||||||
|
showToast('재수집 종료일시는 현재 시간보다 이전이어야 합니다.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (from >= to) {
|
||||||
|
showToast('재수집 시작일시는 종료일시보다 이전이어야 합니다.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSavingApiKey(p.apiKey);
|
||||||
|
try {
|
||||||
|
await batchApi.updateCollectionPeriod(p.apiKey, { rangeFromDate: from, rangeToDate: to });
|
||||||
|
showToast(`${p.apiKeyName || p.apiKey} 수집 기간이 저장되었습니다.`, 'success');
|
||||||
|
setPeriodEdits((prev) => { const next = { ...prev }; delete next[p.apiKey]; return next; });
|
||||||
|
await loadPeriods();
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err instanceof Error ? err.message : '수집 기간 저장에 실패했습니다.', 'error');
|
||||||
|
} finally {
|
||||||
|
setSavingApiKey(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExecuteRecollect = async (p: CollectionPeriodDto) => {
|
||||||
|
if (!p.jobName) {
|
||||||
|
showToast('연결된 Job이 없습니다.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setExecutingApiKey(p.apiKey);
|
||||||
|
try {
|
||||||
|
const result = await batchApi.executeJob(p.jobName, {
|
||||||
|
executionMode: 'RECOLLECT',
|
||||||
|
apiKey: p.apiKey,
|
||||||
|
executor: 'MANUAL',
|
||||||
|
reason: '수집 기간 관리 화면에서 수동 실행',
|
||||||
|
});
|
||||||
|
showToast(result.message || `${p.apiKeyName || p.apiKey} 재수집이 시작되었습니다.`, 'success');
|
||||||
|
setLoading(true);
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err instanceof Error ? err.message : '재수집 실행에 실패했습니다.', 'error');
|
||||||
|
} finally {
|
||||||
|
setExecutingApiKey(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadPeriods = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await batchApi.getCollectionPeriods();
|
||||||
|
setPeriods(data);
|
||||||
|
} catch {
|
||||||
|
/* 수집기간 로드 실패 무시 */
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadHistories = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const params: {
|
||||||
|
apiKey?: string;
|
||||||
|
status?: string;
|
||||||
|
fromDate?: string;
|
||||||
|
toDate?: string;
|
||||||
|
page?: number;
|
||||||
|
size?: number;
|
||||||
|
} = {
|
||||||
|
page: useSearch ? page : 0,
|
||||||
|
size: PAGE_SIZE,
|
||||||
|
};
|
||||||
|
if (selectedApiKey) params.apiKey = selectedApiKey;
|
||||||
|
if (statusFilter !== 'ALL') params.status = statusFilter;
|
||||||
|
if (useSearch && startDate) params.fromDate = `${startDate}T00:00:00`;
|
||||||
|
if (useSearch && endDate) params.toDate = `${endDate}T23:59:59`;
|
||||||
|
|
||||||
|
const data: RecollectionSearchResponse = await batchApi.searchRecollections(params);
|
||||||
|
setHistories(data.content);
|
||||||
|
setTotalPages(data.totalPages);
|
||||||
|
setTotalCount(data.totalElements);
|
||||||
|
if (!useSearch) setPage(data.number);
|
||||||
|
} catch {
|
||||||
|
setHistories([]);
|
||||||
|
setTotalPages(0);
|
||||||
|
setTotalCount(0);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [selectedApiKey, statusFilter, startDate, endDate, useSearch, page]);
|
||||||
|
|
||||||
|
usePoller(loadPeriods, 60_000, []);
|
||||||
|
usePoller(loadHistories, POLLING_INTERVAL_MS, [selectedApiKey, statusFilter, useSearch, page]);
|
||||||
|
|
||||||
|
const filteredHistories = useMemo(() => {
|
||||||
|
if (useSearch) return histories;
|
||||||
|
if (statusFilter === 'ALL') return histories;
|
||||||
|
return histories.filter((h) => h.executionStatus === statusFilter);
|
||||||
|
}, [histories, statusFilter, useSearch]);
|
||||||
|
|
||||||
|
const handleSearch = async () => {
|
||||||
|
setUseSearch(true);
|
||||||
|
setPage(0);
|
||||||
|
setLoading(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResetSearch = () => {
|
||||||
|
setUseSearch(false);
|
||||||
|
setStartDate('');
|
||||||
|
setEndDate('');
|
||||||
|
setPage(0);
|
||||||
|
setTotalPages(0);
|
||||||
|
setTotalCount(0);
|
||||||
|
setLoading(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePageChange = (newPage: number) => {
|
||||||
|
if (newPage < 0 || newPage >= totalPages) return;
|
||||||
|
setPage(newPage);
|
||||||
|
setLoading(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getApiLabel = (apiKey: string) => {
|
||||||
|
const p = periods.find((p) => p.apiKey === apiKey);
|
||||||
|
return p?.apiKeyName || apiKey;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-wing-text">재수집 이력</h1>
|
||||||
|
<p className="mt-1 text-sm text-wing-muted">
|
||||||
|
배치 재수집 실행 이력을 조회하고 관리합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 수집 기간 관리 패널 */}
|
||||||
|
<div className="bg-wing-surface rounded-xl shadow-md">
|
||||||
|
<button
|
||||||
|
onClick={() => setPeriodPanelOpen((v) => !v)}
|
||||||
|
className="w-full flex items-center justify-between px-6 py-4 hover:bg-wing-hover transition-colors rounded-xl"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<svg
|
||||||
|
className={`w-4 h-4 text-wing-muted transition-transform ${periodPanelOpen ? '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>
|
||||||
|
<span className="text-sm font-semibold text-wing-text">재수집 기간 관리</span>
|
||||||
|
<span className="text-xs text-wing-muted">({periods.length}건)</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-wing-muted">
|
||||||
|
{periodPanelOpen ? '접기' : '펼치기'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{periodPanelOpen && (
|
||||||
|
<div className="border-t border-wing-border/50 px-6 py-4 space-y-4">
|
||||||
|
{periods.length === 0 ? (
|
||||||
|
<div className="py-4 text-center text-sm text-wing-muted">
|
||||||
|
등록된 수집 기간이 없습니다.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* 작업 선택 드롭다운 */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<label className="text-sm font-medium text-wing-text shrink-0">
|
||||||
|
작업 선택
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setPeriodDropdownOpen((v) => !v)}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-2 text-sm rounded-lg border border-wing-border bg-wing-surface hover:bg-wing-hover transition-colors min-w-[200px] justify-between"
|
||||||
|
>
|
||||||
|
<span className={selectedPeriodKey ? 'text-wing-text' : 'text-wing-muted'}>
|
||||||
|
{selectedPeriodKey
|
||||||
|
? (periods.find((p) => p.apiKey === selectedPeriodKey)?.apiKeyName || selectedPeriodKey)
|
||||||
|
: '작업을 선택하세요'}
|
||||||
|
</span>
|
||||||
|
<svg className={`w-4 h-4 text-wing-muted transition-transform ${periodDropdownOpen ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{periodDropdownOpen && (
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 z-10" onClick={() => setPeriodDropdownOpen(false)} />
|
||||||
|
<div className="absolute z-20 mt-1 w-72 max-h-60 overflow-y-auto bg-wing-surface border border-wing-border rounded-lg shadow-lg">
|
||||||
|
{periods.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.apiKey}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedPeriodKey(p.apiKey);
|
||||||
|
setPeriodDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
className={`w-full text-left px-3 py-2 text-sm hover:bg-wing-hover transition-colors ${
|
||||||
|
selectedPeriodKey === p.apiKey
|
||||||
|
? 'bg-wing-accent/10 text-wing-accent font-medium'
|
||||||
|
: 'text-wing-text'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div>{p.apiKeyName || p.apiKey}</div>
|
||||||
|
{p.jobName && (
|
||||||
|
<div className="text-xs text-wing-muted font-mono">{p.jobName}</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 선택된 작업의 기간 편집 */}
|
||||||
|
{selectedPeriodKey && (() => {
|
||||||
|
const p = periods.find((pp) => pp.apiKey === selectedPeriodKey);
|
||||||
|
if (!p) return null;
|
||||||
|
const edit = getPeriodEdit(p);
|
||||||
|
const hasChange = !!periodEdits[p.apiKey];
|
||||||
|
const isSaving = savingApiKey === p.apiKey;
|
||||||
|
const isExecuting = executingApiKey === p.apiKey;
|
||||||
|
const isManual = !!manualToDate[p.apiKey];
|
||||||
|
const activeDur = selectedDuration[p.apiKey] ?? null;
|
||||||
|
return (
|
||||||
|
<div className="bg-wing-card rounded-lg p-4 space-y-3">
|
||||||
|
<div className="flex items-center gap-4 text-sm">
|
||||||
|
<span className="text-wing-muted">작업명:</span>
|
||||||
|
<span className="font-mono text-xs text-wing-text">{p.jobName || '-'}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Line 1: 재수집 시작일시 */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-wing-muted mb-1">재수집 시작일시</label>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={edit.fromDate}
|
||||||
|
onChange={(e) => handleFromDateChange(p.apiKey, 'fromDate', e.target.value)}
|
||||||
|
className="flex-[3] min-w-0 rounded border border-wing-border bg-wing-surface px-2 py-1.5 text-sm shadow-sm focus:border-wing-accent focus:ring-1 focus:ring-wing-accent"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={edit.fromTime}
|
||||||
|
onChange={(e) => handleFromDateChange(p.apiKey, 'fromTime', e.target.value)}
|
||||||
|
className="flex-[2] min-w-0 rounded border border-wing-border bg-wing-surface px-2 py-1.5 text-sm shadow-sm focus:border-wing-accent focus:ring-1 focus:ring-wing-accent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Line 2: 기간 선택 버튼 + 직접입력 토글 */}
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
{DURATION_PRESETS.map(({ label, hours }) => (
|
||||||
|
<button
|
||||||
|
key={hours}
|
||||||
|
onClick={() => {
|
||||||
|
setManualToDate((prev) => ({ ...prev, [p.apiKey]: false }));
|
||||||
|
applyDurationPreset(p.apiKey, hours);
|
||||||
|
}}
|
||||||
|
className={`px-3 py-1.5 text-xs font-medium rounded-lg border transition-colors ${
|
||||||
|
!isManual && activeDur === hours
|
||||||
|
? 'bg-wing-accent text-white border-wing-accent shadow-sm'
|
||||||
|
: 'bg-wing-surface text-wing-muted border-wing-border hover:bg-wing-hover'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<div className="ml-auto flex items-center gap-1.5">
|
||||||
|
<span className="text-xs text-wing-muted">직접입력</span>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const next = !isManual;
|
||||||
|
setManualToDate((prev) => ({ ...prev, [p.apiKey]: next }));
|
||||||
|
if (next) {
|
||||||
|
setSelectedDuration((prev) => ({ ...prev, [p.apiKey]: null }));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors ${
|
||||||
|
isManual ? 'bg-wing-accent' : 'bg-wing-border'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow-sm transition-transform ${
|
||||||
|
isManual ? 'translate-x-4' : 'translate-x-0'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Line 3: 재수집 종료일시 */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-wing-muted mb-1">재수집 종료일시</label>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={edit.toDate}
|
||||||
|
disabled={!isManual}
|
||||||
|
onChange={(e) => updatePeriodEdit(p.apiKey, 'toDate', e.target.value)}
|
||||||
|
className={`flex-[3] min-w-0 rounded border border-wing-border px-2 py-1.5 text-sm shadow-sm focus:border-wing-accent focus:ring-1 focus:ring-wing-accent ${
|
||||||
|
isManual ? 'bg-wing-surface' : 'bg-wing-card text-wing-muted cursor-not-allowed'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={edit.toTime}
|
||||||
|
disabled={!isManual}
|
||||||
|
onChange={(e) => updatePeriodEdit(p.apiKey, 'toTime', e.target.value)}
|
||||||
|
className={`flex-[2] min-w-0 rounded border border-wing-border px-2 py-1.5 text-sm shadow-sm focus:border-wing-accent focus:ring-1 focus:ring-wing-accent ${
|
||||||
|
isManual ? 'bg-wing-surface' : 'bg-wing-card text-wing-muted cursor-not-allowed'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-2 pt-1">
|
||||||
|
<button
|
||||||
|
onClick={() => handleResetPeriod(p)}
|
||||||
|
disabled={isSaving}
|
||||||
|
className="px-4 py-2 text-sm font-medium rounded-lg text-red-600 bg-red-50 hover:bg-red-100 border border-red-200 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
기간 초기화
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleSavePeriod(p)}
|
||||||
|
disabled={isSaving || !hasChange}
|
||||||
|
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
|
||||||
|
hasChange
|
||||||
|
? 'text-white bg-wing-accent hover:bg-wing-accent/80 shadow-sm'
|
||||||
|
: 'text-wing-muted bg-wing-surface border border-wing-border cursor-not-allowed'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isSaving ? '저장중...' : '기간 저장'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleExecuteRecollect(p)}
|
||||||
|
disabled={isExecuting || !p.jobName}
|
||||||
|
className="px-4 py-2 text-sm font-medium rounded-lg text-emerald-700 bg-emerald-50 hover:bg-emerald-100 border border-emerald-200 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isExecuting ? '실행중...' : '재수집 실행'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 필터 영역 */}
|
||||||
|
<div className="bg-wing-surface rounded-xl shadow-md p-6">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* API 선택 */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<label className="text-sm font-medium text-wing-text shrink-0">
|
||||||
|
재수집 작업 선택
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setApiDropdownOpen((v) => !v)}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg border border-wing-border bg-wing-surface hover:bg-wing-hover transition-colors"
|
||||||
|
>
|
||||||
|
{selectedApiKey
|
||||||
|
? getApiLabel(selectedApiKey)
|
||||||
|
: '전체'}
|
||||||
|
<svg className={`w-4 h-4 text-wing-muted transition-transform ${apiDropdownOpen ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>
|
||||||
|
</button>
|
||||||
|
{apiDropdownOpen && (
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 z-10" onClick={() => setApiDropdownOpen(false)} />
|
||||||
|
<div className="absolute z-20 mt-1 w-72 max-h-60 overflow-y-auto bg-wing-surface border border-wing-border rounded-lg shadow-lg">
|
||||||
|
<button
|
||||||
|
onClick={() => { setSelectedApiKey(''); setApiDropdownOpen(false); setPage(0); setLoading(true); }}
|
||||||
|
className={`w-full text-left px-3 py-2 text-sm hover:bg-wing-hover transition-colors ${
|
||||||
|
!selectedApiKey ? 'bg-wing-accent/10 text-wing-accent font-medium' : 'text-wing-text'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
전체
|
||||||
|
</button>
|
||||||
|
{periods.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.apiKey}
|
||||||
|
onClick={() => { setSelectedApiKey(p.apiKey); setApiDropdownOpen(false); setPage(0); setLoading(true); }}
|
||||||
|
className={`w-full text-left px-3 py-2 text-sm hover:bg-wing-hover transition-colors ${
|
||||||
|
selectedApiKey === p.apiKey ? 'bg-wing-accent/10 text-wing-accent font-medium' : 'text-wing-text'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{p.apiKeyName || p.apiKey}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{selectedApiKey && (
|
||||||
|
<button
|
||||||
|
onClick={() => { setSelectedApiKey(''); setPage(0); setLoading(true); }}
|
||||||
|
className="text-xs text-wing-muted hover:text-wing-accent transition-colors"
|
||||||
|
>
|
||||||
|
초기화
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{selectedApiKey && (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
<span className="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-medium bg-wing-accent/15 text-wing-accent rounded-full">
|
||||||
|
{getApiLabel(selectedApiKey)}
|
||||||
|
<button
|
||||||
|
onClick={() => { setSelectedApiKey(''); setPage(0); setLoading(true); }}
|
||||||
|
className="hover:text-wing-text transition-colors"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 상태 필터 버튼 그룹 */}
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{STATUS_FILTERS.map(({ value, label }) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
onClick={() => {
|
||||||
|
setStatusFilter(value);
|
||||||
|
setPage(0);
|
||||||
|
setLoading(true);
|
||||||
|
}}
|
||||||
|
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${
|
||||||
|
statusFilter === value
|
||||||
|
? 'bg-wing-accent text-white shadow-sm'
|
||||||
|
: 'bg-wing-card text-wing-muted hover:bg-wing-hover'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 날짜 범위 필터 */}
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center mt-4 pt-4 border-t border-wing-border/50">
|
||||||
|
<label className="text-sm font-medium text-wing-text shrink-0">
|
||||||
|
기간 검색
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={startDate}
|
||||||
|
onChange={(e) => setStartDate(e.target.value)}
|
||||||
|
className="block rounded-lg border border-wing-border bg-wing-surface px-3 py-2 text-sm shadow-sm focus:border-wing-accent focus:ring-1 focus:ring-wing-accent"
|
||||||
|
/>
|
||||||
|
<span className="text-wing-muted text-sm">~</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={endDate}
|
||||||
|
onChange={(e) => setEndDate(e.target.value)}
|
||||||
|
className="block rounded-lg border border-wing-border bg-wing-surface px-3 py-2 text-sm shadow-sm focus:border-wing-accent focus:ring-1 focus:ring-wing-accent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleSearch}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-white bg-wing-accent rounded-lg hover:bg-wing-accent/80 transition-colors shadow-sm"
|
||||||
|
>
|
||||||
|
검색
|
||||||
|
</button>
|
||||||
|
{useSearch && (
|
||||||
|
<button
|
||||||
|
onClick={handleResetSearch}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-wing-text bg-wing-card rounded-lg hover:bg-wing-hover transition-colors"
|
||||||
|
>
|
||||||
|
초기화
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 재수집 이력 테이블 */}
|
||||||
|
<div className="bg-wing-surface rounded-xl shadow-md overflow-hidden">
|
||||||
|
{loading ? (
|
||||||
|
<LoadingSpinner />
|
||||||
|
) : filteredHistories.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
message="재수집 이력이 없습니다."
|
||||||
|
sub={
|
||||||
|
statusFilter !== 'ALL'
|
||||||
|
? '다른 상태 필터를 선택해 보세요.'
|
||||||
|
: selectedApiKey
|
||||||
|
? '선택한 API의 재수집 이력이 없습니다.'
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm text-left">
|
||||||
|
<thead className="bg-wing-card text-xs uppercase text-wing-muted">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 font-medium">재수집 ID</th>
|
||||||
|
<th className="px-4 py-3 font-medium">작업명</th>
|
||||||
|
<th className="px-4 py-3 font-medium">상태</th>
|
||||||
|
<th className="px-4 py-3 font-medium">재수집 시작일시</th>
|
||||||
|
<th className="px-4 py-3 font-medium">재수집 종료일시</th>
|
||||||
|
<th className="px-4 py-3 font-medium">소요시간</th>
|
||||||
|
<th className="px-4 py-3 font-medium text-right">액션</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-wing-border/50">
|
||||||
|
{filteredHistories.map((hist) => (
|
||||||
|
<tr
|
||||||
|
key={hist.historyId}
|
||||||
|
className="hover:bg-wing-hover transition-colors"
|
||||||
|
>
|
||||||
|
<td className="px-4 py-4 font-mono text-wing-text">
|
||||||
|
#{hist.historyId}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-4 text-wing-text">
|
||||||
|
<div className="max-w-[120px] truncate" title={hist.apiKeyName || hist.apiKey}>
|
||||||
|
{hist.apiKeyName || hist.apiKey}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-4">
|
||||||
|
{hist.executionStatus === 'FAILED' ? (
|
||||||
|
<button
|
||||||
|
onClick={() => setFailLogTarget(hist)}
|
||||||
|
className="cursor-pointer"
|
||||||
|
title="클릭하여 실패 로그 확인"
|
||||||
|
>
|
||||||
|
<StatusBadge status={hist.executionStatus} />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<StatusBadge status={hist.executionStatus} />
|
||||||
|
)}
|
||||||
|
{hist.hasOverlap && (
|
||||||
|
<span className="ml-1 text-xs text-amber-500" title="기간 중복 감지">
|
||||||
|
!</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-4 text-wing-muted whitespace-nowrap text-xs">
|
||||||
|
<div>{formatDateTime(hist.rangeFromDate)}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-4 text-wing-muted whitespace-nowrap text-xs">
|
||||||
|
<div>{formatDateTime(hist.rangeToDate)}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-4 text-wing-muted whitespace-nowrap">
|
||||||
|
{formatDuration(hist.durationMs)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-4 text-right">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/recollects/${hist.historyId}`)}
|
||||||
|
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-wing-accent bg-wing-accent/10 rounded-lg hover:bg-wing-accent/15 transition-colors"
|
||||||
|
>
|
||||||
|
상세
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 결과 건수 + 페이지네이션 */}
|
||||||
|
{!loading && filteredHistories.length > 0 && (
|
||||||
|
<div className="px-6 py-3 bg-wing-card border-t border-wing-border/50 flex items-center justify-between">
|
||||||
|
<div className="text-xs text-wing-muted">
|
||||||
|
총 {totalCount}건
|
||||||
|
</div>
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handlePageChange(page - 1)}
|
||||||
|
disabled={page === 0}
|
||||||
|
className="px-3 py-1.5 text-xs font-medium rounded-lg transition-colors disabled:opacity-40 disabled:cursor-not-allowed bg-wing-card text-wing-muted hover:bg-wing-hover"
|
||||||
|
>
|
||||||
|
이전
|
||||||
|
</button>
|
||||||
|
<span className="text-xs text-wing-muted">
|
||||||
|
{page + 1} / {totalPages}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => handlePageChange(page + 1)}
|
||||||
|
disabled={page >= totalPages - 1}
|
||||||
|
className="px-3 py-1.5 text-xs font-medium rounded-lg transition-colors disabled:opacity-40 disabled:cursor-not-allowed bg-wing-card text-wing-muted hover:bg-wing-hover"
|
||||||
|
>
|
||||||
|
다음
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 실패 로그 뷰어 모달 */}
|
||||||
|
<InfoModal
|
||||||
|
open={failLogTarget !== null}
|
||||||
|
title={
|
||||||
|
failLogTarget
|
||||||
|
? `실패 로그 - #${failLogTarget.historyId} (${failLogTarget.jobName})`
|
||||||
|
: '실패 로그'
|
||||||
|
}
|
||||||
|
onClose={() => setFailLogTarget(null)}
|
||||||
|
>
|
||||||
|
{failLogTarget && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-xs font-semibold text-wing-muted uppercase mb-1">
|
||||||
|
실행 상태
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-wing-text font-mono bg-wing-card px-3 py-2 rounded-lg">
|
||||||
|
{failLogTarget.executionStatus}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-xs font-semibold text-wing-muted uppercase mb-1">
|
||||||
|
실패 사유
|
||||||
|
</h4>
|
||||||
|
<pre className="text-sm text-wing-text font-mono bg-wing-card px-3 py-2 rounded-lg whitespace-pre-wrap break-words max-h-64 overflow-y-auto">
|
||||||
|
{failLogTarget.failureReason || '실패 사유 없음'}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</InfoModal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
490
frontend/src/pages/Schedules.tsx
Normal file
490
frontend/src/pages/Schedules.tsx
Normal file
@ -0,0 +1,490 @@
|
|||||||
|
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||||
|
import { batchApi, type ScheduleResponse } from '../api/batchApi';
|
||||||
|
import { formatDateTime } from '../utils/formatters';
|
||||||
|
import { useToastContext } from '../contexts/ToastContext';
|
||||||
|
import ConfirmModal from '../components/ConfirmModal';
|
||||||
|
import EmptyState from '../components/EmptyState';
|
||||||
|
import LoadingSpinner from '../components/LoadingSpinner';
|
||||||
|
import { getNextExecutions } from '../utils/cronPreview';
|
||||||
|
|
||||||
|
type ScheduleMode = 'new' | 'existing';
|
||||||
|
|
||||||
|
interface ConfirmAction {
|
||||||
|
type: 'toggle' | 'delete';
|
||||||
|
schedule: ScheduleResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CRON_PRESETS = [
|
||||||
|
{ label: '매 분', cron: '0 * * * * ?' },
|
||||||
|
{ label: '매시 정각', cron: '0 0 * * * ?' },
|
||||||
|
{ label: '매 15분', cron: '0 0/15 * * * ?' },
|
||||||
|
{ label: '매일 00:00', cron: '0 0 0 * * ?' },
|
||||||
|
{ label: '매일 12:00', cron: '0 0 12 * * ?' },
|
||||||
|
{ label: '매주 월 00:00', cron: '0 0 0 ? * MON' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function CronPreview({ cron }: { cron: string }) {
|
||||||
|
const nextDates = useMemo(() => getNextExecutions(cron, 5), [cron]);
|
||||||
|
|
||||||
|
if (nextDates.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<p className="text-xs text-wing-muted">미리보기 불가 (복잡한 표현식)</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fmt = new Intl.DateTimeFormat('ko-KR', {
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
weekday: 'short',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<label className="block text-sm font-medium text-wing-text mb-1">
|
||||||
|
다음 5회 실행 예정
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{nextDates.map((d, i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className="inline-block bg-wing-accent/10 text-wing-accent text-xs font-mono px-2 py-1 rounded"
|
||||||
|
>
|
||||||
|
{fmt.format(d)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTriggerStateStyle(state: string | null): string {
|
||||||
|
switch (state) {
|
||||||
|
case 'NORMAL':
|
||||||
|
return 'bg-emerald-100 text-emerald-700';
|
||||||
|
case 'PAUSED':
|
||||||
|
return 'bg-amber-100 text-amber-700';
|
||||||
|
case 'BLOCKED':
|
||||||
|
return 'bg-red-100 text-red-700';
|
||||||
|
case 'ERROR':
|
||||||
|
return 'bg-red-100 text-red-700';
|
||||||
|
default:
|
||||||
|
return 'bg-wing-card text-wing-muted';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Schedules() {
|
||||||
|
const { showToast } = useToastContext();
|
||||||
|
|
||||||
|
// Form state
|
||||||
|
const [jobs, setJobs] = useState<string[]>([]);
|
||||||
|
const [selectedJob, setSelectedJob] = useState('');
|
||||||
|
const [cronExpression, setCronExpression] = useState('');
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [scheduleMode, setScheduleMode] = useState<ScheduleMode>('new');
|
||||||
|
const [formLoading, setFormLoading] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
// Schedule list state
|
||||||
|
const [schedules, setSchedules] = useState<ScheduleResponse[]>([]);
|
||||||
|
const [listLoading, setListLoading] = useState(true);
|
||||||
|
|
||||||
|
// Confirm modal state
|
||||||
|
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
|
||||||
|
|
||||||
|
// 폼 영역 ref (편집 버튼 클릭 시 스크롤)
|
||||||
|
const formRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const loadSchedules = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const result = await batchApi.getSchedules();
|
||||||
|
setSchedules(result.schedules);
|
||||||
|
} catch (err) {
|
||||||
|
showToast('스케줄 목록 조회 실패', 'error');
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setListLoading(false);
|
||||||
|
}
|
||||||
|
}, [showToast]);
|
||||||
|
|
||||||
|
const loadJobs = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const result = await batchApi.getJobs();
|
||||||
|
setJobs(result);
|
||||||
|
} catch (err) {
|
||||||
|
showToast('작업 목록 조회 실패', 'error');
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}, [showToast]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadJobs();
|
||||||
|
loadSchedules();
|
||||||
|
}, [loadJobs, loadSchedules]);
|
||||||
|
|
||||||
|
const handleJobSelect = async (jobName: string) => {
|
||||||
|
setSelectedJob(jobName);
|
||||||
|
setCronExpression('');
|
||||||
|
setDescription('');
|
||||||
|
setScheduleMode('new');
|
||||||
|
|
||||||
|
if (!jobName) return;
|
||||||
|
|
||||||
|
setFormLoading(true);
|
||||||
|
try {
|
||||||
|
const schedule = await batchApi.getSchedule(jobName);
|
||||||
|
setCronExpression(schedule.cronExpression);
|
||||||
|
setDescription(schedule.description ?? '');
|
||||||
|
setScheduleMode('existing');
|
||||||
|
} catch {
|
||||||
|
// 404 = new schedule
|
||||||
|
setScheduleMode('new');
|
||||||
|
} finally {
|
||||||
|
setFormLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!selectedJob) {
|
||||||
|
showToast('작업을 선택해주세요', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!cronExpression.trim()) {
|
||||||
|
showToast('Cron 표현식을 입력해주세요', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
if (scheduleMode === 'existing') {
|
||||||
|
await batchApi.updateSchedule(selectedJob, {
|
||||||
|
cronExpression: cronExpression.trim(),
|
||||||
|
description: description.trim() || undefined,
|
||||||
|
});
|
||||||
|
showToast('스케줄이 수정되었습니다', 'success');
|
||||||
|
} else {
|
||||||
|
await batchApi.createSchedule({
|
||||||
|
jobName: selectedJob,
|
||||||
|
cronExpression: cronExpression.trim(),
|
||||||
|
description: description.trim() || undefined,
|
||||||
|
});
|
||||||
|
showToast('스케줄이 등록되었습니다', 'success');
|
||||||
|
}
|
||||||
|
await loadSchedules();
|
||||||
|
// Reload schedule info for current job
|
||||||
|
await handleJobSelect(selectedJob);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : '저장 실패';
|
||||||
|
showToast(message, 'error');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggle = async (schedule: ScheduleResponse) => {
|
||||||
|
try {
|
||||||
|
await batchApi.toggleSchedule(schedule.jobName, !schedule.active);
|
||||||
|
showToast(
|
||||||
|
`${schedule.jobName} 스케줄이 ${schedule.active ? '비활성화' : '활성화'}되었습니다`,
|
||||||
|
'success',
|
||||||
|
);
|
||||||
|
await loadSchedules();
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : '토글 실패';
|
||||||
|
showToast(message, 'error');
|
||||||
|
}
|
||||||
|
setConfirmAction(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (schedule: ScheduleResponse) => {
|
||||||
|
try {
|
||||||
|
await batchApi.deleteSchedule(schedule.jobName);
|
||||||
|
showToast(`${schedule.jobName} 스케줄이 삭제되었습니다`, 'success');
|
||||||
|
await loadSchedules();
|
||||||
|
// Clear form if deleted schedule was selected
|
||||||
|
if (selectedJob === schedule.jobName) {
|
||||||
|
setSelectedJob('');
|
||||||
|
setCronExpression('');
|
||||||
|
setDescription('');
|
||||||
|
setScheduleMode('new');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : '삭제 실패';
|
||||||
|
showToast(message, 'error');
|
||||||
|
}
|
||||||
|
setConfirmAction(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditFromCard = (schedule: ScheduleResponse) => {
|
||||||
|
setSelectedJob(schedule.jobName);
|
||||||
|
setCronExpression(schedule.cronExpression);
|
||||||
|
setDescription(schedule.description ?? '');
|
||||||
|
setScheduleMode('existing');
|
||||||
|
formRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Form Section */}
|
||||||
|
<div ref={formRef} className="bg-wing-surface rounded-xl shadow-lg p-6">
|
||||||
|
<h2 className="text-lg font-bold text-wing-text mb-4">스케줄 등록 / 수정</h2>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{/* Job Select */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-wing-text mb-1">
|
||||||
|
작업 선택
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<select
|
||||||
|
value={selectedJob}
|
||||||
|
onChange={(e) => handleJobSelect(e.target.value)}
|
||||||
|
className="flex-1 rounded-lg border border-wing-border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-wing-accent focus:border-wing-accent"
|
||||||
|
disabled={formLoading}
|
||||||
|
>
|
||||||
|
<option value="">-- 작업을 선택하세요 --</option>
|
||||||
|
{jobs.map((job) => (
|
||||||
|
<option key={job} value={job}>
|
||||||
|
{job}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{selectedJob && (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold whitespace-nowrap ${
|
||||||
|
scheduleMode === 'existing'
|
||||||
|
? 'bg-blue-100 text-blue-700'
|
||||||
|
: 'bg-green-100 text-green-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{scheduleMode === 'existing' ? '기존 스케줄' : '새 스케줄'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{formLoading && (
|
||||||
|
<div className="w-5 h-5 border-2 border-wing-accent/30 border-t-wing-accent rounded-full animate-spin" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cron Expression */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-wing-text mb-1">
|
||||||
|
Cron 표현식
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={cronExpression}
|
||||||
|
onChange={(e) => setCronExpression(e.target.value)}
|
||||||
|
placeholder="0 0/15 * * * ?"
|
||||||
|
className="w-full rounded-lg border border-wing-border px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-wing-accent focus:border-wing-accent"
|
||||||
|
disabled={!selectedJob || formLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cron Presets */}
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<label className="block text-sm font-medium text-wing-text mb-1">
|
||||||
|
프리셋
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{CRON_PRESETS.map(({ label, cron }) => (
|
||||||
|
<button
|
||||||
|
key={cron}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCronExpression(cron)}
|
||||||
|
disabled={!selectedJob || formLoading}
|
||||||
|
className="px-3 py-1 text-xs font-medium bg-wing-card text-wing-text rounded-lg hover:bg-wing-accent/15 hover:text-wing-accent transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cron Preview */}
|
||||||
|
{cronExpression.trim() && (
|
||||||
|
<CronPreview cron={cronExpression.trim()} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<label className="block text-sm font-medium text-wing-text mb-1">
|
||||||
|
설명
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="스케줄 설명 (선택)"
|
||||||
|
className="w-full rounded-lg border border-wing-border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-wing-accent focus:border-wing-accent"
|
||||||
|
disabled={!selectedJob || formLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Save Button */}
|
||||||
|
<div className="mt-4 flex justify-end">
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!selectedJob || !cronExpression.trim() || saving || formLoading}
|
||||||
|
className="px-6 py-2 text-sm font-medium text-white bg-wing-accent rounded-lg hover:bg-wing-accent/80 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
{saving ? '저장 중...' : '저장'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Schedule List */}
|
||||||
|
<div className="bg-wing-surface rounded-xl shadow-lg p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-lg font-bold text-wing-text">
|
||||||
|
등록된 스케줄
|
||||||
|
{schedules.length > 0 && (
|
||||||
|
<span className="ml-2 text-sm font-normal text-wing-muted">
|
||||||
|
({schedules.length}개)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={loadSchedules}
|
||||||
|
className="px-3 py-1.5 text-sm text-wing-muted bg-wing-card rounded-lg hover:bg-wing-hover transition-colors"
|
||||||
|
>
|
||||||
|
새로고침
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{listLoading ? (
|
||||||
|
<LoadingSpinner />
|
||||||
|
) : schedules.length === 0 ? (
|
||||||
|
<EmptyState message="등록된 스케줄이 없습니다" sub="위 폼에서 새 스케줄을 등록하세요" />
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
{schedules.map((schedule) => (
|
||||||
|
<div
|
||||||
|
key={schedule.id}
|
||||||
|
className="border border-wing-border rounded-xl p-4 hover:shadow-md transition-shadow"
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<h3 className="text-sm font-bold text-wing-text truncate">
|
||||||
|
{schedule.jobName}
|
||||||
|
</h3>
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-semibold ${
|
||||||
|
schedule.active
|
||||||
|
? 'bg-emerald-100 text-emerald-700'
|
||||||
|
: 'bg-wing-card text-wing-muted'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{schedule.active ? '활성' : '비활성'}
|
||||||
|
</span>
|
||||||
|
{schedule.triggerState && (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-semibold ${getTriggerStateStyle(schedule.triggerState)}`}
|
||||||
|
>
|
||||||
|
{schedule.triggerState}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cron Expression */}
|
||||||
|
<div className="mb-2">
|
||||||
|
<span className="inline-block bg-wing-card text-wing-text font-mono text-xs px-2 py-1 rounded">
|
||||||
|
{schedule.cronExpression}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
{schedule.description && (
|
||||||
|
<p className="text-sm text-wing-muted mb-3">{schedule.description}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Time Info */}
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-xs text-wing-muted mb-3">
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-wing-muted">다음 실행:</span>{' '}
|
||||||
|
{formatDateTime(schedule.nextFireTime)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-wing-muted">이전 실행:</span>{' '}
|
||||||
|
{formatDateTime(schedule.previousFireTime)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-wing-muted">등록일:</span>{' '}
|
||||||
|
{formatDateTime(schedule.createdAt)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-wing-muted">수정일:</span>{' '}
|
||||||
|
{formatDateTime(schedule.updatedAt)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex gap-2 pt-2 border-t border-wing-border/50">
|
||||||
|
<button
|
||||||
|
onClick={() => handleEditFromCard(schedule)}
|
||||||
|
className="flex-1 px-3 py-1.5 text-xs font-medium text-wing-accent bg-wing-accent/10 rounded-lg hover:bg-wing-accent/20 transition-colors"
|
||||||
|
>
|
||||||
|
편집
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
setConfirmAction({ type: 'toggle', schedule })
|
||||||
|
}
|
||||||
|
className={`flex-1 px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${
|
||||||
|
schedule.active
|
||||||
|
? 'text-amber-700 bg-amber-50 hover:bg-amber-100'
|
||||||
|
: 'text-emerald-700 bg-emerald-50 hover:bg-emerald-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{schedule.active ? '비활성화' : '활성화'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
setConfirmAction({ type: 'delete', schedule })
|
||||||
|
}
|
||||||
|
className="flex-1 px-3 py-1.5 text-xs font-medium text-red-700 bg-red-50 rounded-lg hover:bg-red-100 transition-colors"
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Confirm Modal */}
|
||||||
|
{confirmAction?.type === 'toggle' && (
|
||||||
|
<ConfirmModal
|
||||||
|
open
|
||||||
|
title="스케줄 상태 변경"
|
||||||
|
message={`${confirmAction.schedule.jobName} 스케줄을 ${
|
||||||
|
confirmAction.schedule.active ? '비활성화' : '활성화'
|
||||||
|
}하시겠습니까?`}
|
||||||
|
confirmLabel={confirmAction.schedule.active ? '비활성화' : '활성화'}
|
||||||
|
onConfirm={() => handleToggle(confirmAction.schedule)}
|
||||||
|
onCancel={() => setConfirmAction(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{confirmAction?.type === 'delete' && (
|
||||||
|
<ConfirmModal
|
||||||
|
open
|
||||||
|
title="스케줄 삭제"
|
||||||
|
message={`${confirmAction.schedule.jobName} 스케줄을 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.`}
|
||||||
|
confirmLabel="삭제"
|
||||||
|
confirmColor="bg-red-600 hover:bg-red-700"
|
||||||
|
onConfirm={() => handleDelete(confirmAction.schedule)}
|
||||||
|
onCancel={() => setConfirmAction(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
466
frontend/src/pages/Timeline.tsx
Normal file
466
frontend/src/pages/Timeline.tsx
Normal file
@ -0,0 +1,466 @@
|
|||||||
|
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||||
|
import { batchApi, type ExecutionInfo, type JobExecutionDto, type PeriodInfo, type ScheduleTimeline } from '../api/batchApi';
|
||||||
|
import { formatDateTime, calculateDuration } from '../utils/formatters';
|
||||||
|
import { usePoller } from '../hooks/usePoller';
|
||||||
|
import { useToastContext } from '../contexts/ToastContext';
|
||||||
|
import { getStatusColor } from '../components/StatusBadge';
|
||||||
|
import StatusBadge from '../components/StatusBadge';
|
||||||
|
import LoadingSpinner from '../components/LoadingSpinner';
|
||||||
|
import EmptyState from '../components/EmptyState';
|
||||||
|
|
||||||
|
type ViewType = 'day' | 'week' | 'month';
|
||||||
|
|
||||||
|
interface TooltipData {
|
||||||
|
jobName: string;
|
||||||
|
period: PeriodInfo;
|
||||||
|
execution: ExecutionInfo;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SelectedCell {
|
||||||
|
jobName: string;
|
||||||
|
periodKey: string;
|
||||||
|
periodLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VIEW_OPTIONS: { value: ViewType; label: string }[] = [
|
||||||
|
{ value: 'day', label: 'Day' },
|
||||||
|
{ value: 'week', label: 'Week' },
|
||||||
|
{ value: 'month', label: 'Month' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const LEGEND_ITEMS = [
|
||||||
|
{ status: 'COMPLETED', color: '#10b981', label: '완료' },
|
||||||
|
{ status: 'FAILED', color: '#ef4444', label: '실패' },
|
||||||
|
{ status: 'STARTED', color: '#3b82f6', label: '실행중' },
|
||||||
|
{ status: 'SCHEDULED', color: '#8b5cf6', label: '예정' },
|
||||||
|
{ status: 'NONE', color: '#e5e7eb', label: '없음' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const JOB_COL_WIDTH = 200;
|
||||||
|
const CELL_MIN_WIDTH = 80;
|
||||||
|
const POLLING_INTERVAL = 30000;
|
||||||
|
|
||||||
|
function formatDateStr(date: Date): string {
|
||||||
|
const y = date.getFullYear();
|
||||||
|
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const d = String(date.getDate()).padStart(2, '0');
|
||||||
|
return `${y}-${m}-${d}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shiftDate(date: Date, view: ViewType, delta: number): Date {
|
||||||
|
const next = new Date(date);
|
||||||
|
switch (view) {
|
||||||
|
case 'day':
|
||||||
|
next.setDate(next.getDate() + delta);
|
||||||
|
break;
|
||||||
|
case 'week':
|
||||||
|
next.setDate(next.getDate() + delta * 7);
|
||||||
|
break;
|
||||||
|
case 'month':
|
||||||
|
next.setMonth(next.getMonth() + delta);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRunning(status: string): boolean {
|
||||||
|
return status === 'STARTED' || status === 'STARTING';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Timeline() {
|
||||||
|
const { showToast } = useToastContext();
|
||||||
|
|
||||||
|
const [view, setView] = useState<ViewType>('day');
|
||||||
|
const [currentDate, setCurrentDate] = useState(() => new Date());
|
||||||
|
const [periodLabel, setPeriodLabel] = useState('');
|
||||||
|
const [periods, setPeriods] = useState<PeriodInfo[]>([]);
|
||||||
|
const [schedules, setSchedules] = useState<ScheduleTimeline[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
// Tooltip
|
||||||
|
const [tooltip, setTooltip] = useState<TooltipData | null>(null);
|
||||||
|
const tooltipTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
// Selected cell & detail panel
|
||||||
|
const [selectedCell, setSelectedCell] = useState<SelectedCell | null>(null);
|
||||||
|
const [detailExecutions, setDetailExecutions] = useState<JobExecutionDto[]>([]);
|
||||||
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
|
|
||||||
|
const loadTimeline = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const dateStr = formatDateStr(currentDate);
|
||||||
|
const result = await batchApi.getTimeline(view, dateStr);
|
||||||
|
setPeriodLabel(result.periodLabel);
|
||||||
|
setPeriods(result.periods);
|
||||||
|
setSchedules(result.schedules);
|
||||||
|
} catch (err) {
|
||||||
|
showToast('타임라인 조회 실패', 'error');
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [view, currentDate, showToast]);
|
||||||
|
|
||||||
|
usePoller(loadTimeline, POLLING_INTERVAL, [view, currentDate]);
|
||||||
|
|
||||||
|
const handlePrev = () => setCurrentDate((d) => shiftDate(d, view, -1));
|
||||||
|
const handleNext = () => setCurrentDate((d) => shiftDate(d, view, 1));
|
||||||
|
const handleToday = () => setCurrentDate(new Date());
|
||||||
|
|
||||||
|
const handleRefresh = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
await loadTimeline();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Tooltip handlers
|
||||||
|
const handleCellMouseEnter = (
|
||||||
|
e: React.MouseEvent,
|
||||||
|
jobName: string,
|
||||||
|
period: PeriodInfo,
|
||||||
|
execution: ExecutionInfo,
|
||||||
|
) => {
|
||||||
|
if (tooltipTimeoutRef.current) {
|
||||||
|
clearTimeout(tooltipTimeoutRef.current);
|
||||||
|
}
|
||||||
|
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||||
|
setTooltip({
|
||||||
|
jobName,
|
||||||
|
period,
|
||||||
|
execution,
|
||||||
|
x: rect.left + rect.width / 2,
|
||||||
|
y: rect.top,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCellMouseLeave = () => {
|
||||||
|
tooltipTimeoutRef.current = setTimeout(() => {
|
||||||
|
setTooltip(null);
|
||||||
|
}, 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clean up tooltip timeout
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (tooltipTimeoutRef.current) {
|
||||||
|
clearTimeout(tooltipTimeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Cell click -> detail panel
|
||||||
|
const handleCellClick = async (jobName: string, periodKey: string, periodLabel: string) => {
|
||||||
|
// Toggle off if clicking same cell
|
||||||
|
if (selectedCell?.jobName === jobName && selectedCell?.periodKey === periodKey) {
|
||||||
|
setSelectedCell(null);
|
||||||
|
setDetailExecutions([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedCell({ jobName, periodKey, periodLabel });
|
||||||
|
setDetailLoading(true);
|
||||||
|
setDetailExecutions([]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const executions = await batchApi.getPeriodExecutions(jobName, view, periodKey);
|
||||||
|
setDetailExecutions(executions);
|
||||||
|
} catch (err) {
|
||||||
|
showToast('구간 실행 이력 조회 실패', 'error');
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setDetailLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeDetail = () => {
|
||||||
|
setSelectedCell(null);
|
||||||
|
setDetailExecutions([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const gridTemplateColumns = `${JOB_COL_WIDTH}px repeat(${periods.length}, minmax(${CELL_MIN_WIDTH}px, 1fr))`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Controls */}
|
||||||
|
<div className="bg-wing-surface rounded-xl shadow-lg p-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
{/* View Toggle */}
|
||||||
|
<div className="flex rounded-lg border border-wing-border overflow-hidden">
|
||||||
|
{VIEW_OPTIONS.map((opt) => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
onClick={() => {
|
||||||
|
setView(opt.value);
|
||||||
|
setLoading(true);
|
||||||
|
}}
|
||||||
|
className={`px-4 py-1.5 text-sm font-medium transition-colors ${
|
||||||
|
view === opt.value
|
||||||
|
? 'bg-wing-accent text-white'
|
||||||
|
: 'bg-wing-surface text-wing-muted hover:bg-wing-accent/10'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={handlePrev}
|
||||||
|
className="px-3 py-1.5 text-sm text-wing-muted bg-wing-card rounded-lg hover:bg-wing-hover transition-colors"
|
||||||
|
>
|
||||||
|
← 이전
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleToday}
|
||||||
|
className="px-3 py-1.5 text-sm font-medium text-wing-accent bg-wing-accent/10 rounded-lg hover:bg-wing-accent/15 transition-colors"
|
||||||
|
>
|
||||||
|
오늘
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleNext}
|
||||||
|
className="px-3 py-1.5 text-sm text-wing-muted bg-wing-card rounded-lg hover:bg-wing-hover transition-colors"
|
||||||
|
>
|
||||||
|
다음 →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Period Label */}
|
||||||
|
<span className="text-sm font-semibold text-wing-text">
|
||||||
|
{periodLabel}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Refresh */}
|
||||||
|
<button
|
||||||
|
onClick={handleRefresh}
|
||||||
|
className="ml-auto px-3 py-1.5 text-sm text-wing-muted bg-wing-card rounded-lg hover:bg-wing-hover transition-colors"
|
||||||
|
>
|
||||||
|
새로고침
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
|
<div className="flex flex-wrap items-center gap-4 px-2">
|
||||||
|
{LEGEND_ITEMS.map((item) => (
|
||||||
|
<div key={item.status} className="flex items-center gap-1.5">
|
||||||
|
<div
|
||||||
|
className="w-4 h-4 rounded"
|
||||||
|
style={{ backgroundColor: item.color }}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-wing-muted">{item.label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timeline Grid */}
|
||||||
|
<div className="bg-wing-surface rounded-xl shadow-lg overflow-hidden">
|
||||||
|
{loading ? (
|
||||||
|
<LoadingSpinner />
|
||||||
|
) : schedules.length === 0 ? (
|
||||||
|
<EmptyState message="타임라인 데이터가 없습니다" sub="등록된 스케줄이 없거나 해당 기간에 실행 이력이 없습니다" />
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<div
|
||||||
|
className="grid min-w-max"
|
||||||
|
style={{ gridTemplateColumns }}
|
||||||
|
>
|
||||||
|
{/* Header Row */}
|
||||||
|
<div className="sticky left-0 z-20 bg-wing-card border-b border-r border-wing-border px-3 py-2 text-xs font-semibold text-wing-muted">
|
||||||
|
작업명
|
||||||
|
</div>
|
||||||
|
{periods.map((period) => (
|
||||||
|
<div
|
||||||
|
key={period.key}
|
||||||
|
className="bg-wing-card border-b border-r border-wing-border px-2 py-2 text-xs font-medium text-wing-muted text-center whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{period.label}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Data Rows */}
|
||||||
|
{schedules.map((schedule) => (
|
||||||
|
<>
|
||||||
|
{/* Job Name (sticky) */}
|
||||||
|
<div
|
||||||
|
key={`name-${schedule.jobName}`}
|
||||||
|
className="sticky left-0 z-10 bg-wing-surface border-b border-r border-wing-border px-3 py-2 text-xs font-medium text-wing-text truncate flex items-center"
|
||||||
|
title={schedule.jobName}
|
||||||
|
>
|
||||||
|
{schedule.jobName}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Execution Cells */}
|
||||||
|
{periods.map((period) => {
|
||||||
|
const exec = schedule.executions[period.key];
|
||||||
|
const hasExec = exec !== null && exec !== undefined;
|
||||||
|
const isSelected =
|
||||||
|
selectedCell?.jobName === schedule.jobName &&
|
||||||
|
selectedCell?.periodKey === period.key;
|
||||||
|
const running = hasExec && isRunning(exec.status);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`cell-${schedule.jobName}-${period.key}`}
|
||||||
|
className={`border-b border-r border-wing-border/50 p-1 cursor-pointer transition-all hover:opacity-80 ${
|
||||||
|
isSelected ? 'ring-2 ring-yellow-400 ring-inset' : ''
|
||||||
|
}`}
|
||||||
|
onClick={() =>
|
||||||
|
handleCellClick(schedule.jobName, period.key, period.label)
|
||||||
|
}
|
||||||
|
onMouseEnter={
|
||||||
|
hasExec
|
||||||
|
? (e) => handleCellMouseEnter(e, schedule.jobName, period, exec)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onMouseLeave={hasExec ? handleCellMouseLeave : undefined}
|
||||||
|
>
|
||||||
|
{hasExec && (
|
||||||
|
<div
|
||||||
|
className={`w-full h-6 rounded ${running ? 'animate-pulse' : ''}`}
|
||||||
|
style={{ backgroundColor: getStatusColor(exec.status) }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tooltip */}
|
||||||
|
{tooltip && (
|
||||||
|
<div
|
||||||
|
className="fixed z-50 pointer-events-none"
|
||||||
|
style={{
|
||||||
|
left: tooltip.x,
|
||||||
|
top: tooltip.y - 8,
|
||||||
|
transform: 'translate(-50%, -100%)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="bg-gray-900 text-white text-xs rounded-lg px-3 py-2 shadow-lg max-w-xs">
|
||||||
|
<div className="font-semibold mb-1">{tooltip.jobName}</div>
|
||||||
|
<div className="space-y-0.5 text-gray-300">
|
||||||
|
<div>기간: {tooltip.period.label}</div>
|
||||||
|
<div>
|
||||||
|
상태:{' '}
|
||||||
|
<span
|
||||||
|
className="font-medium"
|
||||||
|
style={{ color: getStatusColor(tooltip.execution.status) }}
|
||||||
|
>
|
||||||
|
{tooltip.execution.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{tooltip.execution.startTime && (
|
||||||
|
<div>시작: {formatDateTime(tooltip.execution.startTime)}</div>
|
||||||
|
)}
|
||||||
|
{tooltip.execution.endTime && (
|
||||||
|
<div>종료: {formatDateTime(tooltip.execution.endTime)}</div>
|
||||||
|
)}
|
||||||
|
{tooltip.execution.executionId && (
|
||||||
|
<div>실행 ID: {tooltip.execution.executionId}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Arrow */}
|
||||||
|
<div className="absolute left-1/2 -translate-x-1/2 top-full w-0 h-0 border-l-[6px] border-l-transparent border-r-[6px] border-r-transparent border-t-[6px] border-t-gray-900" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Detail Panel */}
|
||||||
|
{selectedCell && (
|
||||||
|
<div className="bg-wing-surface rounded-xl shadow-lg p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-bold text-wing-text">
|
||||||
|
{selectedCell.jobName}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-wing-muted mt-0.5">
|
||||||
|
구간: {selectedCell.periodLabel}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={closeDetail}
|
||||||
|
className="px-3 py-1.5 text-xs text-wing-muted bg-wing-card rounded-lg hover:bg-wing-hover transition-colors"
|
||||||
|
>
|
||||||
|
닫기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{detailLoading ? (
|
||||||
|
<LoadingSpinner className="py-6" />
|
||||||
|
) : detailExecutions.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
message="해당 구간에 실행 이력이 없습니다"
|
||||||
|
icon="📭"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-wing-border">
|
||||||
|
<th className="text-left py-2 px-3 text-xs font-semibold text-wing-muted">
|
||||||
|
실행 ID
|
||||||
|
</th>
|
||||||
|
<th className="text-left py-2 px-3 text-xs font-semibold text-wing-muted">
|
||||||
|
상태
|
||||||
|
</th>
|
||||||
|
<th className="text-left py-2 px-3 text-xs font-semibold text-wing-muted">
|
||||||
|
시작 시간
|
||||||
|
</th>
|
||||||
|
<th className="text-left py-2 px-3 text-xs font-semibold text-wing-muted">
|
||||||
|
종료 시간
|
||||||
|
</th>
|
||||||
|
<th className="text-left py-2 px-3 text-xs font-semibold text-wing-muted">
|
||||||
|
소요 시간
|
||||||
|
</th>
|
||||||
|
<th className="text-right py-2 px-3 text-xs font-semibold text-wing-muted">
|
||||||
|
상세
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{detailExecutions.map((exec) => (
|
||||||
|
<tr
|
||||||
|
key={exec.executionId}
|
||||||
|
className="border-b border-wing-border/50 hover:bg-wing-hover"
|
||||||
|
>
|
||||||
|
<td className="py-2 px-3 text-xs font-mono text-wing-text">
|
||||||
|
#{exec.executionId}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3">
|
||||||
|
<StatusBadge status={exec.status} />
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3 text-xs text-wing-muted">
|
||||||
|
{formatDateTime(exec.startTime)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3 text-xs text-wing-muted">
|
||||||
|
{formatDateTime(exec.endTime)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3 text-xs text-wing-muted">
|
||||||
|
{calculateDuration(exec.startTime, exec.endTime)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3 text-right">
|
||||||
|
<a
|
||||||
|
href={`/executions/${exec.executionId}`}
|
||||||
|
className="text-xs text-wing-accent hover:text-wing-accent font-medium"
|
||||||
|
>
|
||||||
|
상세
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
frontend/src/theme/base.css
Normal file
25
frontend/src/theme/base.css
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
body {
|
||||||
|
font-family: 'Noto Sans KR', sans-serif;
|
||||||
|
background: var(--wing-bg);
|
||||||
|
color: var(--wing-text);
|
||||||
|
transition: background-color 0.2s ease, color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar styling for dark mode */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: var(--wing-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--wing-muted);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--wing-accent);
|
||||||
|
}
|
||||||
66
frontend/src/theme/tokens.css
Normal file
66
frontend/src/theme/tokens.css
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
/* Dark theme (default) */
|
||||||
|
:root,
|
||||||
|
[data-theme='dark'] {
|
||||||
|
--wing-bg: #020617;
|
||||||
|
--wing-surface: #0f172a;
|
||||||
|
--wing-card: #1e293b;
|
||||||
|
--wing-border: #1e3a5f;
|
||||||
|
--wing-text: #e2e8f0;
|
||||||
|
--wing-muted: #64748b;
|
||||||
|
--wing-accent: #3b82f6;
|
||||||
|
--wing-danger: #ef4444;
|
||||||
|
--wing-warning: #f59e0b;
|
||||||
|
--wing-success: #22c55e;
|
||||||
|
--wing-glass: rgba(15, 23, 42, 0.92);
|
||||||
|
--wing-glass-dense: rgba(15, 23, 42, 0.95);
|
||||||
|
--wing-overlay: rgba(2, 6, 23, 0.42);
|
||||||
|
--wing-card-alpha: rgba(30, 41, 59, 0.55);
|
||||||
|
--wing-subtle: rgba(255, 255, 255, 0.03);
|
||||||
|
--wing-hover: rgba(255, 255, 255, 0.05);
|
||||||
|
--wing-input-bg: #0f172a;
|
||||||
|
--wing-input-border: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Light theme */
|
||||||
|
[data-theme='light'] {
|
||||||
|
--wing-bg: #e2e8f0;
|
||||||
|
--wing-surface: #ffffff;
|
||||||
|
--wing-card: #f1f5f9;
|
||||||
|
--wing-border: #94a3b8;
|
||||||
|
--wing-text: #0f172a;
|
||||||
|
--wing-muted: #64748b;
|
||||||
|
--wing-accent: #2563eb;
|
||||||
|
--wing-danger: #dc2626;
|
||||||
|
--wing-warning: #d97706;
|
||||||
|
--wing-success: #16a34a;
|
||||||
|
--wing-glass: rgba(255, 255, 255, 0.92);
|
||||||
|
--wing-glass-dense: rgba(255, 255, 255, 0.95);
|
||||||
|
--wing-overlay: rgba(0, 0, 0, 0.25);
|
||||||
|
--wing-card-alpha: rgba(226, 232, 240, 0.6);
|
||||||
|
--wing-subtle: rgba(0, 0, 0, 0.03);
|
||||||
|
--wing-hover: rgba(0, 0, 0, 0.04);
|
||||||
|
--wing-input-bg: #ffffff;
|
||||||
|
--wing-input-border: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--color-wing-bg: var(--wing-bg);
|
||||||
|
--color-wing-surface: var(--wing-surface);
|
||||||
|
--color-wing-card: var(--wing-card);
|
||||||
|
--color-wing-border: var(--wing-border);
|
||||||
|
--color-wing-text: var(--wing-text);
|
||||||
|
--color-wing-muted: var(--wing-muted);
|
||||||
|
--color-wing-accent: var(--wing-accent);
|
||||||
|
--color-wing-danger: var(--wing-danger);
|
||||||
|
--color-wing-warning: var(--wing-warning);
|
||||||
|
--color-wing-success: var(--wing-success);
|
||||||
|
--color-wing-glass: var(--wing-glass);
|
||||||
|
--color-wing-glass-dense: var(--wing-glass-dense);
|
||||||
|
--color-wing-overlay: var(--wing-overlay);
|
||||||
|
--color-wing-card-alpha: var(--wing-card-alpha);
|
||||||
|
--color-wing-subtle: var(--wing-subtle);
|
||||||
|
--color-wing-hover: var(--wing-hover);
|
||||||
|
--color-wing-input-bg: var(--wing-input-bg);
|
||||||
|
--color-wing-input-border: var(--wing-input-border);
|
||||||
|
--font-sans: 'Noto Sans KR', sans-serif;
|
||||||
|
}
|
||||||
153
frontend/src/utils/cronPreview.ts
Normal file
153
frontend/src/utils/cronPreview.ts
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
/**
|
||||||
|
* Quartz 형식 Cron 표현식의 다음 실행 시간을 계산한다.
|
||||||
|
* 형식: 초 분 시 일 월 요일
|
||||||
|
*/
|
||||||
|
export function getNextExecutions(cron: string, count: number): Date[] {
|
||||||
|
const parts = cron.trim().split(/\s+/);
|
||||||
|
if (parts.length < 6) return [];
|
||||||
|
|
||||||
|
const [secField, minField, hourField, dayField, monthField, dowField] = parts;
|
||||||
|
|
||||||
|
if (hasUnsupportedToken(dayField) || hasUnsupportedToken(dowField)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const seconds = parseField(secField, 0, 59);
|
||||||
|
const minutes = parseField(minField, 0, 59);
|
||||||
|
const hours = parseField(hourField, 0, 23);
|
||||||
|
const daysOfMonth = parseField(dayField, 1, 31);
|
||||||
|
const months = parseField(monthField, 1, 12);
|
||||||
|
const daysOfWeek = parseDowField(dowField);
|
||||||
|
|
||||||
|
if (!seconds || !minutes || !hours || !months) return [];
|
||||||
|
|
||||||
|
const results: Date[] = [];
|
||||||
|
const now = new Date();
|
||||||
|
const cursor = new Date(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours(), now.getMinutes(), now.getSeconds() + 1);
|
||||||
|
cursor.setMilliseconds(0);
|
||||||
|
|
||||||
|
const limit = new Date(now.getTime() + 365 * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
while (results.length < count && cursor.getTime() <= limit.getTime()) {
|
||||||
|
const month = cursor.getMonth() + 1;
|
||||||
|
if (!months.includes(month)) {
|
||||||
|
cursor.setMonth(cursor.getMonth() + 1, 1);
|
||||||
|
cursor.setHours(0, 0, 0, 0);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const day = cursor.getDate();
|
||||||
|
const dayMatches = daysOfMonth ? daysOfMonth.includes(day) : true;
|
||||||
|
const dowMatches = daysOfWeek ? daysOfWeek.includes(cursor.getDay()) : true;
|
||||||
|
|
||||||
|
const needDayCheck = dayField !== '?' && dowField !== '?';
|
||||||
|
const dayOk = needDayCheck ? dayMatches && dowMatches : dayMatches && dowMatches;
|
||||||
|
|
||||||
|
if (!dayOk) {
|
||||||
|
cursor.setDate(cursor.getDate() + 1);
|
||||||
|
cursor.setHours(0, 0, 0, 0);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hour = cursor.getHours();
|
||||||
|
if (!hours.includes(hour)) {
|
||||||
|
cursor.setHours(cursor.getHours() + 1, 0, 0, 0);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const minute = cursor.getMinutes();
|
||||||
|
if (!minutes.includes(minute)) {
|
||||||
|
cursor.setMinutes(cursor.getMinutes() + 1, 0, 0);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const second = cursor.getSeconds();
|
||||||
|
if (!seconds.includes(second)) {
|
||||||
|
cursor.setSeconds(cursor.getSeconds() + 1, 0);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
results.push(new Date(cursor));
|
||||||
|
cursor.setSeconds(cursor.getSeconds() + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasUnsupportedToken(field: string): boolean {
|
||||||
|
return /[LW#]/.test(field);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseField(field: string, min: number, max: number): number[] | null {
|
||||||
|
if (field === '?') return null;
|
||||||
|
if (field === '*') return range(min, max);
|
||||||
|
|
||||||
|
const values = new Set<number>();
|
||||||
|
|
||||||
|
for (const part of field.split(',')) {
|
||||||
|
const stepMatch = part.match(/^(.+)\/(\d+)$/);
|
||||||
|
if (stepMatch) {
|
||||||
|
const [, base, stepStr] = stepMatch;
|
||||||
|
const step = parseInt(stepStr, 10);
|
||||||
|
let start = min;
|
||||||
|
let end = max;
|
||||||
|
|
||||||
|
if (base === '*') {
|
||||||
|
start = min;
|
||||||
|
} else if (base.includes('-')) {
|
||||||
|
const [lo, hi] = base.split('-').map(Number);
|
||||||
|
start = lo;
|
||||||
|
end = hi;
|
||||||
|
} else {
|
||||||
|
start = parseInt(base, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let v = start; v <= end; v += step) {
|
||||||
|
if (v >= min && v <= max) values.add(v);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rangeMatch = part.match(/^(\d+)-(\d+)$/);
|
||||||
|
if (rangeMatch) {
|
||||||
|
const lo = parseInt(rangeMatch[1], 10);
|
||||||
|
const hi = parseInt(rangeMatch[2], 10);
|
||||||
|
for (let v = lo; v <= hi; v++) {
|
||||||
|
if (v >= min && v <= max) values.add(v);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const num = parseInt(part, 10);
|
||||||
|
if (!isNaN(num) && num >= min && num <= max) {
|
||||||
|
values.add(num);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return values.size > 0 ? Array.from(values).sort((a, b) => a - b) : range(min, max);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDowField(field: string): number[] | null {
|
||||||
|
if (field === '?' || field === '*') return null;
|
||||||
|
|
||||||
|
const dayMap: Record<string, string> = {
|
||||||
|
SUN: '0', MON: '1', TUE: '2', WED: '3', THU: '4', FRI: '5', SAT: '6',
|
||||||
|
};
|
||||||
|
|
||||||
|
let normalized = field.toUpperCase();
|
||||||
|
for (const [name, num] of Object.entries(dayMap)) {
|
||||||
|
normalized = normalized.replace(new RegExp(name, 'g'), num);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quartz uses 1=SUN..7=SAT, convert to JS 0=SUN..6=SAT
|
||||||
|
const parsed = parseField(normalized, 1, 7);
|
||||||
|
if (!parsed) return null;
|
||||||
|
|
||||||
|
return parsed.map((v) => v - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function range(min: number, max: number): number[] {
|
||||||
|
const result: number[] = [];
|
||||||
|
for (let i = min; i <= max; i++) result.push(i);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
58
frontend/src/utils/formatters.ts
Normal file
58
frontend/src/utils/formatters.ts
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
export function formatDateTime(dateTimeStr: string | null | undefined): string {
|
||||||
|
if (!dateTimeStr) return '-';
|
||||||
|
try {
|
||||||
|
const date = new Date(dateTimeStr);
|
||||||
|
if (isNaN(date.getTime())) return '-';
|
||||||
|
const y = date.getFullYear();
|
||||||
|
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const d = String(date.getDate()).padStart(2, '0');
|
||||||
|
const h = String(date.getHours()).padStart(2, '0');
|
||||||
|
const min = String(date.getMinutes()).padStart(2, '0');
|
||||||
|
const s = String(date.getSeconds()).padStart(2, '0');
|
||||||
|
return `${y}-${m}-${d} ${h}:${min}:${s}`;
|
||||||
|
} catch {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateTimeShort(dateTimeStr: string | null | undefined): string {
|
||||||
|
if (!dateTimeStr) return '-';
|
||||||
|
try {
|
||||||
|
const date = new Date(dateTimeStr);
|
||||||
|
if (isNaN(date.getTime())) return '-';
|
||||||
|
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const d = String(date.getDate()).padStart(2, '0');
|
||||||
|
const h = String(date.getHours()).padStart(2, '0');
|
||||||
|
const min = String(date.getMinutes()).padStart(2, '0');
|
||||||
|
return `${m}/${d} ${h}:${min}`;
|
||||||
|
} catch {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDuration(ms: number | null | undefined): string {
|
||||||
|
if (ms == null || ms < 0) return '-';
|
||||||
|
const totalSeconds = Math.floor(ms / 1000);
|
||||||
|
const hours = Math.floor(totalSeconds / 3600);
|
||||||
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
|
||||||
|
if (hours > 0) return `${hours}시간 ${minutes}분 ${seconds}초`;
|
||||||
|
if (minutes > 0) return `${minutes}분 ${seconds}초`;
|
||||||
|
return `${seconds}초`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calculateDuration(
|
||||||
|
startTime: string | null | undefined,
|
||||||
|
endTime: string | null | undefined,
|
||||||
|
): string {
|
||||||
|
if (!startTime) return '-';
|
||||||
|
const start = new Date(startTime).getTime();
|
||||||
|
if (isNaN(start)) return '-';
|
||||||
|
|
||||||
|
if (!endTime) return '실행 중...';
|
||||||
|
const end = new Date(endTime).getTime();
|
||||||
|
if (isNaN(end)) return '-';
|
||||||
|
|
||||||
|
return formatDuration(end - start);
|
||||||
|
}
|
||||||
28
frontend/tsconfig.app.json
Normal file
28
frontend/tsconfig.app.json
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
7
frontend/tsconfig.json
Normal file
7
frontend/tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
26
frontend/tsconfig.node.json
Normal file
26
frontend/tsconfig.node.json
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
21
frontend/vite.config.ts
Normal file
21
frontend/vite.config.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/snp-api/api': {
|
||||||
|
target: 'http://localhost:8041',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
base: '/snp-api/',
|
||||||
|
build: {
|
||||||
|
outDir: '../src/main/resources/static',
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
6
package-lock.json
generated
Normal file
6
package-lock.json
generated
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "snp-batch-validation",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {}
|
||||||
|
}
|
||||||
43
pom.xml
43
pom.xml
@ -13,7 +13,7 @@
|
|||||||
</parent>
|
</parent>
|
||||||
|
|
||||||
<groupId>com.snp</groupId>
|
<groupId>com.snp</groupId>
|
||||||
<artifactId>snp-batch</artifactId>
|
<artifactId>snp-batch-validation</artifactId>
|
||||||
<version>1.0.0</version>
|
<version>1.0.0</version>
|
||||||
<name>SNP Batch</name>
|
<name>SNP Batch</name>
|
||||||
<description>Spring Batch project for JSON to PostgreSQL with Web GUI</description>
|
<description>Spring Batch project for JSON to PostgreSQL with Web GUI</description>
|
||||||
@ -111,6 +111,12 @@
|
|||||||
<version>2.3.0</version>
|
<version>2.3.0</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Kafka -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.kafka</groupId>
|
||||||
|
<artifactId>spring-kafka</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<!-- Caffeine Cache -->
|
<!-- Caffeine Cache -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.github.ben-manes.caffeine</groupId>
|
<groupId>com.github.ben-manes.caffeine</groupId>
|
||||||
@ -137,6 +143,12 @@
|
|||||||
<artifactId>spring-batch-test</artifactId>
|
<artifactId>spring-batch-test</artifactId>
|
||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.google.code.findbugs</groupId>
|
||||||
|
<artifactId>jsr305</artifactId>
|
||||||
|
<version>3.0.2</version>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
@ -154,6 +166,35 @@
|
|||||||
</excludes>
|
</excludes>
|
||||||
</configuration>
|
</configuration>
|
||||||
</plugin>
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<groupId>com.github.eirslett</groupId>
|
||||||
|
<artifactId>frontend-maven-plugin</artifactId>
|
||||||
|
<version>1.15.1</version>
|
||||||
|
<configuration>
|
||||||
|
<workingDirectory>frontend</workingDirectory>
|
||||||
|
<nodeVersion>v20.19.0</nodeVersion>
|
||||||
|
</configuration>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<id>install-node-and-npm</id>
|
||||||
|
<goals><goal>install-node-and-npm</goal></goals>
|
||||||
|
</execution>
|
||||||
|
<execution>
|
||||||
|
<id>npm-install</id>
|
||||||
|
<goals><goal>npm</goal></goals>
|
||||||
|
<configuration>
|
||||||
|
<arguments>install</arguments>
|
||||||
|
</configuration>
|
||||||
|
</execution>
|
||||||
|
<execution>
|
||||||
|
<id>npm-build</id>
|
||||||
|
<goals><goal>npm</goal></goals>
|
||||||
|
<configuration>
|
||||||
|
<arguments>run build</arguments>
|
||||||
|
</configuration>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
<plugin>
|
<plugin>
|
||||||
<groupId>org.apache.maven.plugins</groupId>
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
<artifactId>maven-compiler-plugin</artifactId>
|
<artifactId>maven-compiler-plugin</artifactId>
|
||||||
|
|||||||
149
sql/chnprmship-cache-diag.sql
Normal file
149
sql/chnprmship-cache-diag.sql
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- ChnPrmShip 캐시 검증 진단 쿼리
|
||||||
|
-- 대상: t_std_snp_data.ais_target (일별 파티션)
|
||||||
|
-- 목적: 최근 2일 내 대상 MMSI별 최종위치 캐싱 검증
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 0. 대상 MMSI 임시 테이블 생성
|
||||||
|
-- ============================================================
|
||||||
|
CREATE TEMP TABLE tmp_chn_mmsi (mmsi BIGINT PRIMARY KEY);
|
||||||
|
|
||||||
|
-- psql에서 실행:
|
||||||
|
-- \copy tmp_chn_mmsi(mmsi) FROM 'chnprmship-mmsi.txt'
|
||||||
|
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 1. 기본 현황: 대상 MMSI 중 최근 2일 내 데이터 존재 여부
|
||||||
|
-- ============================================================
|
||||||
|
SELECT
|
||||||
|
(SELECT COUNT(*) FROM tmp_chn_mmsi) AS total_target_mmsi,
|
||||||
|
COUNT(DISTINCT a.mmsi) AS mmsi_with_data_2d,
|
||||||
|
(SELECT COUNT(*) FROM tmp_chn_mmsi) - COUNT(DISTINCT a.mmsi) AS mmsi_without_data_2d,
|
||||||
|
ROUND(COUNT(DISTINCT a.mmsi) * 100.0
|
||||||
|
/ NULLIF((SELECT COUNT(*) FROM tmp_chn_mmsi), 0), 1) AS hit_rate_pct
|
||||||
|
FROM t_std_snp_data.ais_target a
|
||||||
|
JOIN tmp_chn_mmsi t ON a.mmsi = t.mmsi
|
||||||
|
WHERE a.message_timestamp >= NOW() - INTERVAL '2 days';
|
||||||
|
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 2. 워밍업 시뮬레이션: 최근 2일 내 MMSI별 최종위치
|
||||||
|
-- (수정 후 findLatestByMmsiIn 쿼리와 동일하게 동작)
|
||||||
|
-- ============================================================
|
||||||
|
SELECT COUNT(*) AS cached_count,
|
||||||
|
MIN(message_timestamp) AS oldest_cached,
|
||||||
|
MAX(message_timestamp) AS newest_cached,
|
||||||
|
NOW() - MAX(message_timestamp) AS newest_age
|
||||||
|
FROM (
|
||||||
|
SELECT DISTINCT ON (a.mmsi) a.mmsi, a.message_timestamp
|
||||||
|
FROM t_std_snp_data.ais_target a
|
||||||
|
JOIN tmp_chn_mmsi t ON a.mmsi = t.mmsi
|
||||||
|
WHERE a.message_timestamp >= NOW() - INTERVAL '2 days'
|
||||||
|
ORDER BY a.mmsi, a.message_timestamp DESC
|
||||||
|
) latest;
|
||||||
|
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 3. MMSI별 최종위치 상세 (최근 2일 내, 최신순 상위 30건)
|
||||||
|
-- ============================================================
|
||||||
|
SELECT DISTINCT ON (a.mmsi)
|
||||||
|
a.mmsi,
|
||||||
|
a.message_timestamp,
|
||||||
|
a.name,
|
||||||
|
a.vessel_type,
|
||||||
|
a.lat,
|
||||||
|
a.lon,
|
||||||
|
a.sog,
|
||||||
|
a.cog,
|
||||||
|
a.heading,
|
||||||
|
NOW() - a.message_timestamp AS data_age
|
||||||
|
FROM t_std_snp_data.ais_target a
|
||||||
|
JOIN tmp_chn_mmsi t ON a.mmsi = t.mmsi
|
||||||
|
WHERE a.message_timestamp >= NOW() - INTERVAL '2 days'
|
||||||
|
ORDER BY a.mmsi, a.message_timestamp DESC
|
||||||
|
LIMIT 30;
|
||||||
|
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 4. 데이터 없는 대상 MMSI (최근 2일 내 DB에 없는 선박)
|
||||||
|
-- ============================================================
|
||||||
|
SELECT t.mmsi AS missing_mmsi
|
||||||
|
FROM tmp_chn_mmsi t
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT DISTINCT mmsi
|
||||||
|
FROM t_std_snp_data.ais_target
|
||||||
|
WHERE mmsi IN (SELECT mmsi FROM tmp_chn_mmsi)
|
||||||
|
AND message_timestamp >= NOW() - INTERVAL '2 days'
|
||||||
|
) a ON t.mmsi = a.mmsi
|
||||||
|
WHERE a.mmsi IS NULL
|
||||||
|
ORDER BY t.mmsi;
|
||||||
|
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 5. 시간대별 분포 (2일 기준 세부 확인)
|
||||||
|
-- ============================================================
|
||||||
|
SELECT
|
||||||
|
'6시간 이내' AS time_range,
|
||||||
|
COUNT(DISTINCT mmsi) AS distinct_mmsi
|
||||||
|
FROM t_std_snp_data.ais_target a
|
||||||
|
JOIN tmp_chn_mmsi t ON a.mmsi = t.mmsi
|
||||||
|
WHERE a.message_timestamp >= NOW() - INTERVAL '6 hours'
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
SELECT '12시간 이내', COUNT(DISTINCT mmsi)
|
||||||
|
FROM t_std_snp_data.ais_target a
|
||||||
|
JOIN tmp_chn_mmsi t ON a.mmsi = t.mmsi
|
||||||
|
WHERE a.message_timestamp >= NOW() - INTERVAL '12 hours'
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
SELECT '1일 이내', COUNT(DISTINCT mmsi)
|
||||||
|
FROM t_std_snp_data.ais_target a
|
||||||
|
JOIN tmp_chn_mmsi t ON a.mmsi = t.mmsi
|
||||||
|
WHERE a.message_timestamp >= NOW() - INTERVAL '1 day'
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
SELECT '2일 이내', COUNT(DISTINCT mmsi)
|
||||||
|
FROM t_std_snp_data.ais_target a
|
||||||
|
JOIN tmp_chn_mmsi t ON a.mmsi = t.mmsi
|
||||||
|
WHERE a.message_timestamp >= NOW() - INTERVAL '2 days'
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
SELECT '전체(무제한)', COUNT(DISTINCT mmsi)
|
||||||
|
FROM t_std_snp_data.ais_target a
|
||||||
|
JOIN tmp_chn_mmsi t ON a.mmsi = t.mmsi;
|
||||||
|
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 6. 파티션별 대상 데이터 분포
|
||||||
|
-- ============================================================
|
||||||
|
SELECT
|
||||||
|
tableoid::regclass AS partition_name,
|
||||||
|
COUNT(*) AS row_count,
|
||||||
|
COUNT(DISTINCT mmsi) AS distinct_mmsi,
|
||||||
|
MIN(message_timestamp) AS min_ts,
|
||||||
|
MAX(message_timestamp) AS max_ts
|
||||||
|
FROM t_std_snp_data.ais_target a
|
||||||
|
JOIN tmp_chn_mmsi t ON a.mmsi = t.mmsi
|
||||||
|
GROUP BY tableoid::regclass
|
||||||
|
ORDER BY max_ts DESC;
|
||||||
|
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 7. 전체 ais_target 파티션 현황
|
||||||
|
-- ============================================================
|
||||||
|
SELECT
|
||||||
|
c.relname AS partition_name,
|
||||||
|
pg_size_pretty(pg_relation_size(c.oid)) AS table_size,
|
||||||
|
s.n_live_tup AS estimated_rows
|
||||||
|
FROM pg_inherits i
|
||||||
|
JOIN pg_class c ON c.oid = i.inhrelid
|
||||||
|
JOIN pg_stat_user_tables s ON s.relid = c.oid
|
||||||
|
WHERE i.inhparent = 't_std_snp_data.ais_target'::regclass
|
||||||
|
ORDER BY c.relname DESC;
|
||||||
|
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 정리
|
||||||
|
-- ============================================================
|
||||||
|
DROP TABLE IF EXISTS tmp_chn_mmsi;
|
||||||
@ -2,10 +2,11 @@ package com.snp.batch;
|
|||||||
|
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
import org.springframework.boot.autoconfigure.kafka.KafkaAutoConfiguration;
|
||||||
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
|
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
|
||||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||||
|
|
||||||
@SpringBootApplication
|
@SpringBootApplication(exclude = KafkaAutoConfiguration.class)
|
||||||
@EnableScheduling
|
@EnableScheduling
|
||||||
@ConfigurationPropertiesScan
|
@ConfigurationPropertiesScan
|
||||||
public class SnpBatchApplication {
|
public class SnpBatchApplication {
|
||||||
|
|||||||
@ -43,4 +43,22 @@ public abstract class BaseEntity {
|
|||||||
* 컬럼: updated_by (VARCHAR(100))
|
* 컬럼: updated_by (VARCHAR(100))
|
||||||
*/
|
*/
|
||||||
private String updatedBy;
|
private String updatedBy;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 배치 잡 실행 ID
|
||||||
|
* 컬럼: job_execution_id (int8)
|
||||||
|
*/
|
||||||
|
private Long jobExecutionId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 배치 공통 필드 설정을 위한 편의 메서드
|
||||||
|
*/
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public <T extends BaseEntity> T setBatchInfo(Long jobExecutionId, String createdBy) {
|
||||||
|
this.jobExecutionId = jobExecutionId;
|
||||||
|
this.createdBy = createdBy;
|
||||||
|
// 필요시 생성일시 강제 설정 (JPA Auditing을 안 쓸 경우)
|
||||||
|
if (this.createdAt == null) this.createdAt = LocalDateTime.now();
|
||||||
|
return (T) this;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,140 @@
|
|||||||
|
package com.snp.batch.common.batch.listener;
|
||||||
|
|
||||||
|
import com.snp.batch.service.RecollectionHistoryService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
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.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class RecollectionJobExecutionListener implements JobExecutionListener {
|
||||||
|
|
||||||
|
private static final String ORIGINAL_LAST_SUCCESS_DATE_KEY = "originalLastSuccessDate";
|
||||||
|
|
||||||
|
private final RecollectionHistoryService recollectionHistoryService;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void beforeJob(JobExecution jobExecution) {
|
||||||
|
String executionMode = jobExecution.getJobParameters()
|
||||||
|
.getString("executionMode", "NORMAL");
|
||||||
|
|
||||||
|
if (!"RECOLLECT".equals(executionMode)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Long jobExecutionId = jobExecution.getId();
|
||||||
|
String jobName = jobExecution.getJobInstance().getJobName();
|
||||||
|
String apiKey = jobExecution.getJobParameters().getString("apiKey");
|
||||||
|
String executor = jobExecution.getJobParameters().getString("executor", "SYSTEM");
|
||||||
|
String reason = jobExecution.getJobParameters().getString("reason");
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. 현재 last_success_date를 JobExecutionContext에 저장 (afterJob에서 복원용)
|
||||||
|
if (apiKey != null) {
|
||||||
|
LocalDateTime originalDate = recollectionHistoryService.getLastSuccessDate(apiKey);
|
||||||
|
if (originalDate != null) {
|
||||||
|
jobExecution.getExecutionContext()
|
||||||
|
.putString(ORIGINAL_LAST_SUCCESS_DATE_KEY, originalDate.toString());
|
||||||
|
log.info("[RecollectionListener] 원본 last_success_date 저장: apiKey={}, date={}",
|
||||||
|
apiKey, originalDate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 재수집 이력 기록
|
||||||
|
recollectionHistoryService.recordStart(
|
||||||
|
jobName, jobExecutionId, apiKey, executor, reason);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("[RecollectionListener] beforeJob 처리 실패: jobExecutionId={}", jobExecutionId, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void afterJob(JobExecution jobExecution) {
|
||||||
|
String executionMode = jobExecution.getJobParameters()
|
||||||
|
.getString("executionMode", "NORMAL");
|
||||||
|
|
||||||
|
if (!"RECOLLECT".equals(executionMode)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Long jobExecutionId = jobExecution.getId();
|
||||||
|
String status = jobExecution.getStatus().name();
|
||||||
|
String apiKey = jobExecution.getJobParameters().getString("apiKey");
|
||||||
|
|
||||||
|
// Step별 통계 집계
|
||||||
|
long totalRead = 0;
|
||||||
|
long totalWrite = 0;
|
||||||
|
long totalSkip = 0;
|
||||||
|
int totalApiCalls = 0;
|
||||||
|
|
||||||
|
for (StepExecution step : jobExecution.getStepExecutions()) {
|
||||||
|
totalRead += step.getReadCount();
|
||||||
|
totalWrite += step.getWriteCount();
|
||||||
|
totalSkip += step.getReadSkipCount()
|
||||||
|
+ step.getProcessSkipCount()
|
||||||
|
+ step.getWriteSkipCount();
|
||||||
|
|
||||||
|
if (step.getExecutionContext().containsKey("totalApiCalls")) {
|
||||||
|
totalApiCalls += step.getExecutionContext().getInt("totalApiCalls", 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 실패 사유 추출
|
||||||
|
String failureReason = null;
|
||||||
|
if ("FAILED".equals(status)) {
|
||||||
|
failureReason = jobExecution.getExitStatus().getExitDescription();
|
||||||
|
if (failureReason == null || failureReason.isEmpty()) {
|
||||||
|
failureReason = jobExecution.getStepExecutions().stream()
|
||||||
|
.filter(s -> "FAILED".equals(s.getStatus().name()))
|
||||||
|
.map(s -> s.getExitStatus().getExitDescription())
|
||||||
|
.filter(desc -> desc != null && !desc.isEmpty())
|
||||||
|
.findFirst()
|
||||||
|
.orElse("Unknown error");
|
||||||
|
}
|
||||||
|
if (failureReason != null && failureReason.length() > 2000) {
|
||||||
|
failureReason = failureReason.substring(0, 2000) + "...";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 재수집 이력 완료 기록
|
||||||
|
try {
|
||||||
|
recollectionHistoryService.recordCompletion(
|
||||||
|
jobExecutionId, status,
|
||||||
|
totalRead, totalWrite, totalSkip,
|
||||||
|
totalApiCalls, null,
|
||||||
|
failureReason);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("[RecollectionListener] 재수집 이력 완료 기록 실패: jobExecutionId={}", jobExecutionId, e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. last_success_date 복원 (Tasklet이 NOW()로 업데이트한 것을 되돌림)
|
||||||
|
// 재수집은 과거 데이터 재처리이므로 last_success_date를 변경하면 안 됨
|
||||||
|
// recordCompletion 실패와 무관하게 반드시 실행되어야 함
|
||||||
|
try {
|
||||||
|
if (apiKey != null) {
|
||||||
|
String originalDateStr = jobExecution.getExecutionContext()
|
||||||
|
.getString(ORIGINAL_LAST_SUCCESS_DATE_KEY, null);
|
||||||
|
log.info("[RecollectionListener] last_success_date 복원 시도: apiKey={}, originalDateStr={}",
|
||||||
|
apiKey, originalDateStr);
|
||||||
|
if (originalDateStr != null) {
|
||||||
|
LocalDateTime originalDate = LocalDateTime.parse(originalDateStr);
|
||||||
|
recollectionHistoryService.restoreLastSuccessDate(apiKey, originalDate);
|
||||||
|
log.info("[RecollectionListener] last_success_date 복원 완료: apiKey={}, date={}",
|
||||||
|
apiKey, originalDate);
|
||||||
|
} else {
|
||||||
|
log.warn("[RecollectionListener] originalLastSuccessDate가 ExecutionContext에 없음: apiKey={}",
|
||||||
|
apiKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("[RecollectionListener] last_success_date 복원 실패: apiKey={}, jobExecutionId={}",
|
||||||
|
apiKey, jobExecutionId, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -170,6 +170,125 @@ public abstract class BaseApiReader<T> implements ItemReader<T> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected <T, R> List<R> executeWrapperApiCall(
|
||||||
|
String baseUrl,
|
||||||
|
String path,
|
||||||
|
Class<T> responseWrapperClass, // Stat5CodeApiResponse.class 등을 받음
|
||||||
|
Function<T, List<R>> listExtractor, // 결과 객체에서 리스트를 꺼내는 로직
|
||||||
|
BatchApiLogService logService) {
|
||||||
|
|
||||||
|
String fullUri = UriComponentsBuilder.fromHttpUrl(baseUrl)
|
||||||
|
.path(path)
|
||||||
|
.build()
|
||||||
|
.toUriString();
|
||||||
|
|
||||||
|
long startTime = System.currentTimeMillis();
|
||||||
|
int statusCode = 200;
|
||||||
|
String errorMessage = null;
|
||||||
|
Long responseSize = 0L;
|
||||||
|
|
||||||
|
try {
|
||||||
|
log.info("[{}] API 요청 시작: {}", getReaderName(), fullUri);
|
||||||
|
|
||||||
|
// 1. List<R>이 아닌 Wrapper 객체(T)로 받아옵니다.
|
||||||
|
T response = webClient.get()
|
||||||
|
.uri(uriBuilder -> uriBuilder.path(path).build())
|
||||||
|
.retrieve()
|
||||||
|
.bodyToMono(responseWrapperClass)
|
||||||
|
.block();
|
||||||
|
|
||||||
|
// 2. 추출 함수(listExtractor)를 사용하여 내부 리스트를 꺼냅니다.
|
||||||
|
List<R> result = (response != null) ? listExtractor.apply(response) : Collections.emptyList();
|
||||||
|
|
||||||
|
responseSize = (long) result.size();
|
||||||
|
return result;
|
||||||
|
|
||||||
|
} catch (WebClientResponseException e) {
|
||||||
|
statusCode = e.getStatusCode().value();
|
||||||
|
errorMessage = String.format("API Error: %s", e.getResponseBodyAsString());
|
||||||
|
throw e;
|
||||||
|
} catch (Exception e) {
|
||||||
|
statusCode = 500;
|
||||||
|
errorMessage = String.format("System Error: %s", e.getMessage());
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
long duration = System.currentTimeMillis() - startTime;
|
||||||
|
|
||||||
|
logService.saveLog(BatchApiLog.builder()
|
||||||
|
.apiRequestLocation(getReaderName())
|
||||||
|
.requestUri(fullUri)
|
||||||
|
.httpMethod("GET")
|
||||||
|
.statusCode(statusCode)
|
||||||
|
.responseTimeMs(duration)
|
||||||
|
.responseCount(responseSize)
|
||||||
|
.errorMessage(errorMessage)
|
||||||
|
.createdAt(LocalDateTime.now())
|
||||||
|
.jobExecutionId(this.jobExecutionId)
|
||||||
|
.stepExecutionId(this.stepExecutionId)
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected <R> List<R> executeListApiCall(
|
||||||
|
String baseUrl,
|
||||||
|
String path,
|
||||||
|
ParameterizedTypeReference<List<R>> typeReference,
|
||||||
|
BatchApiLogService logService) {
|
||||||
|
|
||||||
|
String fullUri = UriComponentsBuilder.fromHttpUrl(baseUrl)
|
||||||
|
.path(path)
|
||||||
|
.build()
|
||||||
|
.toUriString();
|
||||||
|
|
||||||
|
long startTime = System.currentTimeMillis();
|
||||||
|
int statusCode = 200;
|
||||||
|
String errorMessage = null;
|
||||||
|
Long responseSize = 0L;
|
||||||
|
|
||||||
|
try {
|
||||||
|
log.info("[{}] API 요청 시작: {}", getReaderName(), fullUri);
|
||||||
|
|
||||||
|
List<R> result = webClient.get()
|
||||||
|
.uri(uriBuilder -> {
|
||||||
|
uriBuilder.path(path);
|
||||||
|
return uriBuilder.build();
|
||||||
|
})
|
||||||
|
.retrieve()
|
||||||
|
.bodyToMono(typeReference)
|
||||||
|
.block();
|
||||||
|
|
||||||
|
responseSize = (result != null) ? (long) result.size() : 0L;
|
||||||
|
return result;
|
||||||
|
|
||||||
|
} catch (WebClientResponseException e) {
|
||||||
|
// API 서버에서 응답은 왔으나 에러인 경우 (4xx, 5xx)
|
||||||
|
statusCode = e.getStatusCode().value();
|
||||||
|
errorMessage = String.format("API Error: %s", e.getResponseBodyAsString());
|
||||||
|
throw e;
|
||||||
|
} catch (Exception e) {
|
||||||
|
// 네트워크 오류, 타임아웃 등 기타 예외
|
||||||
|
statusCode = 500;
|
||||||
|
errorMessage = String.format("System Error: %s", e.getMessage());
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
// 성공/실패 여부와 관계없이 무조건 로그 저장
|
||||||
|
long duration = System.currentTimeMillis() - startTime;
|
||||||
|
|
||||||
|
logService.saveLog(BatchApiLog.builder()
|
||||||
|
.apiRequestLocation(getReaderName())
|
||||||
|
.requestUri(fullUri)
|
||||||
|
.httpMethod("GET")
|
||||||
|
.statusCode(statusCode)
|
||||||
|
.responseTimeMs(duration)
|
||||||
|
.responseCount(responseSize)
|
||||||
|
.errorMessage(errorMessage)
|
||||||
|
.createdAt(LocalDateTime.now())
|
||||||
|
.jobExecutionId(this.jobExecutionId) // 추가
|
||||||
|
.stepExecutionId(this.stepExecutionId) // 추가
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* API 호출 및 로그 적재 통합 메서드
|
* API 호출 및 로그 적재 통합 메서드
|
||||||
* Response Json 구조 : { "data": [...] }
|
* Response Json 구조 : { "data": [...] }
|
||||||
|
|||||||
@ -29,9 +29,23 @@ public abstract class BaseJdbcRepository<T, ID> {
|
|||||||
protected final JdbcTemplate jdbcTemplate;
|
protected final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 테이블명 반환 (하위 클래스에서 구현)
|
* 대상 스키마 이름 반환 (하위 클래스에서 구현)
|
||||||
|
* application.yml의 app.batch.target-schema.name 값을 @Value로 주입받아 반환
|
||||||
*/
|
*/
|
||||||
protected abstract String getTableName();
|
protected abstract String getTargetSchema();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 테이블명만 반환 (스키마 제외, 하위 클래스에서 구현)
|
||||||
|
*/
|
||||||
|
protected abstract String getSimpleTableName();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전체 테이블명 반환 (스키마.테이블)
|
||||||
|
* 하위 클래스에서는 getSimpleTableName()만 구현하면 됨
|
||||||
|
*/
|
||||||
|
protected String getTableName() {
|
||||||
|
return getTargetSchema() + "." + getSimpleTableName();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ID 컬럼명 반환 (기본값: "id")
|
* ID 컬럼명 반환 (기본값: "id")
|
||||||
|
|||||||
@ -1,233 +0,0 @@
|
|||||||
package com.snp.batch.common.util;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
|
|
||||||
import java.security.MessageDigest;
|
|
||||||
import java.util.*;
|
|
||||||
|
|
||||||
public class JsonChangeDetector {
|
|
||||||
|
|
||||||
// Map으로 변환 시 사용할 ObjectMapper (표준 Mapper 사용)
|
|
||||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
|
||||||
|
|
||||||
// 해시 비교에서 제외할 필드 목록 (DataSetVersion 등)
|
|
||||||
// 이 목록은 모든 JSON 계층에 걸쳐 적용됩니다.
|
|
||||||
private static final java.util.Set<String> EXCLUDE_KEYS =
|
|
||||||
java.util.Set.of("DataSetVersion", "APSStatus", "LastUpdateDateTime");
|
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// ✅ LIST_SORT_KEYS: 정적 초기화 블록을 사용한 Map 정의
|
|
||||||
// =========================================================================
|
|
||||||
private static final Map<String, String> LIST_SORT_KEYS;
|
|
||||||
static {
|
|
||||||
// TreeMap을 사용하여 키를 알파벳 순으로 정렬할 수도 있지만, 여기서는 HashMap을 사용하고 final로 만듭니다.
|
|
||||||
Map<String, String> map = new HashMap<>();
|
|
||||||
// List 필드명 // 정렬 기준 복합 키 (JSON 필드명, 쉼표로 구분)
|
|
||||||
map.put("OwnerHistory", "OwnerCode,EffectiveDate,Sequence");
|
|
||||||
map.put("CrewList", "LRNO,Shipname,Nationality");
|
|
||||||
map.put("StowageCommodity", "Sequence,CommodityCode,StowageCode");
|
|
||||||
map.put("GroupBeneficialOwnerHistory", "EffectiveDate,GroupBeneficialOwnerCode,Sequence");
|
|
||||||
map.put("ShipManagerHistory", "EffectiveDate,ShipManagerCode,Sequence");
|
|
||||||
map.put("OperatorHistory", "EffectiveDate,OperatorCode,Sequence");
|
|
||||||
map.put("TechnicalManagerHistory", "EffectiveDate,Sequence,TechnicalManagerCode");
|
|
||||||
map.put("BareBoatCharterHistory", "Sequence,EffectiveDate,BBChartererCode");
|
|
||||||
map.put("NameHistory", "Sequence,EffectiveDate");
|
|
||||||
map.put("FlagHistory", "FlagCode,EffectiveDate,Sequence");
|
|
||||||
map.put("PandIHistory", "PandIClubCode,EffectiveDate");
|
|
||||||
map.put("CallSignAndMmsiHistory", "EffectiveDate,SeqNo");
|
|
||||||
map.put("IceClass", "IceClassCode");
|
|
||||||
map.put("SafetyManagementCertificateHistory", "Sequence");
|
|
||||||
map.put("ClassHistory", "ClassCode,EffectiveDate,Sequence");
|
|
||||||
map.put("SurveyDatesHistory", "ClassSocietyCode");
|
|
||||||
map.put("SurveyDatesHistoryUnique", "ClassSocietyCode,SurveyDate,SurveyType");
|
|
||||||
map.put("SisterShipLinks", "LinkedLRNO");
|
|
||||||
map.put("StatusHistory", "Sequence,StatusCode,StatusDate");
|
|
||||||
map.put("SpecialFeature", "Sequence,SpecialFeatureCode");
|
|
||||||
map.put("Thrusters", "Sequence");
|
|
||||||
map.put("DarkActivityConfirmed", "Lrno,Mmsi,Dark_Time,Dark_Status");
|
|
||||||
map.put("CompanyComplianceDetails", "OwCode");
|
|
||||||
map.put("CompanyVesselRelationships", "LRNO");
|
|
||||||
map.put("CompanyDetailsComplexWithCodesAndParent", "OWCODE,LastChangeDate");
|
|
||||||
|
|
||||||
LIST_SORT_KEYS = Collections.unmodifiableMap(map);
|
|
||||||
}
|
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// 1. JSON 문자열을 정렬 및 필터링된 Map으로 변환하는 핵심 로직
|
|
||||||
// =========================================================================
|
|
||||||
/**
|
|
||||||
* JSON 문자열을 Map으로 변환하고, 특정 키를 제거하며, 키 순서가 정렬된 상태로 만듭니다.
|
|
||||||
* @param jsonString API 응답 또는 DB에서 읽은 JSON 문자열
|
|
||||||
* @return 필터링되고 정렬된 Map 객체
|
|
||||||
*/
|
|
||||||
public static Map<String, Object> jsonToSortedFilteredMap(String jsonString) {
|
|
||||||
if (jsonString == null || jsonString.trim().isEmpty()) {
|
|
||||||
return Collections.emptyMap();
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 1. Map<String, Object>으로 1차 변환합니다. (순서 보장 안됨)
|
|
||||||
Map<String, Object> rawMap = MAPPER.readValue(jsonString,
|
|
||||||
new com.fasterxml.jackson.core.type.TypeReference<Map<String, Object>>() {});
|
|
||||||
|
|
||||||
// 2. 재귀 함수를 호출하여 키를 제거하고 TreeMap(키 순서 정렬)으로 깊은 복사합니다.
|
|
||||||
return deepFilterAndSort(rawMap);
|
|
||||||
|
|
||||||
} catch (Exception e) {
|
|
||||||
System.err.println("Error converting JSON to filtered Map: " + e.getMessage());
|
|
||||||
// 예외 발생 시 빈 Map 반환
|
|
||||||
return Collections.emptyMap();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Map을 재귀적으로 탐색하며 제외 키를 제거하고 TreeMap(알파벳 순서)으로 변환합니다.
|
|
||||||
*/
|
|
||||||
private static Map<String, Object> deepFilterAndSort(Map<String, Object> rawMap) {
|
|
||||||
// Map을 TreeMap으로 생성하여 키 순서를 알파벳 순으로 강제 정렬합니다.
|
|
||||||
Map<String, Object> sortedMap = new TreeMap<>();
|
|
||||||
|
|
||||||
for (Map.Entry<String, Object> entry : rawMap.entrySet()) {
|
|
||||||
String key = entry.getKey();
|
|
||||||
Object value = entry.getValue();
|
|
||||||
|
|
||||||
// 🔑 1. 제외할 키 값인지 확인
|
|
||||||
if (EXCLUDE_KEYS.contains(key)) {
|
|
||||||
continue; // 제외
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 값의 타입에 따라 재귀 처리
|
|
||||||
if (value instanceof Map) {
|
|
||||||
// 재귀 호출: 하위 Map을 필터링하고 정렬
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
Map<String, Object> subMap = (Map<String, Object>) value;
|
|
||||||
sortedMap.put(key, deepFilterAndSort(subMap));
|
|
||||||
} else if (value instanceof List) {
|
|
||||||
// List 처리: List 내부의 Map 요소만 재귀 호출
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
List<Object> rawList = (List<Object>) value;
|
|
||||||
List<Object> filteredList = new ArrayList<>();
|
|
||||||
|
|
||||||
// 1. List 내부의 Map 요소들을 재귀적으로 필터링/정렬하여 filteredList에 추가
|
|
||||||
for (Object item : rawList) {
|
|
||||||
if (item instanceof Map) {
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
Map<String, Object> itemMap = (Map<String, Object>) item;
|
|
||||||
// List의 요소인 Map도 필터링하고 정렬 (Map의 필드 순서 정렬)
|
|
||||||
filteredList.add(deepFilterAndSort(itemMap));
|
|
||||||
} else {
|
|
||||||
filteredList.add(item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 🔑 List 필드명에 따른 복합 순서 정렬 로직 (수정된 핵심 로직)
|
|
||||||
String listFieldName = entry.getKey();
|
|
||||||
String sortKeysString = LIST_SORT_KEYS.get(listFieldName); // 쉼표로 구분된 복합 키 문자열
|
|
||||||
|
|
||||||
if (sortKeysString != null && !filteredList.isEmpty() && filteredList.get(0) instanceof Map) {
|
|
||||||
// 복합 키 문자열을 개별 키 배열로 분리
|
|
||||||
final String[] sortKeys = sortKeysString.split(",");
|
|
||||||
|
|
||||||
// Map 요소를 가진 리스트인 경우에만 정렬 실행
|
|
||||||
try {
|
|
||||||
Collections.sort(filteredList, new Comparator<Object>() {
|
|
||||||
@Override
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
public int compare(Object o1, Object o2) {
|
|
||||||
Map<String, Object> map1 = (Map<String, Object>) o1;
|
|
||||||
Map<String, Object> map2 = (Map<String, Object>) o2;
|
|
||||||
|
|
||||||
// 복합 키(sortKeys)를 순서대로 순회하며 비교
|
|
||||||
for (String rawSortKey : sortKeys) {
|
|
||||||
// 키의 공백 제거
|
|
||||||
String sortKey = rawSortKey.trim();
|
|
||||||
|
|
||||||
Object key1 = map1.get(sortKey);
|
|
||||||
Object key2 = map2.get(sortKey);
|
|
||||||
|
|
||||||
// null 값 처리 로직
|
|
||||||
if (key1 == null && key2 == null) {
|
|
||||||
continue; // 두 값이 동일하므로 다음 키로 이동
|
|
||||||
}
|
|
||||||
if (key1 == null) {
|
|
||||||
// key1이 null이고 key2는 null이 아니면, key2가 더 크다고 (뒤 순서) 간주하고 1 반환
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
if (key2 == null) {
|
|
||||||
// key2가 null이고 key1은 null이 아니면, key1이 더 크다고 (뒤 순서) 간주하고 -1 반환
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 값을 문자열로 변환하여 비교 (String, Number, Date 타입 모두 처리 가능)
|
|
||||||
int comparisonResult = key1.toString().compareTo(key2.toString());
|
|
||||||
|
|
||||||
// 현재 키에서 순서가 결정되면 즉시 반환
|
|
||||||
if (comparisonResult != 0) {
|
|
||||||
return comparisonResult;
|
|
||||||
}
|
|
||||||
// comparisonResult == 0 이면 다음 키로 이동하여 비교를 계속함
|
|
||||||
}
|
|
||||||
|
|
||||||
// 모든 키를 비교해도 동일한 경우
|
|
||||||
// 이 경우 두 Map은 해시값 측면에서 동일한 것으로 간주되어야 합니다.
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (Exception e) {
|
|
||||||
System.err.println("List sort failed for key " + listFieldName + ": " + e.getMessage());
|
|
||||||
// 정렬 실패 시 원래 순서 유지 (filteredList 상태 유지)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sortedMap.put(key, filteredList);
|
|
||||||
} else {
|
|
||||||
// String, Number 등 기본 타입은 그대로 추가
|
|
||||||
sortedMap.put(key, value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return sortedMap;
|
|
||||||
}
|
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// 2. 해시 생성 로직
|
|
||||||
// =========================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 필터링되고 정렬된 Map의 문자열 표현을 기반으로 SHA-256 해시를 생성합니다.
|
|
||||||
*/
|
|
||||||
public static String getSha256HashFromMap(Map<String, Object> sortedMap) {
|
|
||||||
// 1. Map을 String으로 변환: TreeMap 덕분에 toString() 결과가 항상 동일한 순서를 가집니다.
|
|
||||||
String mapString = sortedMap.toString();
|
|
||||||
|
|
||||||
try {
|
|
||||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
|
||||||
byte[] hash = digest.digest(mapString.getBytes("UTF-8"));
|
|
||||||
|
|
||||||
// 바이트 배열을 16진수 문자열로 변환
|
|
||||||
StringBuilder hexString = new StringBuilder();
|
|
||||||
for (byte b : hash) {
|
|
||||||
String hex = Integer.toHexString(0xff & b);
|
|
||||||
if (hex.length() == 1) hexString.append('0');
|
|
||||||
hexString.append(hex);
|
|
||||||
}
|
|
||||||
return hexString.toString();
|
|
||||||
} catch (Exception e) {
|
|
||||||
System.err.println("Error generating hash: " + e.getMessage());
|
|
||||||
return "HASH_ERROR";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// 3. 해시값 비교 로직
|
|
||||||
// =========================================================================
|
|
||||||
public static boolean isChanged(String previousHash, String currentHash) {
|
|
||||||
// DB 해시가 null인 경우 (첫 Insert)는 변경된 것으로 간주
|
|
||||||
if (previousHash == null || previousHash.isEmpty()) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 해시값이 다르면 변경된 것으로 간주
|
|
||||||
return !Objects.equals(previousHash, currentHash);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@ -1,33 +0,0 @@
|
|||||||
package com.snp.batch.common.util;
|
|
||||||
|
|
||||||
public class SafeGetDataUtil {
|
|
||||||
private String safeGetString(String value) {
|
|
||||||
if (value == null || value.trim().isEmpty()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return value.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
private Double safeGetDouble(String value) {
|
|
||||||
if (value == null || value.trim().isEmpty()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return Double.parseDouble(value);
|
|
||||||
} catch (NumberFormatException e) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private Long safeGetLong(String value) {
|
|
||||||
if (value == null || value.trim().isEmpty()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return Long.parseLong(value.trim());
|
|
||||||
} catch (NumberFormatException e) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@ -1,5 +1,6 @@
|
|||||||
package com.snp.batch.common.web;
|
package com.snp.batch.common.web;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
@ -14,26 +15,19 @@ import lombok.NoArgsConstructor;
|
|||||||
@Builder
|
@Builder
|
||||||
@NoArgsConstructor
|
@NoArgsConstructor
|
||||||
@AllArgsConstructor
|
@AllArgsConstructor
|
||||||
|
@Schema(description = "공통 API 응답 래퍼")
|
||||||
public class ApiResponse<T> {
|
public class ApiResponse<T> {
|
||||||
|
|
||||||
/**
|
@Schema(description = "성공 여부", example = "true")
|
||||||
* 성공 여부
|
|
||||||
*/
|
|
||||||
private boolean success;
|
private boolean success;
|
||||||
|
|
||||||
/**
|
@Schema(description = "응답 메시지", example = "Success")
|
||||||
* 메시지
|
|
||||||
*/
|
|
||||||
private String message;
|
private String message;
|
||||||
|
|
||||||
/**
|
@Schema(description = "응답 데이터")
|
||||||
* 응답 데이터
|
|
||||||
*/
|
|
||||||
private T data;
|
private T data;
|
||||||
|
|
||||||
/**
|
@Schema(description = "에러 코드 (실패 시에만 존재)", example = "NOT_FOUND", nullable = true)
|
||||||
* 에러 코드 (실패 시)
|
|
||||||
*/
|
|
||||||
private String errorCode;
|
private String errorCode;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -1,300 +0,0 @@
|
|||||||
package com.snp.batch.common.web.controller;
|
|
||||||
|
|
||||||
import com.snp.batch.common.web.ApiResponse;
|
|
||||||
import com.snp.batch.common.web.service.BaseService;
|
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
|
||||||
import io.swagger.v3.oas.annotations.Parameter;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.springframework.http.ResponseEntity;
|
|
||||||
import org.springframework.web.bind.annotation.*;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 모든 REST Controller의 공통 베이스 클래스
|
|
||||||
* CRUD API의 일관된 구조 제공
|
|
||||||
*
|
|
||||||
* 이 클래스는 추상 클래스이므로 @Tag를 붙이지 않습니다.
|
|
||||||
* 하위 클래스에서 @Tag를 정의하면 모든 엔드포인트가 해당 태그로 그룹화됩니다.
|
|
||||||
*
|
|
||||||
* @param <D> DTO 타입
|
|
||||||
* @param <ID> ID 타입
|
|
||||||
*/
|
|
||||||
@Slf4j
|
|
||||||
public abstract class BaseController<D, ID> {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Service 반환 (하위 클래스에서 구현)
|
|
||||||
*/
|
|
||||||
protected abstract BaseService<?, D, ID> getService();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 리소스 이름 반환 (로깅용)
|
|
||||||
*/
|
|
||||||
protected abstract String getResourceName();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 단건 생성
|
|
||||||
*/
|
|
||||||
@Operation(
|
|
||||||
summary = "리소스 생성",
|
|
||||||
description = "새로운 리소스를 생성합니다",
|
|
||||||
responses = {
|
|
||||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
|
||||||
responseCode = "200",
|
|
||||||
description = "생성 성공"
|
|
||||||
),
|
|
||||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
|
||||||
responseCode = "500",
|
|
||||||
description = "서버 오류"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
@PostMapping
|
|
||||||
public ResponseEntity<ApiResponse<D>> create(
|
|
||||||
@Parameter(description = "생성할 리소스 데이터", required = true)
|
|
||||||
@RequestBody D dto) {
|
|
||||||
log.info("{} 생성 요청", getResourceName());
|
|
||||||
try {
|
|
||||||
D created = getService().create(dto);
|
|
||||||
return ResponseEntity.ok(
|
|
||||||
ApiResponse.success(getResourceName() + " created successfully", created)
|
|
||||||
);
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("{} 생성 실패", getResourceName(), e);
|
|
||||||
return ResponseEntity.internalServerError().body(
|
|
||||||
ApiResponse.error("Failed to create " + getResourceName() + ": " + e.getMessage())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 단건 조회
|
|
||||||
*/
|
|
||||||
@Operation(
|
|
||||||
summary = "리소스 조회",
|
|
||||||
description = "ID로 특정 리소스를 조회합니다",
|
|
||||||
responses = {
|
|
||||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
|
||||||
responseCode = "200",
|
|
||||||
description = "조회 성공"
|
|
||||||
),
|
|
||||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
|
||||||
responseCode = "404",
|
|
||||||
description = "리소스 없음"
|
|
||||||
),
|
|
||||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
|
||||||
responseCode = "500",
|
|
||||||
description = "서버 오류"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
@GetMapping("/{id}")
|
|
||||||
public ResponseEntity<ApiResponse<D>> getById(
|
|
||||||
@Parameter(description = "리소스 ID", required = true)
|
|
||||||
@PathVariable ID id) {
|
|
||||||
log.info("{} 조회 요청: ID={}", getResourceName(), id);
|
|
||||||
try {
|
|
||||||
return getService().findById(id)
|
|
||||||
.map(dto -> ResponseEntity.ok(ApiResponse.success(dto)))
|
|
||||||
.orElse(ResponseEntity.notFound().build());
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("{} 조회 실패: ID={}", getResourceName(), id, e);
|
|
||||||
return ResponseEntity.internalServerError().body(
|
|
||||||
ApiResponse.error("Failed to get " + getResourceName() + ": " + e.getMessage())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 전체 조회
|
|
||||||
*/
|
|
||||||
@Operation(
|
|
||||||
summary = "전체 리소스 조회",
|
|
||||||
description = "모든 리소스를 조회합니다",
|
|
||||||
responses = {
|
|
||||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
|
||||||
responseCode = "200",
|
|
||||||
description = "조회 성공"
|
|
||||||
),
|
|
||||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
|
||||||
responseCode = "500",
|
|
||||||
description = "서버 오류"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
@GetMapping
|
|
||||||
public ResponseEntity<ApiResponse<List<D>>> getAll() {
|
|
||||||
log.info("{} 전체 조회 요청", getResourceName());
|
|
||||||
try {
|
|
||||||
List<D> list = getService().findAll();
|
|
||||||
return ResponseEntity.ok(ApiResponse.success(list));
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("{} 전체 조회 실패", getResourceName(), e);
|
|
||||||
return ResponseEntity.internalServerError().body(
|
|
||||||
ApiResponse.error("Failed to get all " + getResourceName() + ": " + e.getMessage())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 페이징 조회 (JDBC 기반)
|
|
||||||
*
|
|
||||||
* @param offset 시작 위치 (기본값: 0)
|
|
||||||
* @param limit 조회 개수 (기본값: 20)
|
|
||||||
*/
|
|
||||||
@Operation(
|
|
||||||
summary = "페이징 조회",
|
|
||||||
description = "페이지 단위로 리소스를 조회합니다",
|
|
||||||
responses = {
|
|
||||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
|
||||||
responseCode = "200",
|
|
||||||
description = "조회 성공"
|
|
||||||
),
|
|
||||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
|
||||||
responseCode = "500",
|
|
||||||
description = "서버 오류"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
@GetMapping("/page")
|
|
||||||
public ResponseEntity<ApiResponse<List<D>>> getPage(
|
|
||||||
@Parameter(description = "시작 위치 (0부터 시작)", example = "0")
|
|
||||||
@RequestParam(defaultValue = "0") int offset,
|
|
||||||
@Parameter(description = "조회 개수", example = "20")
|
|
||||||
@RequestParam(defaultValue = "20") int limit) {
|
|
||||||
log.info("{} 페이징 조회 요청: offset={}, limit={}",
|
|
||||||
getResourceName(), offset, limit);
|
|
||||||
try {
|
|
||||||
List<D> list = getService().findAll(offset, limit);
|
|
||||||
long total = getService().count();
|
|
||||||
|
|
||||||
// 페이징 정보를 포함한 응답
|
|
||||||
return ResponseEntity.ok(
|
|
||||||
ApiResponse.success("Retrieved " + list.size() + " items (total: " + total + ")", list)
|
|
||||||
);
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("{} 페이징 조회 실패", getResourceName(), e);
|
|
||||||
return ResponseEntity.internalServerError().body(
|
|
||||||
ApiResponse.error("Failed to get page of " + getResourceName() + ": " + e.getMessage())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 단건 수정
|
|
||||||
*/
|
|
||||||
@Operation(
|
|
||||||
summary = "리소스 수정",
|
|
||||||
description = "기존 리소스를 수정합니다",
|
|
||||||
responses = {
|
|
||||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
|
||||||
responseCode = "200",
|
|
||||||
description = "수정 성공"
|
|
||||||
),
|
|
||||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
|
||||||
responseCode = "404",
|
|
||||||
description = "리소스 없음"
|
|
||||||
),
|
|
||||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
|
||||||
responseCode = "500",
|
|
||||||
description = "서버 오류"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
@PutMapping("/{id}")
|
|
||||||
public ResponseEntity<ApiResponse<D>> update(
|
|
||||||
@Parameter(description = "리소스 ID", required = true)
|
|
||||||
@PathVariable ID id,
|
|
||||||
@Parameter(description = "수정할 리소스 데이터", required = true)
|
|
||||||
@RequestBody D dto) {
|
|
||||||
log.info("{} 수정 요청: ID={}", getResourceName(), id);
|
|
||||||
try {
|
|
||||||
D updated = getService().update(id, dto);
|
|
||||||
return ResponseEntity.ok(
|
|
||||||
ApiResponse.success(getResourceName() + " updated successfully", updated)
|
|
||||||
);
|
|
||||||
} catch (IllegalArgumentException e) {
|
|
||||||
return ResponseEntity.notFound().build();
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("{} 수정 실패: ID={}", getResourceName(), id, e);
|
|
||||||
return ResponseEntity.internalServerError().body(
|
|
||||||
ApiResponse.error("Failed to update " + getResourceName() + ": " + e.getMessage())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 단건 삭제
|
|
||||||
*/
|
|
||||||
@Operation(
|
|
||||||
summary = "리소스 삭제",
|
|
||||||
description = "기존 리소스를 삭제합니다",
|
|
||||||
responses = {
|
|
||||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
|
||||||
responseCode = "200",
|
|
||||||
description = "삭제 성공"
|
|
||||||
),
|
|
||||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
|
||||||
responseCode = "404",
|
|
||||||
description = "리소스 없음"
|
|
||||||
),
|
|
||||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
|
||||||
responseCode = "500",
|
|
||||||
description = "서버 오류"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
@DeleteMapping("/{id}")
|
|
||||||
public ResponseEntity<ApiResponse<Void>> delete(
|
|
||||||
@Parameter(description = "리소스 ID", required = true)
|
|
||||||
@PathVariable ID id) {
|
|
||||||
log.info("{} 삭제 요청: ID={}", getResourceName(), id);
|
|
||||||
try {
|
|
||||||
getService().deleteById(id);
|
|
||||||
return ResponseEntity.ok(
|
|
||||||
ApiResponse.success(getResourceName() + " deleted successfully", null)
|
|
||||||
);
|
|
||||||
} catch (IllegalArgumentException e) {
|
|
||||||
return ResponseEntity.notFound().build();
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("{} 삭제 실패: ID={}", getResourceName(), id, e);
|
|
||||||
return ResponseEntity.internalServerError().body(
|
|
||||||
ApiResponse.error("Failed to delete " + getResourceName() + ": " + e.getMessage())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 존재 여부 확인
|
|
||||||
*/
|
|
||||||
@Operation(
|
|
||||||
summary = "리소스 존재 확인",
|
|
||||||
description = "특정 ID의 리소스가 존재하는지 확인합니다",
|
|
||||||
responses = {
|
|
||||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
|
||||||
responseCode = "200",
|
|
||||||
description = "확인 성공"
|
|
||||||
),
|
|
||||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
|
||||||
responseCode = "500",
|
|
||||||
description = "서버 오류"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
@GetMapping("/{id}/exists")
|
|
||||||
public ResponseEntity<ApiResponse<Boolean>> exists(
|
|
||||||
@Parameter(description = "리소스 ID", required = true)
|
|
||||||
@PathVariable ID id) {
|
|
||||||
log.debug("{} 존재 여부 확인: ID={}", getResourceName(), id);
|
|
||||||
try {
|
|
||||||
boolean exists = getService().existsById(id);
|
|
||||||
return ResponseEntity.ok(ApiResponse.success(exists));
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("{} 존재 여부 확인 실패: ID={}", getResourceName(), id, e);
|
|
||||||
return ResponseEntity.internalServerError().body(
|
|
||||||
ApiResponse.error("Failed to check existence: " + e.getMessage())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,33 +0,0 @@
|
|||||||
package com.snp.batch.common.web.dto;
|
|
||||||
|
|
||||||
import lombok.Data;
|
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 모든 DTO의 공통 베이스 클래스
|
|
||||||
* 생성/수정 정보 등 공통 필드
|
|
||||||
*/
|
|
||||||
@Data
|
|
||||||
public abstract class BaseDto {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 생성 일시
|
|
||||||
*/
|
|
||||||
private LocalDateTime createdAt;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 수정 일시
|
|
||||||
*/
|
|
||||||
private LocalDateTime updatedAt;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 생성자
|
|
||||||
*/
|
|
||||||
private String createdBy;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 수정자
|
|
||||||
*/
|
|
||||||
private String updatedBy;
|
|
||||||
}
|
|
||||||
@ -1,202 +0,0 @@
|
|||||||
package com.snp.batch.common.web.service;
|
|
||||||
|
|
||||||
import com.snp.batch.common.batch.repository.BaseJdbcRepository;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
import org.springframework.web.reactive.function.client.WebClient;
|
|
||||||
|
|
||||||
import java.time.Duration;
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Optional;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 하이브리드 서비스 Base 클래스 (DB 캐시 + 외부 API 프록시)
|
|
||||||
*
|
|
||||||
* 사용 시나리오:
|
|
||||||
* 1. 클라이언트 요청 → DB 조회 (캐시 Hit)
|
|
||||||
* - 캐시 데이터 유효 시 즉시 반환
|
|
||||||
* 2. 캐시 Miss 또는 만료 시
|
|
||||||
* - 외부 서비스 API 호출
|
|
||||||
* - DB에 저장 (캐시 갱신)
|
|
||||||
* - 클라이언트에게 반환
|
|
||||||
*
|
|
||||||
* 장점:
|
|
||||||
* - 빠른 응답 (DB 캐시)
|
|
||||||
* - 외부 서비스 장애 시에도 캐시 데이터 제공 가능
|
|
||||||
* - 외부 API 호출 횟수 감소 (비용 절감)
|
|
||||||
*
|
|
||||||
* @param <T> Entity 타입
|
|
||||||
* @param <D> DTO 타입
|
|
||||||
* @param <ID> ID 타입
|
|
||||||
*/
|
|
||||||
@Slf4j
|
|
||||||
public abstract class BaseHybridService<T, D, ID> extends BaseServiceImpl<T, D, ID> {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* WebClient 반환 (하위 클래스에서 구현)
|
|
||||||
*/
|
|
||||||
protected abstract WebClient getWebClient();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 외부 서비스 이름 반환
|
|
||||||
*/
|
|
||||||
protected abstract String getExternalServiceName();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 캐시 유효 시간 (초)
|
|
||||||
* 기본값: 300초 (5분)
|
|
||||||
*/
|
|
||||||
protected long getCacheTtlSeconds() {
|
|
||||||
return 300;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 요청 타임아웃
|
|
||||||
*/
|
|
||||||
protected Duration getTimeout() {
|
|
||||||
return Duration.ofSeconds(30);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 하이브리드 조회: DB 캐시 우선, 없으면 외부 API 호출
|
|
||||||
*
|
|
||||||
* @param id 조회 키
|
|
||||||
* @return DTO
|
|
||||||
*/
|
|
||||||
@Transactional
|
|
||||||
public D findByIdHybrid(ID id) {
|
|
||||||
log.info("[하이브리드] ID로 조회: {}", id);
|
|
||||||
|
|
||||||
// 1. DB 캐시 조회
|
|
||||||
Optional<D> cached = findById(id);
|
|
||||||
|
|
||||||
if (cached.isPresent()) {
|
|
||||||
// 캐시 유효성 검증
|
|
||||||
if (isCacheValid(cached.get())) {
|
|
||||||
log.info("[하이브리드] 캐시 Hit - DB에서 반환");
|
|
||||||
return cached.get();
|
|
||||||
} else {
|
|
||||||
log.info("[하이브리드] 캐시 만료 - 외부 API 호출");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
log.info("[하이브리드] 캐시 Miss - 외부 API 호출");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 외부 API 호출
|
|
||||||
try {
|
|
||||||
D externalData = fetchFromExternalApi(id);
|
|
||||||
|
|
||||||
// 3. DB 저장 (캐시 갱신)
|
|
||||||
T entity = toEntity(externalData);
|
|
||||||
T saved = getRepository().save(entity);
|
|
||||||
|
|
||||||
log.info("[하이브리드] 외부 데이터 DB 저장 완료");
|
|
||||||
return toDto(saved);
|
|
||||||
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("[하이브리드] 외부 API 호출 실패: {}", e.getMessage());
|
|
||||||
|
|
||||||
// 4. 외부 API 실패 시 만료된 캐시라도 반환 (Fallback)
|
|
||||||
if (cached.isPresent()) {
|
|
||||||
log.warn("[하이브리드] Fallback - 만료된 캐시 반환");
|
|
||||||
return cached.get();
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new RuntimeException("데이터 조회 실패: " + e.getMessage(), e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 외부 API에서 데이터 조회 (하위 클래스에서 구현)
|
|
||||||
*
|
|
||||||
* @param id 조회 키
|
|
||||||
* @return DTO
|
|
||||||
*/
|
|
||||||
protected abstract D fetchFromExternalApi(ID id) throws Exception;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 캐시 유효성 검증
|
|
||||||
* 기본 구현: updated_at 기준으로 TTL 체크
|
|
||||||
*
|
|
||||||
* @param dto 캐시 데이터
|
|
||||||
* @return 유효 여부
|
|
||||||
*/
|
|
||||||
protected boolean isCacheValid(D dto) {
|
|
||||||
// BaseDto를 상속한 경우 updatedAt 체크
|
|
||||||
try {
|
|
||||||
LocalDateTime updatedAt = extractUpdatedAt(dto);
|
|
||||||
if (updatedAt == null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
LocalDateTime now = LocalDateTime.now();
|
|
||||||
long elapsedSeconds = Duration.between(updatedAt, now).getSeconds();
|
|
||||||
|
|
||||||
return elapsedSeconds < getCacheTtlSeconds();
|
|
||||||
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.warn("캐시 유효성 검증 실패 - 항상 최신 데이터 조회: {}", e.getMessage());
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DTO에서 updatedAt 추출 (하위 클래스에서 오버라이드 가능)
|
|
||||||
*/
|
|
||||||
protected LocalDateTime extractUpdatedAt(D dto) {
|
|
||||||
// 기본 구현: 항상 캐시 무효 (외부 API 호출)
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 강제 캐시 갱신 (외부 API 호출 강제)
|
|
||||||
*/
|
|
||||||
@Transactional
|
|
||||||
public D refreshCache(ID id) throws Exception {
|
|
||||||
log.info("[하이브리드] 캐시 강제 갱신: {}", id);
|
|
||||||
|
|
||||||
D externalData = fetchFromExternalApi(id);
|
|
||||||
T entity = toEntity(externalData);
|
|
||||||
T saved = getRepository().save(entity);
|
|
||||||
|
|
||||||
return toDto(saved);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 외부 API GET 요청
|
|
||||||
*/
|
|
||||||
protected <RES> RES callExternalGet(String endpoint, Map<String, String> params, Class<RES> responseType) {
|
|
||||||
log.info("[{}] GET 요청: endpoint={}", getExternalServiceName(), endpoint);
|
|
||||||
|
|
||||||
return getWebClient()
|
|
||||||
.get()
|
|
||||||
.uri(uriBuilder -> {
|
|
||||||
uriBuilder.path(endpoint);
|
|
||||||
if (params != null) {
|
|
||||||
params.forEach(uriBuilder::queryParam);
|
|
||||||
}
|
|
||||||
return uriBuilder.build();
|
|
||||||
})
|
|
||||||
.retrieve()
|
|
||||||
.bodyToMono(responseType)
|
|
||||||
.timeout(getTimeout())
|
|
||||||
.block();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 외부 API POST 요청
|
|
||||||
*/
|
|
||||||
protected <REQ, RES> RES callExternalPost(String endpoint, REQ requestBody, Class<RES> responseType) {
|
|
||||||
log.info("[{}] POST 요청: endpoint={}", getExternalServiceName(), endpoint);
|
|
||||||
|
|
||||||
return getWebClient()
|
|
||||||
.post()
|
|
||||||
.uri(endpoint)
|
|
||||||
.bodyValue(requestBody)
|
|
||||||
.retrieve()
|
|
||||||
.bodyToMono(responseType)
|
|
||||||
.timeout(getTimeout())
|
|
||||||
.block();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,176 +0,0 @@
|
|||||||
package com.snp.batch.common.web.service;
|
|
||||||
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.springframework.web.reactive.function.client.WebClient;
|
|
||||||
import reactor.core.publisher.Mono;
|
|
||||||
|
|
||||||
import java.time.Duration;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 외부 API 프록시 서비스 Base 클래스
|
|
||||||
*
|
|
||||||
* 목적: 해외 외부 서비스를 국내에서 우회 접근할 수 있도록 프록시 역할 수행
|
|
||||||
*
|
|
||||||
* 사용 시나리오:
|
|
||||||
* - 외부 서비스가 해외에 있고 국내 IP에서만 접근 가능
|
|
||||||
* - 클라이언트 A → 우리 서버 (국내) → 외부 서비스 (해외) → 응답 전달
|
|
||||||
*
|
|
||||||
* 장점:
|
|
||||||
* - 실시간 데이터 제공 (DB 캐시 없이)
|
|
||||||
* - 외부 서비스의 최신 데이터 보장
|
|
||||||
* - DB 저장 부담 없음
|
|
||||||
*
|
|
||||||
* @param <REQ> 요청 DTO 타입
|
|
||||||
* @param <RES> 응답 DTO 타입
|
|
||||||
*/
|
|
||||||
@Slf4j
|
|
||||||
public abstract class BaseProxyService<REQ, RES> {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* WebClient 반환 (하위 클래스에서 구현)
|
|
||||||
* 외부 서비스별로 인증, Base URL 등 설정
|
|
||||||
*/
|
|
||||||
protected abstract WebClient getWebClient();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 외부 서비스 이름 반환 (로깅용)
|
|
||||||
*/
|
|
||||||
protected abstract String getServiceName();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 요청 타임아웃 (밀리초)
|
|
||||||
* 기본값: 30초
|
|
||||||
*/
|
|
||||||
protected Duration getTimeout() {
|
|
||||||
return Duration.ofSeconds(30);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET 요청 프록시
|
|
||||||
*
|
|
||||||
* @param endpoint 엔드포인트 경로 (예: "/api/ships")
|
|
||||||
* @param params 쿼리 파라미터
|
|
||||||
* @param responseType 응답 클래스 타입
|
|
||||||
* @return 외부 서비스 응답
|
|
||||||
*/
|
|
||||||
public RES proxyGet(String endpoint, Map<String, String> params, Class<RES> responseType) {
|
|
||||||
log.info("[{}] GET 요청 프록시: endpoint={}, params={}", getServiceName(), endpoint, params);
|
|
||||||
|
|
||||||
try {
|
|
||||||
WebClient.RequestHeadersSpec<?> spec = getWebClient()
|
|
||||||
.get()
|
|
||||||
.uri(uriBuilder -> {
|
|
||||||
uriBuilder.path(endpoint);
|
|
||||||
if (params != null) {
|
|
||||||
params.forEach(uriBuilder::queryParam);
|
|
||||||
}
|
|
||||||
return uriBuilder.build();
|
|
||||||
});
|
|
||||||
|
|
||||||
RES response = spec.retrieve()
|
|
||||||
.bodyToMono(responseType)
|
|
||||||
.timeout(getTimeout())
|
|
||||||
.block();
|
|
||||||
|
|
||||||
log.info("[{}] 응답 성공", getServiceName());
|
|
||||||
return response;
|
|
||||||
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("[{}] 프록시 요청 실패: {}", getServiceName(), e.getMessage(), e);
|
|
||||||
throw new RuntimeException("외부 서비스 호출 실패: " + e.getMessage(), e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST 요청 프록시
|
|
||||||
*
|
|
||||||
* @param endpoint 엔드포인트 경로
|
|
||||||
* @param requestBody 요청 본문
|
|
||||||
* @param responseType 응답 클래스 타입
|
|
||||||
* @return 외부 서비스 응답
|
|
||||||
*/
|
|
||||||
public RES proxyPost(String endpoint, REQ requestBody, Class<RES> responseType) {
|
|
||||||
log.info("[{}] POST 요청 프록시: endpoint={}", getServiceName(), endpoint);
|
|
||||||
|
|
||||||
try {
|
|
||||||
RES response = getWebClient()
|
|
||||||
.post()
|
|
||||||
.uri(endpoint)
|
|
||||||
.bodyValue(requestBody)
|
|
||||||
.retrieve()
|
|
||||||
.bodyToMono(responseType)
|
|
||||||
.timeout(getTimeout())
|
|
||||||
.block();
|
|
||||||
|
|
||||||
log.info("[{}] 응답 성공", getServiceName());
|
|
||||||
return response;
|
|
||||||
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("[{}] 프록시 요청 실패: {}", getServiceName(), e.getMessage(), e);
|
|
||||||
throw new RuntimeException("외부 서비스 호출 실패: " + e.getMessage(), e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PUT 요청 프록시
|
|
||||||
*/
|
|
||||||
public RES proxyPut(String endpoint, REQ requestBody, Class<RES> responseType) {
|
|
||||||
log.info("[{}] PUT 요청 프록시: endpoint={}", getServiceName(), endpoint);
|
|
||||||
|
|
||||||
try {
|
|
||||||
RES response = getWebClient()
|
|
||||||
.put()
|
|
||||||
.uri(endpoint)
|
|
||||||
.bodyValue(requestBody)
|
|
||||||
.retrieve()
|
|
||||||
.bodyToMono(responseType)
|
|
||||||
.timeout(getTimeout())
|
|
||||||
.block();
|
|
||||||
|
|
||||||
log.info("[{}] 응답 성공", getServiceName());
|
|
||||||
return response;
|
|
||||||
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("[{}] 프록시 요청 실패: {}", getServiceName(), e.getMessage(), e);
|
|
||||||
throw new RuntimeException("외부 서비스 호출 실패: " + e.getMessage(), e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DELETE 요청 프록시
|
|
||||||
*/
|
|
||||||
public void proxyDelete(String endpoint, Map<String, String> params) {
|
|
||||||
log.info("[{}] DELETE 요청 프록시: endpoint={}, params={}", getServiceName(), endpoint, params);
|
|
||||||
|
|
||||||
try {
|
|
||||||
getWebClient()
|
|
||||||
.delete()
|
|
||||||
.uri(uriBuilder -> {
|
|
||||||
uriBuilder.path(endpoint);
|
|
||||||
if (params != null) {
|
|
||||||
params.forEach(uriBuilder::queryParam);
|
|
||||||
}
|
|
||||||
return uriBuilder.build();
|
|
||||||
})
|
|
||||||
.retrieve()
|
|
||||||
.bodyToMono(Void.class)
|
|
||||||
.timeout(getTimeout())
|
|
||||||
.block();
|
|
||||||
|
|
||||||
log.info("[{}] DELETE 성공", getServiceName());
|
|
||||||
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("[{}] 프록시 DELETE 실패: {}", getServiceName(), e.getMessage(), e);
|
|
||||||
throw new RuntimeException("외부 서비스 호출 실패: " + e.getMessage(), e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 커스텀 요청 처리 (하위 클래스에서 오버라이드)
|
|
||||||
* 복잡한 로직이 필요한 경우 사용
|
|
||||||
*/
|
|
||||||
protected RES customRequest(REQ request) {
|
|
||||||
throw new UnsupportedOperationException("커스텀 요청이 구현되지 않았습니다");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,94 +0,0 @@
|
|||||||
package com.snp.batch.common.web.service;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Optional;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 모든 서비스의 공통 인터페이스 (JDBC 기반)
|
|
||||||
* CRUD 기본 메서드 정의
|
|
||||||
*
|
|
||||||
* @param <T> Entity 타입
|
|
||||||
* @param <D> DTO 타입
|
|
||||||
* @param <ID> ID 타입
|
|
||||||
*/
|
|
||||||
public interface BaseService<T, D, ID> {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 단건 생성
|
|
||||||
*
|
|
||||||
* @param dto 생성할 데이터 DTO
|
|
||||||
* @return 생성된 데이터 DTO
|
|
||||||
*/
|
|
||||||
D create(D dto);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 단건 조회
|
|
||||||
*
|
|
||||||
* @param id 조회할 ID
|
|
||||||
* @return 조회된 데이터 DTO (Optional)
|
|
||||||
*/
|
|
||||||
Optional<D> findById(ID id);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 전체 조회
|
|
||||||
*
|
|
||||||
* @return 전체 데이터 DTO 리스트
|
|
||||||
*/
|
|
||||||
List<D> findAll();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 페이징 조회
|
|
||||||
*
|
|
||||||
* @param offset 시작 위치 (0부터 시작)
|
|
||||||
* @param limit 조회 개수
|
|
||||||
* @return 페이징된 데이터 리스트
|
|
||||||
*/
|
|
||||||
List<D> findAll(int offset, int limit);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 전체 개수 조회
|
|
||||||
*
|
|
||||||
* @return 전체 데이터 개수
|
|
||||||
*/
|
|
||||||
long count();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 단건 수정
|
|
||||||
*
|
|
||||||
* @param id 수정할 ID
|
|
||||||
* @param dto 수정할 데이터 DTO
|
|
||||||
* @return 수정된 데이터 DTO
|
|
||||||
*/
|
|
||||||
D update(ID id, D dto);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 단건 삭제
|
|
||||||
*
|
|
||||||
* @param id 삭제할 ID
|
|
||||||
*/
|
|
||||||
void deleteById(ID id);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 존재 여부 확인
|
|
||||||
*
|
|
||||||
* @param id 확인할 ID
|
|
||||||
* @return 존재 여부
|
|
||||||
*/
|
|
||||||
boolean existsById(ID id);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Entity를 DTO로 변환
|
|
||||||
*
|
|
||||||
* @param entity 엔티티
|
|
||||||
* @return DTO
|
|
||||||
*/
|
|
||||||
D toDto(T entity);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DTO를 Entity로 변환
|
|
||||||
*
|
|
||||||
* @param dto DTO
|
|
||||||
* @return 엔티티
|
|
||||||
*/
|
|
||||||
T toEntity(D dto);
|
|
||||||
}
|
|
||||||
@ -1,131 +0,0 @@
|
|||||||
package com.snp.batch.common.web.service;
|
|
||||||
|
|
||||||
import com.snp.batch.common.batch.repository.BaseJdbcRepository;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Optional;
|
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* BaseService의 기본 구현 (JDBC 기반)
|
|
||||||
* 공통 CRUD 로직 구현
|
|
||||||
*
|
|
||||||
* @param <T> Entity 타입
|
|
||||||
* @param <D> DTO 타입
|
|
||||||
* @param <ID> ID 타입
|
|
||||||
*/
|
|
||||||
@Slf4j
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public abstract class BaseServiceImpl<T, D, ID> implements BaseService<T, D, ID> {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Repository 반환 (하위 클래스에서 구현)
|
|
||||||
*/
|
|
||||||
protected abstract BaseJdbcRepository<T, ID> getRepository();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 엔티티 이름 반환 (로깅용)
|
|
||||||
*/
|
|
||||||
protected abstract String getEntityName();
|
|
||||||
|
|
||||||
@Override
|
|
||||||
@Transactional
|
|
||||||
public D create(D dto) {
|
|
||||||
log.info("{} 생성 시작", getEntityName());
|
|
||||||
T entity = toEntity(dto);
|
|
||||||
T saved = getRepository().save(entity);
|
|
||||||
log.info("{} 생성 완료: ID={}", getEntityName(), extractId(saved));
|
|
||||||
return toDto(saved);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Optional<D> findById(ID id) {
|
|
||||||
log.debug("{} 조회: ID={}", getEntityName(), id);
|
|
||||||
return getRepository().findById(id).map(this::toDto);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public List<D> findAll() {
|
|
||||||
log.debug("{} 전체 조회", getEntityName());
|
|
||||||
return getRepository().findAll().stream()
|
|
||||||
.map(this::toDto)
|
|
||||||
.collect(Collectors.toList());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public List<D> findAll(int offset, int limit) {
|
|
||||||
log.debug("{} 페이징 조회: offset={}, limit={}", getEntityName(), offset, limit);
|
|
||||||
|
|
||||||
// 하위 클래스에서 제공하는 페이징 쿼리 실행
|
|
||||||
List<T> entities = executePagingQuery(offset, limit);
|
|
||||||
|
|
||||||
return entities.stream()
|
|
||||||
.map(this::toDto)
|
|
||||||
.collect(Collectors.toList());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 페이징 쿼리 실행 (하위 클래스에서 구현)
|
|
||||||
*
|
|
||||||
* @param offset 시작 위치
|
|
||||||
* @param limit 조회 개수
|
|
||||||
* @return Entity 리스트
|
|
||||||
*/
|
|
||||||
protected abstract List<T> executePagingQuery(int offset, int limit);
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public long count() {
|
|
||||||
log.debug("{} 개수 조회", getEntityName());
|
|
||||||
return getRepository().count();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
@Transactional
|
|
||||||
public D update(ID id, D dto) {
|
|
||||||
log.info("{} 수정 시작: ID={}", getEntityName(), id);
|
|
||||||
|
|
||||||
T entity = getRepository().findById(id)
|
|
||||||
.orElseThrow(() -> new IllegalArgumentException(
|
|
||||||
getEntityName() + " not found with id: " + id));
|
|
||||||
|
|
||||||
updateEntity(entity, dto);
|
|
||||||
T updated = getRepository().save(entity);
|
|
||||||
|
|
||||||
log.info("{} 수정 완료: ID={}", getEntityName(), id);
|
|
||||||
return toDto(updated);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
@Transactional
|
|
||||||
public void deleteById(ID id) {
|
|
||||||
log.info("{} 삭제: ID={}", getEntityName(), id);
|
|
||||||
|
|
||||||
if (!getRepository().existsById(id)) {
|
|
||||||
throw new IllegalArgumentException(
|
|
||||||
getEntityName() + " not found with id: " + id);
|
|
||||||
}
|
|
||||||
|
|
||||||
getRepository().deleteById(id);
|
|
||||||
log.info("{} 삭제 완료: ID={}", getEntityName(), id);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean existsById(ID id) {
|
|
||||||
return getRepository().existsById(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Entity 업데이트 (하위 클래스에서 구현)
|
|
||||||
*
|
|
||||||
* @param entity 업데이트할 엔티티
|
|
||||||
* @param dto 업데이트 데이터
|
|
||||||
*/
|
|
||||||
protected abstract void updateEntity(T entity, D dto);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Entity에서 ID 추출 (로깅용, 하위 클래스에서 구현)
|
|
||||||
*/
|
|
||||||
protected abstract ID extractId(T entity);
|
|
||||||
}
|
|
||||||
@ -15,9 +15,9 @@ import java.util.List;
|
|||||||
* Swagger/OpenAPI 3.0 설정
|
* Swagger/OpenAPI 3.0 설정
|
||||||
*
|
*
|
||||||
* Swagger UI 접속 URL:
|
* Swagger UI 접속 URL:
|
||||||
* - Swagger UI: http://localhost:8081/swagger-ui/index.html
|
* - Swagger UI: http://localhost:8041/snp-api/swagger-ui/index.html
|
||||||
* - API 문서 (JSON): http://localhost:8081/v3/api-docs
|
* - API 문서 (JSON): http://localhost:8041/snp-api/v3/api-docs
|
||||||
* - API 문서 (YAML): http://localhost:8081/v3/api-docs.yaml
|
* - API 문서 (YAML): http://localhost:8041/snp-api/v3/api-docs.yaml
|
||||||
*
|
*
|
||||||
* 주요 기능:
|
* 주요 기능:
|
||||||
* - REST API 자동 문서화
|
* - REST API 자동 문서화
|
||||||
@ -62,17 +62,19 @@ public class SwaggerConfig {
|
|||||||
.description("""
|
.description("""
|
||||||
## SNP Batch 시스템 REST API 문서
|
## SNP Batch 시스템 REST API 문서
|
||||||
|
|
||||||
Spring Batch 기반 데이터 통합 시스템의 REST API 문서입니다.
|
해양 데이터 통합 배치 시스템의 REST API 문서입니다.
|
||||||
|
|
||||||
### 제공 API
|
### 제공 API
|
||||||
- **Batch API**: 배치 Job 실행 및 관리
|
- **Batch Management API**: 배치 Job 실행, 이력 조회, 스케줄 관리
|
||||||
- **Product API**: 샘플 제품 데이터 CRUD (샘플용)
|
- **AIS Target API**: AIS 선박 위치 정보 조회 (캐시 기반, 공간/조건 검색)
|
||||||
|
|
||||||
### 주요 기능
|
### 주요 기능
|
||||||
- 배치 Job 실행 및 중지
|
- 배치 Job 실행 및 중지
|
||||||
- Job 실행 이력 조회
|
- Job 실행 이력 조회
|
||||||
- 스케줄 관리 (Quartz)
|
- 스케줄 관리 (Quartz)
|
||||||
- 제품 데이터 CRUD (샘플)
|
- AIS 선박 실시간 위치 조회 (MMSI 단건/다건, 시간/공간 범위 검색)
|
||||||
|
- 항해 조건 필터 검색 (SOG, COG, Heading, 목적지, 항행상태)
|
||||||
|
- 폴리곤/WKT 범위 검색, 거리 포함 검색, 항적 조회
|
||||||
|
|
||||||
### 버전 정보
|
### 버전 정보
|
||||||
- API Version: v1.0.0
|
- API Version: v1.0.0
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
package com.snp.batch.global.controller;
|
package com.snp.batch.global.controller;
|
||||||
|
|
||||||
import com.snp.batch.global.dto.JobExecutionDto;
|
import com.snp.batch.global.dto.*;
|
||||||
import com.snp.batch.global.dto.JobLaunchRequest;
|
import com.snp.batch.global.model.BatchCollectionPeriod;
|
||||||
import com.snp.batch.global.dto.ScheduleRequest;
|
import com.snp.batch.global.model.BatchRecollectionHistory;
|
||||||
import com.snp.batch.global.dto.ScheduleResponse;
|
|
||||||
import com.snp.batch.service.BatchService;
|
import com.snp.batch.service.BatchService;
|
||||||
|
import com.snp.batch.service.RecollectionHistoryService;
|
||||||
import com.snp.batch.service.ScheduleService;
|
import com.snp.batch.service.ScheduleService;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.Parameter;
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
@ -20,6 +20,11 @@ import org.springdoc.core.annotations.ParameterObject;
|
|||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@ -33,6 +38,7 @@ public class BatchController {
|
|||||||
|
|
||||||
private final BatchService batchService;
|
private final BatchService batchService;
|
||||||
private final ScheduleService scheduleService;
|
private final ScheduleService scheduleService;
|
||||||
|
private final RecollectionHistoryService recollectionHistoryService;
|
||||||
|
|
||||||
@Operation(summary = "배치 작업 실행", description = "지정된 배치 작업을 즉시 실행합니다. 쿼리 파라미터로 Job Parameters 전달 가능")
|
@Operation(summary = "배치 작업 실행", description = "지정된 배치 작업을 즉시 실행합니다. 쿼리 파라미터로 Job Parameters 전달 가능")
|
||||||
@ApiResponses(value = {
|
@ApiResponses(value = {
|
||||||
@ -101,7 +107,7 @@ public class BatchController {
|
|||||||
})
|
})
|
||||||
@GetMapping("/jobs")
|
@GetMapping("/jobs")
|
||||||
public ResponseEntity<List<String>> listJobs() {
|
public ResponseEntity<List<String>> listJobs() {
|
||||||
log.info("Received request to list all jobs");
|
log.debug("Received request to list all jobs");
|
||||||
List<String> jobs = batchService.listAllJobs();
|
List<String> jobs = batchService.listAllJobs();
|
||||||
return ResponseEntity.ok(jobs);
|
return ResponseEntity.ok(jobs);
|
||||||
}
|
}
|
||||||
@ -119,6 +125,16 @@ public class BatchController {
|
|||||||
return ResponseEntity.ok(executions);
|
return ResponseEntity.ok(executions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "최근 전체 실행 이력 조회", description = "Job 구분 없이 최근 실행 이력을 조회합니다")
|
||||||
|
@GetMapping("/executions/recent")
|
||||||
|
public ResponseEntity<List<JobExecutionDto>> getRecentExecutions(
|
||||||
|
@Parameter(description = "조회 건수", example = "50")
|
||||||
|
@RequestParam(defaultValue = "50") int limit) {
|
||||||
|
log.debug("Received request to get recent executions: limit={}", limit);
|
||||||
|
List<JobExecutionDto> executions = batchService.getRecentExecutions(limit);
|
||||||
|
return ResponseEntity.ok(executions);
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/executions/{executionId}")
|
@GetMapping("/executions/{executionId}")
|
||||||
public ResponseEntity<JobExecutionDto> getExecutionDetails(@PathVariable Long executionId) {
|
public ResponseEntity<JobExecutionDto> getExecutionDetails(@PathVariable Long executionId) {
|
||||||
log.info("Received request to get execution details for: {}", executionId);
|
log.info("Received request to get execution details for: {}", executionId);
|
||||||
@ -291,7 +307,7 @@ public class BatchController {
|
|||||||
public ResponseEntity<com.snp.batch.global.dto.TimelineResponse> getTimeline(
|
public ResponseEntity<com.snp.batch.global.dto.TimelineResponse> getTimeline(
|
||||||
@RequestParam String view,
|
@RequestParam String view,
|
||||||
@RequestParam String date) {
|
@RequestParam String date) {
|
||||||
log.info("Received request to get timeline: view={}, date={}", view, date);
|
log.debug("Received request to get timeline: view={}, date={}", view, date);
|
||||||
try {
|
try {
|
||||||
com.snp.batch.global.dto.TimelineResponse timeline = batchService.getTimeline(view, date);
|
com.snp.batch.global.dto.TimelineResponse timeline = batchService.getTimeline(view, date);
|
||||||
return ResponseEntity.ok(timeline);
|
return ResponseEntity.ok(timeline);
|
||||||
@ -303,7 +319,7 @@ public class BatchController {
|
|||||||
|
|
||||||
@GetMapping("/dashboard")
|
@GetMapping("/dashboard")
|
||||||
public ResponseEntity<com.snp.batch.global.dto.DashboardResponse> getDashboard() {
|
public ResponseEntity<com.snp.batch.global.dto.DashboardResponse> getDashboard() {
|
||||||
log.info("Received request to get dashboard data");
|
log.debug("Received request to get dashboard data");
|
||||||
try {
|
try {
|
||||||
com.snp.batch.global.dto.DashboardResponse dashboard = batchService.getDashboardData();
|
com.snp.batch.global.dto.DashboardResponse dashboard = batchService.getDashboardData();
|
||||||
return ResponseEntity.ok(dashboard);
|
return ResponseEntity.ok(dashboard);
|
||||||
@ -327,4 +343,237 @@ public class BatchController {
|
|||||||
return ResponseEntity.internalServerError().build();
|
return ResponseEntity.internalServerError().build();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── F1: 강제 종료(Abandon) API ─────────────────────────────
|
||||||
|
|
||||||
|
@Operation(summary = "오래된 실행 중 목록 조회", description = "지정된 시간(분) 이상 STARTED/STARTING 상태인 실행 목록을 조회합니다")
|
||||||
|
@GetMapping("/executions/stale")
|
||||||
|
public ResponseEntity<List<JobExecutionDto>> getStaleExecutions(
|
||||||
|
@Parameter(description = "임계 시간(분)", example = "60")
|
||||||
|
@RequestParam(defaultValue = "60") int thresholdMinutes) {
|
||||||
|
log.info("Received request to get stale executions: thresholdMinutes={}", thresholdMinutes);
|
||||||
|
List<JobExecutionDto> executions = batchService.getStaleExecutions(thresholdMinutes);
|
||||||
|
return ResponseEntity.ok(executions);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "실행 강제 종료", description = "특정 실행을 ABANDONED 상태로 강제 변경합니다")
|
||||||
|
@PostMapping("/executions/{executionId}/abandon")
|
||||||
|
public ResponseEntity<Map<String, Object>> abandonExecution(@PathVariable Long executionId) {
|
||||||
|
log.info("Received request to abandon execution: {}", executionId);
|
||||||
|
try {
|
||||||
|
batchService.abandonExecution(executionId);
|
||||||
|
return ResponseEntity.ok(Map.of(
|
||||||
|
"success", true,
|
||||||
|
"message", "Execution abandoned successfully"
|
||||||
|
));
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of(
|
||||||
|
"success", false,
|
||||||
|
"message", e.getMessage()
|
||||||
|
));
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Error abandoning execution: {}", executionId, e);
|
||||||
|
return ResponseEntity.internalServerError().body(Map.of(
|
||||||
|
"success", false,
|
||||||
|
"message", "Failed to abandon execution: " + e.getMessage()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "오래된 실행 전체 강제 종료", description = "지정된 시간(분) 이상 실행 중인 모든 Job을 ABANDONED로 변경합니다")
|
||||||
|
@PostMapping("/executions/stale/abandon-all")
|
||||||
|
public ResponseEntity<Map<String, Object>> abandonAllStaleExecutions(
|
||||||
|
@Parameter(description = "임계 시간(분)", example = "60")
|
||||||
|
@RequestParam(defaultValue = "60") int thresholdMinutes) {
|
||||||
|
log.info("Received request to abandon all stale executions: thresholdMinutes={}", thresholdMinutes);
|
||||||
|
try {
|
||||||
|
int count = batchService.abandonAllStaleExecutions(thresholdMinutes);
|
||||||
|
return ResponseEntity.ok(Map.of(
|
||||||
|
"success", true,
|
||||||
|
"message", count + "건의 실행이 강제 종료되었습니다",
|
||||||
|
"abandonedCount", count
|
||||||
|
));
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Error abandoning all stale executions", e);
|
||||||
|
return ResponseEntity.internalServerError().body(Map.of(
|
||||||
|
"success", false,
|
||||||
|
"message", "Failed to abandon stale executions: " + e.getMessage()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── F4: 실행 이력 검색 API ─────────────────────────────────
|
||||||
|
|
||||||
|
@Operation(summary = "실행 이력 검색", description = "조건별 실행 이력 검색 (페이지네이션 지원)")
|
||||||
|
@GetMapping("/executions/search")
|
||||||
|
public ResponseEntity<ExecutionSearchResponse> searchExecutions(
|
||||||
|
@Parameter(description = "Job 이름 (콤마 구분, 복수 가능)") @RequestParam(required = false) String jobNames,
|
||||||
|
@Parameter(description = "상태 (필터)", example = "COMPLETED") @RequestParam(required = false) String status,
|
||||||
|
@Parameter(description = "시작일 (yyyy-MM-dd'T'HH:mm:ss)") @RequestParam(required = false) String startDate,
|
||||||
|
@Parameter(description = "종료일 (yyyy-MM-dd'T'HH:mm:ss)") @RequestParam(required = false) String endDate,
|
||||||
|
@Parameter(description = "페이지 번호 (0부터)") @RequestParam(defaultValue = "0") int page,
|
||||||
|
@Parameter(description = "페이지 크기") @RequestParam(defaultValue = "50") int size) {
|
||||||
|
log.debug("Search executions: jobNames={}, status={}, startDate={}, endDate={}, page={}, size={}",
|
||||||
|
jobNames, status, startDate, endDate, page, size);
|
||||||
|
|
||||||
|
List<String> jobNameList = (jobNames != null && !jobNames.isBlank())
|
||||||
|
? java.util.Arrays.stream(jobNames.split(","))
|
||||||
|
.map(String::trim).filter(s -> !s.isEmpty()).toList()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
LocalDateTime start = startDate != null ? LocalDateTime.parse(startDate) : null;
|
||||||
|
LocalDateTime end = endDate != null ? LocalDateTime.parse(endDate) : null;
|
||||||
|
|
||||||
|
ExecutionSearchResponse response = batchService.searchExecutions(jobNameList, status, start, end, page, size);
|
||||||
|
return ResponseEntity.ok(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── F7: Job 상세 목록 API ──────────────────────────────────
|
||||||
|
|
||||||
|
@Operation(summary = "Job 상세 목록 조회", description = "모든 Job의 최근 실행 상태 및 스케줄 정보를 조회합니다")
|
||||||
|
@GetMapping("/jobs/detail")
|
||||||
|
public ResponseEntity<List<JobDetailDto>> getJobsDetail() {
|
||||||
|
log.debug("Received request to get jobs with detail");
|
||||||
|
List<JobDetailDto> jobs = batchService.getJobsWithDetail();
|
||||||
|
return ResponseEntity.ok(jobs);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── F8: 실행 통계 API ──────────────────────────────────────
|
||||||
|
|
||||||
|
@Operation(summary = "전체 실행 통계", description = "전체 배치 작업의 일별 실행 통계를 조회합니다")
|
||||||
|
@GetMapping("/statistics")
|
||||||
|
public ResponseEntity<ExecutionStatisticsDto> getStatistics(
|
||||||
|
@Parameter(description = "조회 기간(일)", example = "30")
|
||||||
|
@RequestParam(defaultValue = "30") int days) {
|
||||||
|
log.debug("Received request to get statistics: days={}", days);
|
||||||
|
ExecutionStatisticsDto stats = batchService.getStatistics(days);
|
||||||
|
return ResponseEntity.ok(stats);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "Job별 실행 통계", description = "특정 배치 작업의 일별 실행 통계를 조회합니다")
|
||||||
|
@GetMapping("/statistics/{jobName}")
|
||||||
|
public ResponseEntity<ExecutionStatisticsDto> getJobStatistics(
|
||||||
|
@Parameter(description = "Job 이름", required = true) @PathVariable String jobName,
|
||||||
|
@Parameter(description = "조회 기간(일)", example = "30")
|
||||||
|
@RequestParam(defaultValue = "30") int days) {
|
||||||
|
log.debug("Received request to get statistics for job: {}, days={}", jobName, days);
|
||||||
|
ExecutionStatisticsDto stats = batchService.getJobStatistics(jobName, days);
|
||||||
|
return ResponseEntity.ok(stats);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 재수집 이력 관리 API ─────────────────────────────────────
|
||||||
|
|
||||||
|
@Operation(summary = "재수집 이력 목록 조회", description = "필터 조건으로 재수집 이력을 페이징 조회합니다")
|
||||||
|
@GetMapping("/recollection-histories")
|
||||||
|
public ResponseEntity<Map<String, Object>> getRecollectionHistories(
|
||||||
|
@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,
|
||||||
|
@Parameter(description = "페이지 번호 (0부터)") @RequestParam(defaultValue = "0") int page,
|
||||||
|
@Parameter(description = "페이지 크기") @RequestParam(defaultValue = "20") int size) {
|
||||||
|
log.debug("Search recollection histories: apiKey={}, jobName={}, status={}, page={}, size={}",
|
||||||
|
apiKey, jobName, status, page, size);
|
||||||
|
|
||||||
|
LocalDateTime from = fromDate != null ? LocalDateTime.parse(fromDate) : null;
|
||||||
|
LocalDateTime to = toDate != null ? LocalDateTime.parse(toDate) : null;
|
||||||
|
|
||||||
|
Page<BatchRecollectionHistory> histories = recollectionHistoryService
|
||||||
|
.getHistories(apiKey, jobName, status, from, to, PageRequest.of(page, size));
|
||||||
|
|
||||||
|
Map<String, Object> response = new HashMap<>();
|
||||||
|
response.put("content", histories.getContent());
|
||||||
|
response.put("totalElements", histories.getTotalElements());
|
||||||
|
response.put("totalPages", histories.getTotalPages());
|
||||||
|
response.put("number", histories.getNumber());
|
||||||
|
response.put("size", histories.getSize());
|
||||||
|
return ResponseEntity.ok(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "재수집 이력 상세 조회", description = "재수집 이력의 상세 정보 (Step Execution + Collection Period + 중복 이력 + API 통계 포함)")
|
||||||
|
@GetMapping("/recollection-histories/{historyId}")
|
||||||
|
public ResponseEntity<Map<String, Object>> getRecollectionHistoryDetail(
|
||||||
|
@Parameter(description = "이력 ID") @PathVariable Long historyId) {
|
||||||
|
log.debug("Get recollection history detail: historyId={}", historyId);
|
||||||
|
try {
|
||||||
|
Map<String, Object> detail = recollectionHistoryService.getHistoryDetailWithSteps(historyId);
|
||||||
|
return ResponseEntity.ok(detail);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "재수집 통계 조회", description = "재수집 실행 통계 및 최근 10건 조회")
|
||||||
|
@GetMapping("/recollection-histories/stats")
|
||||||
|
public ResponseEntity<Map<String, Object>> getRecollectionHistoryStats() {
|
||||||
|
log.debug("Get recollection history stats");
|
||||||
|
Map<String, Object> stats = recollectionHistoryService.getHistoryStats();
|
||||||
|
stats.put("recentHistories", recollectionHistoryService.getRecentHistories());
|
||||||
|
return ResponseEntity.ok(stats);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 수집 기간 관리 API ───────────────────────────────────────
|
||||||
|
|
||||||
|
@Operation(summary = "수집 기간 목록 조회", description = "모든 API의 수집 기간 설정을 조회합니다")
|
||||||
|
@GetMapping("/collection-periods")
|
||||||
|
public ResponseEntity<List<BatchCollectionPeriod>> getCollectionPeriods() {
|
||||||
|
log.debug("Get all collection periods");
|
||||||
|
return ResponseEntity.ok(recollectionHistoryService.getAllCollectionPeriods());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "수집 기간 수정", description = "특정 API의 수집 기간을 수정합니다")
|
||||||
|
@PutMapping("/collection-periods/{apiKey}")
|
||||||
|
public ResponseEntity<Map<String, Object>> updateCollectionPeriod(
|
||||||
|
@Parameter(description = "API Key") @PathVariable String apiKey,
|
||||||
|
@RequestBody Map<String, String> request) {
|
||||||
|
log.info("Update collection period: apiKey={}", apiKey);
|
||||||
|
try {
|
||||||
|
String rangeFromStr = request.get("rangeFromDate");
|
||||||
|
String rangeToStr = request.get("rangeToDate");
|
||||||
|
|
||||||
|
if (rangeFromStr == null || rangeToStr == null) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of(
|
||||||
|
"success", false,
|
||||||
|
"message", "rangeFromDate와 rangeToDate는 필수입니다"));
|
||||||
|
}
|
||||||
|
|
||||||
|
LocalDateTime rangeFrom = LocalDateTime.parse(rangeFromStr);
|
||||||
|
LocalDateTime rangeTo = LocalDateTime.parse(rangeToStr);
|
||||||
|
|
||||||
|
if (rangeTo.isBefore(rangeFrom)) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of(
|
||||||
|
"success", false,
|
||||||
|
"message", "rangeToDate는 rangeFromDate보다 이후여야 합니다"));
|
||||||
|
}
|
||||||
|
|
||||||
|
recollectionHistoryService.updateCollectionPeriod(apiKey, rangeFrom, rangeTo);
|
||||||
|
return ResponseEntity.ok(Map.of(
|
||||||
|
"success", true,
|
||||||
|
"message", "수집 기간이 수정되었습니다"));
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Error updating collection period: apiKey={}", apiKey, e);
|
||||||
|
return ResponseEntity.internalServerError().body(Map.of(
|
||||||
|
"success", false,
|
||||||
|
"message", "수집 기간 수정 실패: " + e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "수집 기간 초기화", description = "특정 API의 수집 기간을 null로 초기화합니다")
|
||||||
|
@PostMapping("/collection-periods/{apiKey}/reset")
|
||||||
|
public ResponseEntity<Map<String, Object>> resetCollectionPeriod(
|
||||||
|
@Parameter(description = "API Key") @PathVariable String apiKey) {
|
||||||
|
log.info("Reset collection period: apiKey={}", apiKey);
|
||||||
|
try {
|
||||||
|
recollectionHistoryService.resetCollectionPeriod(apiKey);
|
||||||
|
return ResponseEntity.ok(Map.of(
|
||||||
|
"success", true,
|
||||||
|
"message", "수집 기간이 초기화되었습니다"));
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Error resetting collection period: apiKey={}", apiKey, e);
|
||||||
|
return ResponseEntity.internalServerError().body(Map.of(
|
||||||
|
"success", false,
|
||||||
|
"message", "수집 기간 초기화 실패: " + e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,41 +3,18 @@ package com.snp.batch.global.controller;
|
|||||||
import org.springframework.stereotype.Controller;
|
import org.springframework.stereotype.Controller;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SPA(React) fallback 라우터
|
||||||
|
*
|
||||||
|
* React Router가 클라이언트 사이드 라우팅을 처리하므로,
|
||||||
|
* 모든 프론트 경로를 index.html로 포워딩한다.
|
||||||
|
*/
|
||||||
@Controller
|
@Controller
|
||||||
public class WebViewController {
|
public class WebViewController {
|
||||||
|
|
||||||
@GetMapping("/")
|
@GetMapping({"/", "/jobs", "/executions", "/executions/{id:\\d+}",
|
||||||
public String index() {
|
"/execution-detail", "/schedules", "/schedule-timeline"})
|
||||||
return "index";
|
public String forward() {
|
||||||
}
|
return "forward:/index.html";
|
||||||
|
|
||||||
@GetMapping("/jobs")
|
|
||||||
public String jobs() {
|
|
||||||
return "jobs";
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/executions")
|
|
||||||
public String executions() {
|
|
||||||
return "executions";
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/schedules")
|
|
||||||
public String schedules() {
|
|
||||||
return "schedules";
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/execution-detail")
|
|
||||||
public String executionDetail() {
|
|
||||||
return "execution-detail";
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/executions/{id}")
|
|
||||||
public String executionDetailById() {
|
|
||||||
return "execution-detail";
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/schedule-timeline")
|
|
||||||
public String scheduleTimeline() {
|
|
||||||
return "schedule-timeline";
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,6 +16,9 @@ public class DashboardResponse {
|
|||||||
private Stats stats;
|
private Stats stats;
|
||||||
private List<RunningJob> runningJobs;
|
private List<RunningJob> runningJobs;
|
||||||
private List<RecentExecution> recentExecutions;
|
private List<RecentExecution> recentExecutions;
|
||||||
|
private List<RecentFailure> recentFailures;
|
||||||
|
private int staleExecutionCount;
|
||||||
|
private FailureStats failureStats;
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
@Builder
|
@Builder
|
||||||
@ -50,4 +53,26 @@ public class DashboardResponse {
|
|||||||
private LocalDateTime startTime;
|
private LocalDateTime startTime;
|
||||||
private LocalDateTime endTime;
|
private LocalDateTime endTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public static class RecentFailure {
|
||||||
|
private Long executionId;
|
||||||
|
private String jobName;
|
||||||
|
private String status;
|
||||||
|
private LocalDateTime startTime;
|
||||||
|
private LocalDateTime endTime;
|
||||||
|
private String exitMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public static class FailureStats {
|
||||||
|
private int last24h;
|
||||||
|
private int last7d;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,21 @@
|
|||||||
|
package com.snp.batch.global.dto;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class ExecutionSearchResponse {
|
||||||
|
|
||||||
|
private List<JobExecutionDto> executions;
|
||||||
|
private int totalCount;
|
||||||
|
private int page;
|
||||||
|
private int size;
|
||||||
|
private int totalPages;
|
||||||
|
}
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
package com.snp.batch.global.dto;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class ExecutionStatisticsDto {
|
||||||
|
|
||||||
|
private List<DailyStat> dailyStats;
|
||||||
|
private int totalExecutions;
|
||||||
|
private int totalSuccess;
|
||||||
|
private int totalFailed;
|
||||||
|
private double avgDurationMs;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public static class DailyStat {
|
||||||
|
private String date;
|
||||||
|
private int successCount;
|
||||||
|
private int failedCount;
|
||||||
|
private int otherCount;
|
||||||
|
private double avgDurationMs;
|
||||||
|
}
|
||||||
|
}
|
||||||
30
src/main/java/com/snp/batch/global/dto/JobDetailDto.java
Normal file
30
src/main/java/com/snp/batch/global/dto/JobDetailDto.java
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
package com.snp.batch.global.dto;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class JobDetailDto {
|
||||||
|
|
||||||
|
private String jobName;
|
||||||
|
private LastExecution lastExecution;
|
||||||
|
private String scheduleCron;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public static class LastExecution {
|
||||||
|
private Long executionId;
|
||||||
|
private String status;
|
||||||
|
private LocalDateTime startTime;
|
||||||
|
private LocalDateTime endTime;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -68,11 +68,12 @@ public class JobExecutionDetailDto {
|
|||||||
private String exitCode;
|
private String exitCode;
|
||||||
private String exitMessage;
|
private String exitMessage;
|
||||||
private Long duration; // 실행 시간 (ms)
|
private Long duration; // 실행 시간 (ms)
|
||||||
private ApiCallInfo apiCallInfo; // API 호출 정보 (옵셔널)
|
private ApiCallInfo apiCallInfo; // API 호출 정보 - StepExecutionContext 기반 (옵셔널)
|
||||||
|
private StepApiLogSummary apiLogSummary; // API 호출 로그 요약 - batch_api_log 기반 (옵셔널)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* API 호출 정보 DTO
|
* API 호출 정보 DTO (StepExecutionContext 기반)
|
||||||
*/
|
*/
|
||||||
@Data
|
@Data
|
||||||
@Builder
|
@Builder
|
||||||
@ -86,4 +87,41 @@ public class JobExecutionDetailDto {
|
|||||||
private Integer completedCalls; // 완료된 API 호출 횟수
|
private Integer completedCalls; // 완료된 API 호출 횟수
|
||||||
private String lastCallTime; // 마지막 호출 시간
|
private String lastCallTime; // 마지막 호출 시간
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step별 API 로그 집계 요약 (batch_api_log 테이블 기반)
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public static class StepApiLogSummary {
|
||||||
|
private Long totalCalls; // 총 호출수
|
||||||
|
private Long successCount; // 성공(2xx) 수
|
||||||
|
private Long errorCount; // 에러(4xx/5xx) 수
|
||||||
|
private Double avgResponseMs; // 평균 응답시간
|
||||||
|
private Long maxResponseMs; // 최대 응답시간
|
||||||
|
private Long minResponseMs; // 최소 응답시간
|
||||||
|
private Long totalResponseMs; // 총 응답시간
|
||||||
|
private Long totalRecordCount; // 총 반환 건수
|
||||||
|
private List<ApiLogEntryDto> logs; // 개별 로그 목록
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 개별 API 호출 로그 DTO (batch_api_log 1건)
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public static class ApiLogEntryDto {
|
||||||
|
private Long logId;
|
||||||
|
private String requestUri;
|
||||||
|
private String httpMethod;
|
||||||
|
private Integer statusCode;
|
||||||
|
private Long responseTimeMs;
|
||||||
|
private Long responseCount;
|
||||||
|
private String errorMessage;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import org.hibernate.annotations.CreationTimestamp;
|
|||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "batch_api_log", schema = "snp_data")
|
@Table(name = "batch_api_log", schema = "t_std_snp_data")
|
||||||
@Getter
|
@Getter
|
||||||
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
||||||
@AllArgsConstructor
|
@AllArgsConstructor
|
||||||
|
|||||||
@ -0,0 +1,53 @@
|
|||||||
|
package com.snp.batch.global.model;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import lombok.Setter;
|
||||||
|
import org.springframework.data.annotation.CreatedDate;
|
||||||
|
import org.springframework.data.annotation.LastModifiedDate;
|
||||||
|
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@NoArgsConstructor
|
||||||
|
@Table(name = "BATCH_COLLECTION_PERIOD")
|
||||||
|
@EntityListeners(AuditingEntityListener.class)
|
||||||
|
public class BatchCollectionPeriod {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "API_KEY", length = 50)
|
||||||
|
private String apiKey;
|
||||||
|
|
||||||
|
@Column(name = "API_KEY_NAME", length = 100)
|
||||||
|
private String apiKeyName;
|
||||||
|
|
||||||
|
@Column(name = "JOB_NAME", length = 100)
|
||||||
|
private String jobName;
|
||||||
|
|
||||||
|
@Column(name = "ORDER_SEQ")
|
||||||
|
private Integer orderSeq;
|
||||||
|
|
||||||
|
@Column(name = "RANGE_FROM_DATE")
|
||||||
|
private LocalDateTime rangeFromDate;
|
||||||
|
|
||||||
|
@Column(name = "RANGE_TO_DATE")
|
||||||
|
private LocalDateTime rangeToDate;
|
||||||
|
|
||||||
|
@CreatedDate
|
||||||
|
@Column(name = "CREATED_AT", updatable = false, nullable = false)
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@LastModifiedDate
|
||||||
|
@Column(name = "UPDATED_AT", nullable = false)
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
public BatchCollectionPeriod(String apiKey, LocalDateTime rangeFromDate, LocalDateTime rangeToDate) {
|
||||||
|
this.apiKey = apiKey;
|
||||||
|
this.rangeFromDate = rangeFromDate;
|
||||||
|
this.rangeToDate = rangeToDate;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,93 @@
|
|||||||
|
package com.snp.batch.global.model;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.*;
|
||||||
|
import org.springframework.data.annotation.CreatedDate;
|
||||||
|
import org.springframework.data.annotation.LastModifiedDate;
|
||||||
|
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Builder
|
||||||
|
@Table(name = "BATCH_RECOLLECTION_HISTORY")
|
||||||
|
@EntityListeners(AuditingEntityListener.class)
|
||||||
|
public class BatchRecollectionHistory {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
@Column(name = "HISTORY_ID")
|
||||||
|
private Long historyId;
|
||||||
|
|
||||||
|
@Column(name = "API_KEY", length = 50, nullable = false)
|
||||||
|
private String apiKey;
|
||||||
|
|
||||||
|
@Column(name = "API_KEY_NAME", length = 100)
|
||||||
|
private String apiKeyName;
|
||||||
|
|
||||||
|
@Column(name = "JOB_NAME", length = 100, nullable = false)
|
||||||
|
private String jobName;
|
||||||
|
|
||||||
|
@Column(name = "JOB_EXECUTION_ID")
|
||||||
|
private Long jobExecutionId;
|
||||||
|
|
||||||
|
@Column(name = "RANGE_FROM_DATE", nullable = false)
|
||||||
|
private LocalDateTime rangeFromDate;
|
||||||
|
|
||||||
|
@Column(name = "RANGE_TO_DATE", nullable = false)
|
||||||
|
private LocalDateTime rangeToDate;
|
||||||
|
|
||||||
|
@Column(name = "EXECUTION_STATUS", length = 20, nullable = false)
|
||||||
|
private String executionStatus;
|
||||||
|
|
||||||
|
@Column(name = "EXECUTION_START_TIME")
|
||||||
|
private LocalDateTime executionStartTime;
|
||||||
|
|
||||||
|
@Column(name = "EXECUTION_END_TIME")
|
||||||
|
private LocalDateTime executionEndTime;
|
||||||
|
|
||||||
|
@Column(name = "DURATION_MS")
|
||||||
|
private Long durationMs;
|
||||||
|
|
||||||
|
@Column(name = "READ_COUNT")
|
||||||
|
private Long readCount;
|
||||||
|
|
||||||
|
@Column(name = "WRITE_COUNT")
|
||||||
|
private Long writeCount;
|
||||||
|
|
||||||
|
@Column(name = "SKIP_COUNT")
|
||||||
|
private Long skipCount;
|
||||||
|
|
||||||
|
@Column(name = "API_CALL_COUNT")
|
||||||
|
private Integer apiCallCount;
|
||||||
|
|
||||||
|
@Column(name = "TOTAL_RESPONSE_TIME_MS")
|
||||||
|
private Long totalResponseTimeMs;
|
||||||
|
|
||||||
|
@Column(name = "EXECUTOR", length = 50)
|
||||||
|
private String executor;
|
||||||
|
|
||||||
|
@Column(name = "RECOLLECTION_REASON", columnDefinition = "TEXT")
|
||||||
|
private String recollectionReason;
|
||||||
|
|
||||||
|
@Column(name = "FAILURE_REASON", columnDefinition = "TEXT")
|
||||||
|
private String failureReason;
|
||||||
|
|
||||||
|
@Column(name = "HAS_OVERLAP")
|
||||||
|
private Boolean hasOverlap;
|
||||||
|
|
||||||
|
@Column(name = "OVERLAPPING_HISTORY_IDS", length = 500)
|
||||||
|
private String overlappingHistoryIds;
|
||||||
|
|
||||||
|
@CreatedDate
|
||||||
|
@Column(name = "CREATED_AT", updatable = false, nullable = false)
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@LastModifiedDate
|
||||||
|
@Column(name = "UPDATED_AT", nullable = false)
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
}
|
||||||
@ -2,9 +2,45 @@ package com.snp.batch.global.repository;
|
|||||||
|
|
||||||
import com.snp.batch.global.model.BatchApiLog;
|
import com.snp.batch.global.model.BatchApiLog;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
public interface BatchApiLogRepository extends JpaRepository<BatchApiLog, Long> {
|
public interface BatchApiLogRepository extends JpaRepository<BatchApiLog, Long> {
|
||||||
|
|
||||||
|
@Query("""
|
||||||
|
SELECT COUNT(l),
|
||||||
|
COALESCE(SUM(l.responseTimeMs), 0),
|
||||||
|
COALESCE(AVG(l.responseTimeMs), 0),
|
||||||
|
COALESCE(MAX(l.responseTimeMs), 0),
|
||||||
|
COALESCE(MIN(l.responseTimeMs), 0)
|
||||||
|
FROM BatchApiLog l
|
||||||
|
WHERE l.jobExecutionId = :jobExecutionId
|
||||||
|
""")
|
||||||
|
List<Object[]> getApiStatsByJobExecutionId(@Param("jobExecutionId") Long jobExecutionId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step별 API 호출 통계 집계
|
||||||
|
*/
|
||||||
|
@Query("""
|
||||||
|
SELECT COUNT(l),
|
||||||
|
SUM(CASE WHEN l.statusCode >= 200 AND l.statusCode < 300 THEN 1 ELSE 0 END),
|
||||||
|
SUM(CASE WHEN l.statusCode >= 400 OR l.errorMessage IS NOT NULL THEN 1 ELSE 0 END),
|
||||||
|
COALESCE(AVG(l.responseTimeMs), 0),
|
||||||
|
COALESCE(MAX(l.responseTimeMs), 0),
|
||||||
|
COALESCE(MIN(l.responseTimeMs), 0),
|
||||||
|
COALESCE(SUM(l.responseTimeMs), 0),
|
||||||
|
COALESCE(SUM(l.responseCount), 0)
|
||||||
|
FROM BatchApiLog l
|
||||||
|
WHERE l.stepExecutionId = :stepExecutionId
|
||||||
|
""")
|
||||||
|
List<Object[]> getApiStatsByStepExecutionId(@Param("stepExecutionId") Long stepExecutionId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step별 개별 API 호출 로그 목록
|
||||||
|
*/
|
||||||
|
List<BatchApiLog> findByStepExecutionIdOrderByCreatedAtAsc(Long stepExecutionId);
|
||||||
}
|
}
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
package com.snp.batch.global.repository;
|
||||||
|
|
||||||
|
import com.snp.batch.global.model.BatchCollectionPeriod;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface BatchCollectionPeriodRepository extends JpaRepository<BatchCollectionPeriod, String> {
|
||||||
|
|
||||||
|
List<BatchCollectionPeriod> findAllByOrderByOrderSeqAsc();
|
||||||
|
}
|
||||||
@ -0,0 +1,40 @@
|
|||||||
|
package com.snp.batch.global.repository;
|
||||||
|
|
||||||
|
import com.snp.batch.global.model.BatchRecollectionHistory;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface BatchRecollectionHistoryRepository
|
||||||
|
extends JpaRepository<BatchRecollectionHistory, Long>,
|
||||||
|
JpaSpecificationExecutor<BatchRecollectionHistory> {
|
||||||
|
|
||||||
|
Optional<BatchRecollectionHistory> findByJobExecutionId(Long jobExecutionId);
|
||||||
|
|
||||||
|
List<BatchRecollectionHistory> findTop10ByOrderByCreatedAtDesc();
|
||||||
|
|
||||||
|
@Query("""
|
||||||
|
SELECT h FROM BatchRecollectionHistory h
|
||||||
|
WHERE h.apiKey = :apiKey
|
||||||
|
AND h.historyId != :excludeId
|
||||||
|
AND h.rangeFromDate < :toDate
|
||||||
|
AND h.rangeToDate > :fromDate
|
||||||
|
ORDER BY h.createdAt DESC
|
||||||
|
""")
|
||||||
|
List<BatchRecollectionHistory> findOverlappingHistories(
|
||||||
|
@Param("apiKey") String apiKey,
|
||||||
|
@Param("fromDate") LocalDateTime fromDate,
|
||||||
|
@Param("toDate") LocalDateTime toDate,
|
||||||
|
@Param("excludeId") Long excludeId);
|
||||||
|
|
||||||
|
long countByExecutionStatus(String executionStatus);
|
||||||
|
|
||||||
|
long countByHasOverlapTrue();
|
||||||
|
}
|
||||||
@ -3,10 +3,13 @@ package com.snp.batch.global.repository;
|
|||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 타임라인 조회를 위한 경량 Repository
|
* 타임라인 조회를 위한 경량 Repository
|
||||||
@ -33,6 +36,10 @@ public class TimelineRepository {
|
|||||||
return tablePrefix + "JOB_INSTANCE";
|
return tablePrefix + "JOB_INSTANCE";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String getStepExecutionTable() {
|
||||||
|
return tablePrefix + "STEP_EXECUTION";
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 특정 Job의 특정 범위 내 실행 이력 조회 (경량)
|
* 특정 Job의 특정 범위 내 실행 이력 조회 (경량)
|
||||||
* Step Context를 조회하지 않아 성능이 매우 빠름
|
* Step Context를 조회하지 않아 성능이 매우 빠름
|
||||||
@ -112,7 +119,9 @@ public class TimelineRepository {
|
|||||||
je.JOB_EXECUTION_ID as executionId,
|
je.JOB_EXECUTION_ID as executionId,
|
||||||
je.STATUS as status,
|
je.STATUS as status,
|
||||||
je.START_TIME as startTime,
|
je.START_TIME as startTime,
|
||||||
je.END_TIME as endTime
|
je.END_TIME as endTime,
|
||||||
|
je.EXIT_CODE as exitCode,
|
||||||
|
je.EXIT_MESSAGE as exitMessage
|
||||||
FROM %s je
|
FROM %s je
|
||||||
INNER JOIN %s ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID
|
INNER JOIN %s ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID
|
||||||
ORDER BY je.START_TIME DESC
|
ORDER BY je.START_TIME DESC
|
||||||
@ -121,4 +130,263 @@ public class TimelineRepository {
|
|||||||
|
|
||||||
return jdbcTemplate.queryForList(sql, limit);
|
return jdbcTemplate.queryForList(sql, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── F1: 강제 종료(Abandon) 관련 ──────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 오래된 실행 중 Job 조회 (threshold 분 이상 STARTED/STARTING)
|
||||||
|
*/
|
||||||
|
public List<Map<String, Object>> findStaleExecutions(int thresholdMinutes) {
|
||||||
|
String sql = String.format("""
|
||||||
|
SELECT
|
||||||
|
ji.JOB_NAME as jobName,
|
||||||
|
je.JOB_EXECUTION_ID as executionId,
|
||||||
|
je.STATUS as status,
|
||||||
|
je.START_TIME as startTime
|
||||||
|
FROM %s je
|
||||||
|
INNER JOIN %s ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID
|
||||||
|
WHERE je.STATUS IN ('STARTED', 'STARTING')
|
||||||
|
AND je.START_TIME < NOW() - INTERVAL '%d minutes'
|
||||||
|
ORDER BY je.START_TIME ASC
|
||||||
|
""", getJobExecutionTable(), getJobInstanceTable(), thresholdMinutes);
|
||||||
|
|
||||||
|
return jdbcTemplate.queryForList(sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Job Execution 상태를 ABANDONED로 변경
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public int abandonJobExecution(long executionId) {
|
||||||
|
String sql = String.format("""
|
||||||
|
UPDATE %s
|
||||||
|
SET STATUS = 'ABANDONED',
|
||||||
|
EXIT_CODE = 'ABANDONED',
|
||||||
|
END_TIME = NOW(),
|
||||||
|
EXIT_MESSAGE = 'Force abandoned by admin'
|
||||||
|
WHERE JOB_EXECUTION_ID = ?
|
||||||
|
AND STATUS IN ('STARTED', 'STARTING')
|
||||||
|
""", getJobExecutionTable());
|
||||||
|
|
||||||
|
return jdbcTemplate.update(sql, executionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 해당 Job Execution의 Step Execution들도 ABANDONED로 변경
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public int abandonStepExecutions(long jobExecutionId) {
|
||||||
|
String sql = String.format("""
|
||||||
|
UPDATE %s
|
||||||
|
SET STATUS = 'ABANDONED',
|
||||||
|
EXIT_CODE = 'ABANDONED',
|
||||||
|
END_TIME = NOW(),
|
||||||
|
EXIT_MESSAGE = 'Force abandoned by admin'
|
||||||
|
WHERE JOB_EXECUTION_ID = ?
|
||||||
|
AND STATUS IN ('STARTED', 'STARTING')
|
||||||
|
""", getStepExecutionTable());
|
||||||
|
|
||||||
|
return jdbcTemplate.update(sql, jobExecutionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 오래된 실행 중 건수 조회
|
||||||
|
*/
|
||||||
|
public int countStaleExecutions(int thresholdMinutes) {
|
||||||
|
String sql = String.format("""
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM %s je
|
||||||
|
WHERE je.STATUS IN ('STARTED', 'STARTING')
|
||||||
|
AND je.START_TIME < NOW() - INTERVAL '%d minutes'
|
||||||
|
""", getJobExecutionTable(), thresholdMinutes);
|
||||||
|
|
||||||
|
Integer count = jdbcTemplate.queryForObject(sql, Integer.class);
|
||||||
|
return count != null ? count : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── F4: 실행 이력 검색 (페이지네이션) ─────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 실행 이력 검색 (동적 조건 + 페이지네이션)
|
||||||
|
*/
|
||||||
|
public List<Map<String, Object>> searchExecutions(
|
||||||
|
List<String> jobNames, String status,
|
||||||
|
LocalDateTime startDate, LocalDateTime endDate,
|
||||||
|
int offset, int limit) {
|
||||||
|
|
||||||
|
StringBuilder sql = new StringBuilder(String.format("""
|
||||||
|
SELECT
|
||||||
|
ji.JOB_NAME as jobName,
|
||||||
|
je.JOB_EXECUTION_ID as executionId,
|
||||||
|
je.STATUS as status,
|
||||||
|
je.START_TIME as startTime,
|
||||||
|
je.END_TIME as endTime,
|
||||||
|
je.EXIT_CODE as exitCode,
|
||||||
|
je.EXIT_MESSAGE as exitMessage
|
||||||
|
FROM %s je
|
||||||
|
INNER JOIN %s ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID
|
||||||
|
WHERE 1=1
|
||||||
|
""", getJobExecutionTable(), getJobInstanceTable()));
|
||||||
|
|
||||||
|
List<Object> params = new ArrayList<>();
|
||||||
|
appendSearchConditions(sql, params, jobNames, status, startDate, endDate);
|
||||||
|
|
||||||
|
sql.append(" ORDER BY je.START_TIME DESC LIMIT ? OFFSET ?");
|
||||||
|
params.add(limit);
|
||||||
|
params.add(offset);
|
||||||
|
|
||||||
|
return jdbcTemplate.queryForList(sql.toString(), params.toArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 실행 이력 검색 건수
|
||||||
|
*/
|
||||||
|
public int countExecutions(
|
||||||
|
List<String> jobNames, String status,
|
||||||
|
LocalDateTime startDate, LocalDateTime endDate) {
|
||||||
|
|
||||||
|
StringBuilder sql = new StringBuilder(String.format("""
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM %s je
|
||||||
|
INNER JOIN %s ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID
|
||||||
|
WHERE 1=1
|
||||||
|
""", getJobExecutionTable(), getJobInstanceTable()));
|
||||||
|
|
||||||
|
List<Object> params = new ArrayList<>();
|
||||||
|
appendSearchConditions(sql, params, jobNames, status, startDate, endDate);
|
||||||
|
|
||||||
|
Integer count = jdbcTemplate.queryForObject(sql.toString(), Integer.class, params.toArray());
|
||||||
|
return count != null ? count : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void appendSearchConditions(
|
||||||
|
StringBuilder sql, List<Object> params,
|
||||||
|
List<String> jobNames, String status,
|
||||||
|
LocalDateTime startDate, LocalDateTime endDate) {
|
||||||
|
|
||||||
|
if (jobNames != null && !jobNames.isEmpty()) {
|
||||||
|
String placeholders = jobNames.stream().map(n -> "?").collect(Collectors.joining(", "));
|
||||||
|
sql.append(" AND ji.JOB_NAME IN (").append(placeholders).append(")");
|
||||||
|
params.addAll(jobNames);
|
||||||
|
}
|
||||||
|
if (status != null && !status.isBlank()) {
|
||||||
|
sql.append(" AND je.STATUS = ?");
|
||||||
|
params.add(status);
|
||||||
|
}
|
||||||
|
if (startDate != null) {
|
||||||
|
sql.append(" AND je.START_TIME >= ?");
|
||||||
|
params.add(startDate);
|
||||||
|
}
|
||||||
|
if (endDate != null) {
|
||||||
|
sql.append(" AND je.START_TIME < ?");
|
||||||
|
params.add(endDate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── F6: 대시보드 실패 통계 ──────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 최근 실패 이력 조회
|
||||||
|
*/
|
||||||
|
public List<Map<String, Object>> findRecentFailures(int hours) {
|
||||||
|
String sql = String.format("""
|
||||||
|
SELECT
|
||||||
|
ji.JOB_NAME as jobName,
|
||||||
|
je.JOB_EXECUTION_ID as executionId,
|
||||||
|
je.STATUS as status,
|
||||||
|
je.START_TIME as startTime,
|
||||||
|
je.END_TIME as endTime,
|
||||||
|
je.EXIT_MESSAGE as exitMessage
|
||||||
|
FROM %s je
|
||||||
|
INNER JOIN %s ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID
|
||||||
|
WHERE je.STATUS = 'FAILED'
|
||||||
|
AND je.START_TIME >= NOW() - INTERVAL '%d hours'
|
||||||
|
ORDER BY je.START_TIME DESC
|
||||||
|
LIMIT 10
|
||||||
|
""", getJobExecutionTable(), getJobInstanceTable(), hours);
|
||||||
|
|
||||||
|
return jdbcTemplate.queryForList(sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 시점 이후 실패 건수
|
||||||
|
*/
|
||||||
|
public int countFailuresSince(LocalDateTime since) {
|
||||||
|
String sql = String.format("""
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM %s je
|
||||||
|
WHERE je.STATUS = 'FAILED'
|
||||||
|
AND je.START_TIME >= ?
|
||||||
|
""", getJobExecutionTable());
|
||||||
|
|
||||||
|
Integer count = jdbcTemplate.queryForObject(sql, Integer.class, since);
|
||||||
|
return count != null ? count : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── F7: Job별 최근 실행 정보 ────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Job별 가장 최근 실행 정보 조회 (DISTINCT ON 활용)
|
||||||
|
*/
|
||||||
|
public List<Map<String, Object>> findLastExecutionPerJob() {
|
||||||
|
String sql = String.format("""
|
||||||
|
SELECT DISTINCT ON (ji.JOB_NAME)
|
||||||
|
ji.JOB_NAME as jobName,
|
||||||
|
je.JOB_EXECUTION_ID as executionId,
|
||||||
|
je.STATUS as status,
|
||||||
|
je.START_TIME as startTime,
|
||||||
|
je.END_TIME as endTime
|
||||||
|
FROM %s je
|
||||||
|
INNER JOIN %s ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID
|
||||||
|
ORDER BY ji.JOB_NAME, je.START_TIME DESC
|
||||||
|
""", getJobExecutionTable(), getJobInstanceTable());
|
||||||
|
|
||||||
|
return jdbcTemplate.queryForList(sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── F8: 실행 통계 ──────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 일별 실행 통계 (전체)
|
||||||
|
*/
|
||||||
|
public List<Map<String, Object>> findDailyStatistics(int days) {
|
||||||
|
String sql = String.format("""
|
||||||
|
SELECT
|
||||||
|
CAST(je.START_TIME AS DATE) as execDate,
|
||||||
|
SUM(CASE WHEN je.STATUS = 'COMPLETED' THEN 1 ELSE 0 END) as successCount,
|
||||||
|
SUM(CASE WHEN je.STATUS = 'FAILED' THEN 1 ELSE 0 END) as failedCount,
|
||||||
|
SUM(CASE WHEN je.STATUS NOT IN ('COMPLETED', 'FAILED') THEN 1 ELSE 0 END) as otherCount,
|
||||||
|
AVG(EXTRACT(EPOCH FROM (je.END_TIME - je.START_TIME)) * 1000) as avgDurationMs
|
||||||
|
FROM %s je
|
||||||
|
WHERE je.START_TIME >= NOW() - INTERVAL '%d days'
|
||||||
|
AND je.START_TIME IS NOT NULL
|
||||||
|
GROUP BY CAST(je.START_TIME AS DATE)
|
||||||
|
ORDER BY execDate
|
||||||
|
""", getJobExecutionTable(), days);
|
||||||
|
|
||||||
|
return jdbcTemplate.queryForList(sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 일별 실행 통계 (특정 Job)
|
||||||
|
*/
|
||||||
|
public List<Map<String, Object>> findDailyStatisticsForJob(String jobName, int days) {
|
||||||
|
String sql = String.format("""
|
||||||
|
SELECT
|
||||||
|
CAST(je.START_TIME AS DATE) as execDate,
|
||||||
|
SUM(CASE WHEN je.STATUS = 'COMPLETED' THEN 1 ELSE 0 END) as successCount,
|
||||||
|
SUM(CASE WHEN je.STATUS = 'FAILED' THEN 1 ELSE 0 END) as failedCount,
|
||||||
|
SUM(CASE WHEN je.STATUS NOT IN ('COMPLETED', 'FAILED') THEN 1 ELSE 0 END) as otherCount,
|
||||||
|
AVG(EXTRACT(EPOCH FROM (je.END_TIME - je.START_TIME)) * 1000) as avgDurationMs
|
||||||
|
FROM %s je
|
||||||
|
INNER JOIN %s ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID
|
||||||
|
WHERE ji.JOB_NAME = ?
|
||||||
|
AND je.START_TIME >= NOW() - INTERVAL '%d days'
|
||||||
|
AND je.START_TIME IS NOT NULL
|
||||||
|
GROUP BY CAST(je.START_TIME AS DATE)
|
||||||
|
ORDER BY execDate
|
||||||
|
""", getJobExecutionTable(), getJobInstanceTable(), days);
|
||||||
|
|
||||||
|
return jdbcTemplate.queryForList(sql, jobName);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -84,6 +84,14 @@ public class AisTargetEntity extends BaseEntity {
|
|||||||
private OffsetDateTime receivedDate;
|
private OffsetDateTime receivedDate;
|
||||||
private OffsetDateTime collectedAt; // 배치 수집 시점
|
private OffsetDateTime collectedAt; // 배치 수집 시점
|
||||||
|
|
||||||
|
// ========== 선종 분류 정보 ==========
|
||||||
|
/**
|
||||||
|
* MDA 범례코드 (signalKindCode)
|
||||||
|
* - vesselType + extraInfo 기반으로 치환
|
||||||
|
* - 예: "000020"(어선), "000023"(카고), "000027"(일반/기타)
|
||||||
|
*/
|
||||||
|
private String signalKindCode;
|
||||||
|
|
||||||
// ========== ClassType 분류 정보 ==========
|
// ========== ClassType 분류 정보 ==========
|
||||||
/**
|
/**
|
||||||
* 선박 클래스 타입
|
* 선박 클래스 타입
|
||||||
|
|||||||
@ -26,6 +26,14 @@ public interface AisTargetRepository {
|
|||||||
*/
|
*/
|
||||||
List<AisTargetEntity> findLatestByMmsiIn(List<Long> mmsiList);
|
List<AisTargetEntity> findLatestByMmsiIn(List<Long> mmsiList);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 여러 MMSI의 최신 위치 조회 (시간 범위 필터)
|
||||||
|
*
|
||||||
|
* @param mmsiList 대상 MMSI 목록
|
||||||
|
* @param since 이 시점 이후 데이터만 조회
|
||||||
|
*/
|
||||||
|
List<AisTargetEntity> findLatestByMmsiInSince(List<Long> mmsiList, OffsetDateTime since);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 시간 범위 내 특정 MMSI의 항적 조회
|
* 시간 범위 내 특정 MMSI의 항적 조회
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
package com.snp.batch.jobs.aistarget.batch.repository;
|
package com.snp.batch.jobs.aistarget.batch.repository;
|
||||||
|
|
||||||
import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
|
import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.jdbc.core.RowMapper;
|
import org.springframework.jdbc.core.RowMapper;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
@ -19,111 +19,118 @@ import java.util.Optional;
|
|||||||
/**
|
/**
|
||||||
* AIS Target Repository 구현체
|
* AIS Target Repository 구현체
|
||||||
*
|
*
|
||||||
* 테이블: snp_data.ais_target
|
* 테이블: {targetSchema}.ais_target
|
||||||
* PK: mmsi + message_timestamp (복합키)
|
* PK: mmsi + message_timestamp (복합키)
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Repository
|
@Repository
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class AisTargetRepositoryImpl implements AisTargetRepository {
|
public class AisTargetRepositoryImpl implements AisTargetRepository {
|
||||||
|
|
||||||
private final JdbcTemplate jdbcTemplate;
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
private final String tableName;
|
||||||
|
private final String upsertSql;
|
||||||
|
|
||||||
private static final String TABLE_NAME = "snp_data.ais_target";
|
public AisTargetRepositoryImpl(JdbcTemplate jdbcTemplate,
|
||||||
|
@Value("${app.batch.target-schema.name}") String targetSchema) {
|
||||||
|
this.jdbcTemplate = jdbcTemplate;
|
||||||
|
this.tableName = targetSchema + ".ais_target";
|
||||||
|
this.upsertSql = buildUpsertSql(targetSchema);
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== UPSERT SQL ====================
|
private String buildUpsertSql(String schema) {
|
||||||
|
return """
|
||||||
|
INSERT INTO %s.ais_target (
|
||||||
|
mmsi, message_timestamp, imo, name, callsign, vessel_type, extra_info,
|
||||||
|
lat, lon, geom,
|
||||||
|
heading, sog, cog, rot,
|
||||||
|
length, width, draught, length_bow, length_stern, width_port, width_starboard,
|
||||||
|
destination, eta, status,
|
||||||
|
age_minutes, position_accuracy, timestamp_utc, repeat_indicator, raim_flag,
|
||||||
|
radio_status, regional, regional2, spare, spare2,
|
||||||
|
ais_version, position_fix_type, dte, band_flag,
|
||||||
|
received_date, collected_at, created_at, updated_at,
|
||||||
|
tonnes_cargo, in_sts, on_berth, dwt, anomalous,
|
||||||
|
destination_port_id, destination_tidied, destination_unlocode, imo_verified, last_static_update_received,
|
||||||
|
lpc_code, message_type, "source", station_id, zone_id
|
||||||
|
) VALUES (
|
||||||
|
?, ?, ?, ?, ?, ?, ?,
|
||||||
|
?, ?, ST_SetSRID(ST_MakePoint(?, ?), 4326),
|
||||||
|
?, ?, ?, ?,
|
||||||
|
?, ?, ?, ?, ?, ?, ?,
|
||||||
|
?, ?, ?,
|
||||||
|
?, ?, ?, ?, ?,
|
||||||
|
?, ?, ?, ?, ?,
|
||||||
|
?, ?, ?, ?,
|
||||||
|
?, ?, NOW(), NOW(),
|
||||||
|
?, ?, ?, ?, ?,
|
||||||
|
?, ?, ?, ?, ?,
|
||||||
|
?, ?, ?, ?, ?
|
||||||
|
)
|
||||||
|
ON CONFLICT (mmsi, message_timestamp) DO UPDATE SET
|
||||||
|
imo = EXCLUDED.imo,
|
||||||
|
name = EXCLUDED.name,
|
||||||
|
callsign = EXCLUDED.callsign,
|
||||||
|
vessel_type = EXCLUDED.vessel_type,
|
||||||
|
extra_info = EXCLUDED.extra_info,
|
||||||
|
lat = EXCLUDED.lat,
|
||||||
|
lon = EXCLUDED.lon,
|
||||||
|
geom = EXCLUDED.geom,
|
||||||
|
heading = EXCLUDED.heading,
|
||||||
|
sog = EXCLUDED.sog,
|
||||||
|
cog = EXCLUDED.cog,
|
||||||
|
rot = EXCLUDED.rot,
|
||||||
|
length = EXCLUDED.length,
|
||||||
|
width = EXCLUDED.width,
|
||||||
|
draught = EXCLUDED.draught,
|
||||||
|
length_bow = EXCLUDED.length_bow,
|
||||||
|
length_stern = EXCLUDED.length_stern,
|
||||||
|
width_port = EXCLUDED.width_port,
|
||||||
|
width_starboard = EXCLUDED.width_starboard,
|
||||||
|
destination = EXCLUDED.destination,
|
||||||
|
eta = EXCLUDED.eta,
|
||||||
|
status = EXCLUDED.status,
|
||||||
|
age_minutes = EXCLUDED.age_minutes,
|
||||||
|
position_accuracy = EXCLUDED.position_accuracy,
|
||||||
|
timestamp_utc = EXCLUDED.timestamp_utc,
|
||||||
|
repeat_indicator = EXCLUDED.repeat_indicator,
|
||||||
|
raim_flag = EXCLUDED.raim_flag,
|
||||||
|
radio_status = EXCLUDED.radio_status,
|
||||||
|
regional = EXCLUDED.regional,
|
||||||
|
regional2 = EXCLUDED.regional2,
|
||||||
|
spare = EXCLUDED.spare,
|
||||||
|
spare2 = EXCLUDED.spare2,
|
||||||
|
ais_version = EXCLUDED.ais_version,
|
||||||
|
position_fix_type = EXCLUDED.position_fix_type,
|
||||||
|
dte = EXCLUDED.dte,
|
||||||
|
band_flag = EXCLUDED.band_flag,
|
||||||
|
received_date = EXCLUDED.received_date,
|
||||||
|
collected_at = EXCLUDED.collected_at,
|
||||||
|
updated_at = NOW(),
|
||||||
|
tonnes_cargo = EXCLUDED.tonnes_cargo,
|
||||||
|
in_sts = EXCLUDED.in_sts,
|
||||||
|
on_berth = EXCLUDED.on_berth,
|
||||||
|
dwt = EXCLUDED.dwt,
|
||||||
|
anomalous = EXCLUDED.anomalous,
|
||||||
|
destination_port_id = EXCLUDED.destination_port_id,
|
||||||
|
destination_tidied = EXCLUDED.destination_tidied,
|
||||||
|
destination_unlocode = EXCLUDED.destination_unlocode,
|
||||||
|
imo_verified = EXCLUDED.imo_verified,
|
||||||
|
last_static_update_received = EXCLUDED.last_static_update_received,
|
||||||
|
lpc_code = EXCLUDED.lpc_code,
|
||||||
|
message_type = EXCLUDED.message_type,
|
||||||
|
"source" = EXCLUDED."source",
|
||||||
|
station_id = EXCLUDED.station_id,
|
||||||
|
zone_id = EXCLUDED.zone_id
|
||||||
|
""".formatted(schema);
|
||||||
|
}
|
||||||
|
|
||||||
private static final String UPSERT_SQL = """
|
|
||||||
INSERT INTO snp_data.ais_target (
|
|
||||||
mmsi, message_timestamp, imo, name, callsign, vessel_type, extra_info,
|
|
||||||
lat, lon, geom,
|
|
||||||
heading, sog, cog, rot,
|
|
||||||
length, width, draught, length_bow, length_stern, width_port, width_starboard,
|
|
||||||
destination, eta, status,
|
|
||||||
age_minutes, position_accuracy, timestamp_utc, repeat_indicator, raim_flag,
|
|
||||||
radio_status, regional, regional2, spare, spare2,
|
|
||||||
ais_version, position_fix_type, dte, band_flag,
|
|
||||||
received_date, collected_at, created_at, updated_at,
|
|
||||||
tonnes_cargo, in_sts, on_berth, dwt, anomalous,
|
|
||||||
destination_port_id, destination_tidied, destination_unlocode, imo_verified, last_static_update_received,
|
|
||||||
lpc_code, message_type, "source", station_id, zone_id
|
|
||||||
) VALUES (
|
|
||||||
?, ?, ?, ?, ?, ?, ?,
|
|
||||||
?, ?, ST_SetSRID(ST_MakePoint(?, ?), 4326),
|
|
||||||
?, ?, ?, ?,
|
|
||||||
?, ?, ?, ?, ?, ?, ?,
|
|
||||||
?, ?, ?,
|
|
||||||
?, ?, ?, ?, ?,
|
|
||||||
?, ?, ?, ?, ?,
|
|
||||||
?, ?, ?, ?,
|
|
||||||
?, ?, NOW(), NOW(),
|
|
||||||
?, ?, ?, ?, ?,
|
|
||||||
?, ?, ?, ?, ?,
|
|
||||||
?, ?, ?, ?, ?
|
|
||||||
)
|
|
||||||
ON CONFLICT (mmsi, message_timestamp) DO UPDATE SET
|
|
||||||
imo = EXCLUDED.imo,
|
|
||||||
name = EXCLUDED.name,
|
|
||||||
callsign = EXCLUDED.callsign,
|
|
||||||
vessel_type = EXCLUDED.vessel_type,
|
|
||||||
extra_info = EXCLUDED.extra_info,
|
|
||||||
lat = EXCLUDED.lat,
|
|
||||||
lon = EXCLUDED.lon,
|
|
||||||
geom = EXCLUDED.geom,
|
|
||||||
heading = EXCLUDED.heading,
|
|
||||||
sog = EXCLUDED.sog,
|
|
||||||
cog = EXCLUDED.cog,
|
|
||||||
rot = EXCLUDED.rot,
|
|
||||||
length = EXCLUDED.length,
|
|
||||||
width = EXCLUDED.width,
|
|
||||||
draught = EXCLUDED.draught,
|
|
||||||
length_bow = EXCLUDED.length_bow,
|
|
||||||
length_stern = EXCLUDED.length_stern,
|
|
||||||
width_port = EXCLUDED.width_port,
|
|
||||||
width_starboard = EXCLUDED.width_starboard,
|
|
||||||
destination = EXCLUDED.destination,
|
|
||||||
eta = EXCLUDED.eta,
|
|
||||||
status = EXCLUDED.status,
|
|
||||||
age_minutes = EXCLUDED.age_minutes,
|
|
||||||
position_accuracy = EXCLUDED.position_accuracy,
|
|
||||||
timestamp_utc = EXCLUDED.timestamp_utc,
|
|
||||||
repeat_indicator = EXCLUDED.repeat_indicator,
|
|
||||||
raim_flag = EXCLUDED.raim_flag,
|
|
||||||
radio_status = EXCLUDED.radio_status,
|
|
||||||
regional = EXCLUDED.regional,
|
|
||||||
regional2 = EXCLUDED.regional2,
|
|
||||||
spare = EXCLUDED.spare,
|
|
||||||
spare2 = EXCLUDED.spare2,
|
|
||||||
ais_version = EXCLUDED.ais_version,
|
|
||||||
position_fix_type = EXCLUDED.position_fix_type,
|
|
||||||
dte = EXCLUDED.dte,
|
|
||||||
band_flag = EXCLUDED.band_flag,
|
|
||||||
received_date = EXCLUDED.received_date,
|
|
||||||
collected_at = EXCLUDED.collected_at,
|
|
||||||
updated_at = NOW(),
|
|
||||||
tonnes_cargo = EXCLUDED.tonnes_cargo,
|
|
||||||
in_sts = EXCLUDED.in_sts,
|
|
||||||
on_berth = EXCLUDED.on_berth,
|
|
||||||
dwt = EXCLUDED.dwt,
|
|
||||||
anomalous = EXCLUDED.anomalous,
|
|
||||||
destination_port_id = EXCLUDED.destination_port_id,
|
|
||||||
destination_tidied = EXCLUDED.destination_tidied,
|
|
||||||
destination_unlocode = EXCLUDED.destination_unlocode,
|
|
||||||
imo_verified = EXCLUDED.imo_verified,
|
|
||||||
last_static_update_received = EXCLUDED.last_static_update_received,
|
|
||||||
lpc_code = EXCLUDED.lpc_code,
|
|
||||||
message_type = EXCLUDED.message_type,
|
|
||||||
"source" = EXCLUDED."source",
|
|
||||||
station_id = EXCLUDED.station_id,
|
|
||||||
zone_id = EXCLUDED.zone_id
|
|
||||||
""";
|
|
||||||
|
|
||||||
// ==================== RowMapper ====================
|
// ==================== RowMapper ====================
|
||||||
|
|
||||||
private final RowMapper<AisTargetEntity> rowMapper = (rs, rowNum) -> AisTargetEntity.builder()
|
private final RowMapper<AisTargetEntity> rowMapper = (rs, rowNum) -> AisTargetEntity.builder()
|
||||||
.mmsi(rs.getLong("mmsi"))
|
.mmsi(rs.getLong("mmsi"))
|
||||||
.messageTimestamp(toOffsetDateTime(rs.getTimestamp("message_timestamp")))
|
.messageTimestamp(toOffsetDateTime(rs.getTimestamp("message_timestamp")))
|
||||||
.imo(rs.getObject("imo", Long.class))
|
.imo(toLong(rs, "imo"))
|
||||||
.name(rs.getString("name"))
|
.name(rs.getString("name"))
|
||||||
.callsign(rs.getString("callsign"))
|
.callsign(rs.getString("callsign"))
|
||||||
.vesselType(rs.getString("vessel_type"))
|
.vesselType(rs.getString("vessel_type"))
|
||||||
@ -133,45 +140,45 @@ public class AisTargetRepositoryImpl implements AisTargetRepository {
|
|||||||
.heading(rs.getObject("heading", Double.class))
|
.heading(rs.getObject("heading", Double.class))
|
||||||
.sog(rs.getObject("sog", Double.class))
|
.sog(rs.getObject("sog", Double.class))
|
||||||
.cog(rs.getObject("cog", Double.class))
|
.cog(rs.getObject("cog", Double.class))
|
||||||
.rot(rs.getObject("rot", Integer.class))
|
.rot(toInt(rs, "rot"))
|
||||||
.length(rs.getObject("length", Integer.class))
|
.length(toInt(rs, "length"))
|
||||||
.width(rs.getObject("width", Integer.class))
|
.width(toInt(rs, "width"))
|
||||||
.draught(rs.getObject("draught", Double.class))
|
.draught(rs.getObject("draught", Double.class))
|
||||||
.lengthBow(rs.getObject("length_bow", Integer.class))
|
.lengthBow(toInt(rs, "length_bow"))
|
||||||
.lengthStern(rs.getObject("length_stern", Integer.class))
|
.lengthStern(toInt(rs, "length_stern"))
|
||||||
.widthPort(rs.getObject("width_port", Integer.class))
|
.widthPort(toInt(rs, "width_port"))
|
||||||
.widthStarboard(rs.getObject("width_starboard", Integer.class))
|
.widthStarboard(toInt(rs, "width_starboard"))
|
||||||
.destination(rs.getString("destination"))
|
.destination(rs.getString("destination"))
|
||||||
.eta(toOffsetDateTime(rs.getTimestamp("eta")))
|
.eta(toOffsetDateTime(rs.getTimestamp("eta")))
|
||||||
.status(rs.getString("status"))
|
.status(rs.getString("status"))
|
||||||
.ageMinutes(rs.getObject("age_minutes", Double.class))
|
.ageMinutes(rs.getObject("age_minutes", Double.class))
|
||||||
.positionAccuracy(rs.getObject("position_accuracy", Integer.class))
|
.positionAccuracy(toInt(rs, "position_accuracy"))
|
||||||
.timestampUtc(rs.getObject("timestamp_utc", Integer.class))
|
.timestampUtc(toInt(rs, "timestamp_utc"))
|
||||||
.repeatIndicator(rs.getObject("repeat_indicator", Integer.class))
|
.repeatIndicator(toInt(rs, "repeat_indicator"))
|
||||||
.raimFlag(rs.getObject("raim_flag", Integer.class))
|
.raimFlag(toInt(rs, "raim_flag"))
|
||||||
.radioStatus(rs.getObject("radio_status", Integer.class))
|
.radioStatus(toInt(rs, "radio_status"))
|
||||||
.regional(rs.getObject("regional", Integer.class))
|
.regional(toInt(rs, "regional"))
|
||||||
.regional2(rs.getObject("regional2", Integer.class))
|
.regional2(toInt(rs, "regional2"))
|
||||||
.spare(rs.getObject("spare", Integer.class))
|
.spare(toInt(rs, "spare"))
|
||||||
.spare2(rs.getObject("spare2", Integer.class))
|
.spare2(toInt(rs, "spare2"))
|
||||||
.aisVersion(rs.getObject("ais_version", Integer.class))
|
.aisVersion(toInt(rs, "ais_version"))
|
||||||
.positionFixType(rs.getObject("position_fix_type", Integer.class))
|
.positionFixType(toInt(rs, "position_fix_type"))
|
||||||
.dte(rs.getObject("dte", Integer.class))
|
.dte(toInt(rs, "dte"))
|
||||||
.bandFlag(rs.getObject("band_flag", Integer.class))
|
.bandFlag(toInt(rs, "band_flag"))
|
||||||
.receivedDate(toOffsetDateTime(rs.getTimestamp("received_date")))
|
.receivedDate(toOffsetDateTime(rs.getTimestamp("received_date")))
|
||||||
.collectedAt(toOffsetDateTime(rs.getTimestamp("collected_at")))
|
.collectedAt(toOffsetDateTime(rs.getTimestamp("collected_at")))
|
||||||
.tonnesCargo(rs.getObject("tonnes_cargo", Integer.class))
|
.tonnesCargo(toInt(rs, "tonnes_cargo"))
|
||||||
.inSTS(rs.getObject("in_sts", Integer.class))
|
.inSTS(toInt(rs, "in_sts"))
|
||||||
.onBerth(rs.getObject("on_berth", Boolean.class))
|
.onBerth(rs.getObject("on_berth", Boolean.class))
|
||||||
.dwt(rs.getObject("dwt", Integer.class))
|
.dwt(toInt(rs, "dwt"))
|
||||||
.anomalous(rs.getString("anomalous"))
|
.anomalous(rs.getString("anomalous"))
|
||||||
.destinationPortID(rs.getObject("destination_port_id", Integer.class))
|
.destinationPortID(toInt(rs, "destination_port_id"))
|
||||||
.destinationTidied(rs.getString("destination_tidied"))
|
.destinationTidied(rs.getString("destination_tidied"))
|
||||||
.destinationUNLOCODE(rs.getString("destination_unlocode"))
|
.destinationUNLOCODE(rs.getString("destination_unlocode"))
|
||||||
.imoVerified(rs.getString("imo_verified"))
|
.imoVerified(rs.getString("imo_verified"))
|
||||||
.lastStaticUpdateReceived(toOffsetDateTime(rs.getTimestamp("last_static_update_received")))
|
.lastStaticUpdateReceived(toOffsetDateTime(rs.getTimestamp("last_static_update_received")))
|
||||||
.lpcCode(rs.getObject("lpc_code", Integer.class))
|
.lpcCode(toInt(rs, "lpc_code"))
|
||||||
.messageType(rs.getObject("message_type", Integer.class))
|
.messageType(toInt(rs, "message_type"))
|
||||||
.source(rs.getString("source"))
|
.source(rs.getString("source"))
|
||||||
.stationId(rs.getString("station_id"))
|
.stationId(rs.getString("station_id"))
|
||||||
.zoneId(rs.getObject("zone_id", Double.class))
|
.zoneId(rs.getObject("zone_id", Double.class))
|
||||||
@ -181,7 +188,7 @@ public class AisTargetRepositoryImpl implements AisTargetRepository {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Optional<AisTargetEntity> findByMmsiAndMessageTimestamp(Long mmsi, OffsetDateTime messageTimestamp) {
|
public Optional<AisTargetEntity> findByMmsiAndMessageTimestamp(Long mmsi, OffsetDateTime messageTimestamp) {
|
||||||
String sql = "SELECT * FROM " + TABLE_NAME + " WHERE mmsi = ? AND message_timestamp = ?";
|
String sql = "SELECT * FROM " + tableName + " WHERE mmsi = ? AND message_timestamp = ?";
|
||||||
List<AisTargetEntity> results = jdbcTemplate.query(sql, rowMapper, mmsi, toTimestamp(messageTimestamp));
|
List<AisTargetEntity> results = jdbcTemplate.query(sql, rowMapper, mmsi, toTimestamp(messageTimestamp));
|
||||||
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
|
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
|
||||||
}
|
}
|
||||||
@ -193,7 +200,7 @@ public class AisTargetRepositoryImpl implements AisTargetRepository {
|
|||||||
WHERE mmsi = ?
|
WHERE mmsi = ?
|
||||||
ORDER BY message_timestamp DESC
|
ORDER BY message_timestamp DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
""".formatted(TABLE_NAME);
|
""".formatted(tableName);
|
||||||
List<AisTargetEntity> results = jdbcTemplate.query(sql, rowMapper, mmsi);
|
List<AisTargetEntity> results = jdbcTemplate.query(sql, rowMapper, mmsi);
|
||||||
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
|
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
|
||||||
}
|
}
|
||||||
@ -210,12 +217,30 @@ public class AisTargetRepositoryImpl implements AisTargetRepository {
|
|||||||
FROM %s
|
FROM %s
|
||||||
WHERE mmsi = ANY(?)
|
WHERE mmsi = ANY(?)
|
||||||
ORDER BY mmsi, message_timestamp DESC
|
ORDER BY mmsi, message_timestamp DESC
|
||||||
""".formatted(TABLE_NAME);
|
""".formatted(tableName);
|
||||||
|
|
||||||
Long[] mmsiArray = mmsiList.toArray(new Long[0]);
|
Long[] mmsiArray = mmsiList.toArray(new Long[0]);
|
||||||
return jdbcTemplate.query(sql, rowMapper, (Object) mmsiArray);
|
return jdbcTemplate.query(sql, rowMapper, (Object) mmsiArray);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<AisTargetEntity> findLatestByMmsiInSince(List<Long> mmsiList, OffsetDateTime since) {
|
||||||
|
if (mmsiList == null || mmsiList.isEmpty()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
String sql = """
|
||||||
|
SELECT DISTINCT ON (mmsi) *
|
||||||
|
FROM %s
|
||||||
|
WHERE mmsi = ANY(?)
|
||||||
|
AND message_timestamp >= ?
|
||||||
|
ORDER BY mmsi, message_timestamp DESC
|
||||||
|
""".formatted(tableName);
|
||||||
|
|
||||||
|
Long[] mmsiArray = mmsiList.toArray(new Long[0]);
|
||||||
|
return jdbcTemplate.query(sql, rowMapper, (Object) mmsiArray, toTimestamp(since));
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<AisTargetEntity> findByMmsiAndTimeRange(Long mmsi, OffsetDateTime start, OffsetDateTime end) {
|
public List<AisTargetEntity> findByMmsiAndTimeRange(Long mmsi, OffsetDateTime start, OffsetDateTime end) {
|
||||||
String sql = """
|
String sql = """
|
||||||
@ -223,7 +248,7 @@ public class AisTargetRepositoryImpl implements AisTargetRepository {
|
|||||||
WHERE mmsi = ?
|
WHERE mmsi = ?
|
||||||
AND message_timestamp BETWEEN ? AND ?
|
AND message_timestamp BETWEEN ? AND ?
|
||||||
ORDER BY message_timestamp ASC
|
ORDER BY message_timestamp ASC
|
||||||
""".formatted(TABLE_NAME);
|
""".formatted(tableName);
|
||||||
return jdbcTemplate.query(sql, rowMapper, mmsi, toTimestamp(start), toTimestamp(end));
|
return jdbcTemplate.query(sql, rowMapper, mmsi, toTimestamp(start), toTimestamp(end));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -245,7 +270,7 @@ public class AisTargetRepositoryImpl implements AisTargetRepository {
|
|||||||
?
|
?
|
||||||
)
|
)
|
||||||
ORDER BY mmsi, message_timestamp DESC
|
ORDER BY mmsi, message_timestamp DESC
|
||||||
""".formatted(TABLE_NAME);
|
""".formatted(tableName);
|
||||||
|
|
||||||
return jdbcTemplate.query(sql, rowMapper,
|
return jdbcTemplate.query(sql, rowMapper,
|
||||||
toTimestamp(start), toTimestamp(end),
|
toTimestamp(start), toTimestamp(end),
|
||||||
@ -261,7 +286,7 @@ public class AisTargetRepositoryImpl implements AisTargetRepository {
|
|||||||
|
|
||||||
log.info("AIS Target 배치 UPSERT 시작: {} 건", entities.size());
|
log.info("AIS Target 배치 UPSERT 시작: {} 건", entities.size());
|
||||||
|
|
||||||
jdbcTemplate.batchUpdate(UPSERT_SQL, entities, 1000, (ps, entity) -> {
|
jdbcTemplate.batchUpdate(upsertSql, entities, 1000, (ps, entity) -> {
|
||||||
int idx = 1;
|
int idx = 1;
|
||||||
// PK
|
// PK
|
||||||
ps.setLong(idx++, entity.getMmsi());
|
ps.setLong(idx++, entity.getMmsi());
|
||||||
@ -336,7 +361,7 @@ public class AisTargetRepositoryImpl implements AisTargetRepository {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public long count() {
|
public long count() {
|
||||||
String sql = "SELECT COUNT(*) FROM " + TABLE_NAME;
|
String sql = "SELECT COUNT(*) FROM " + tableName;
|
||||||
Long count = jdbcTemplate.queryForObject(sql, Long.class);
|
Long count = jdbcTemplate.queryForObject(sql, Long.class);
|
||||||
return count != null ? count : 0L;
|
return count != null ? count : 0L;
|
||||||
}
|
}
|
||||||
@ -344,7 +369,7 @@ public class AisTargetRepositoryImpl implements AisTargetRepository {
|
|||||||
@Override
|
@Override
|
||||||
@Transactional
|
@Transactional
|
||||||
public int deleteOlderThan(OffsetDateTime threshold) {
|
public int deleteOlderThan(OffsetDateTime threshold) {
|
||||||
String sql = "DELETE FROM " + TABLE_NAME + " WHERE message_timestamp < ?";
|
String sql = "DELETE FROM " + tableName + " WHERE message_timestamp < ?";
|
||||||
int deleted = jdbcTemplate.update(sql, toTimestamp(threshold));
|
int deleted = jdbcTemplate.update(sql, toTimestamp(threshold));
|
||||||
log.info("AIS Target 오래된 데이터 삭제 완료: {} 건 (기준: {})", deleted, threshold);
|
log.info("AIS Target 오래된 데이터 삭제 완료: {} 건 (기준: {})", deleted, threshold);
|
||||||
return deleted;
|
return deleted;
|
||||||
@ -352,6 +377,23 @@ public class AisTargetRepositoryImpl implements AisTargetRepository {
|
|||||||
|
|
||||||
// ==================== Helper Methods ====================
|
// ==================== Helper Methods ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* int8(bigint) → Integer 안전 변환
|
||||||
|
* PostgreSQL JDBC 드라이버는 int8 → Integer 자동 변환을 지원하지 않아
|
||||||
|
* getObject("col", Integer.class) 사용 시 오류 발생. Number로 읽어서 변환.
|
||||||
|
*/
|
||||||
|
private Integer toInt(ResultSet rs, String column) throws SQLException {
|
||||||
|
Object val = rs.getObject(column);
|
||||||
|
if (val == null) return null;
|
||||||
|
return ((Number) val).intValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long toLong(ResultSet rs, String column) throws SQLException {
|
||||||
|
Object val = rs.getObject(column);
|
||||||
|
if (val == null) return null;
|
||||||
|
return ((Number) val).longValue();
|
||||||
|
}
|
||||||
|
|
||||||
private Timestamp toTimestamp(OffsetDateTime odt) {
|
private Timestamp toTimestamp(OffsetDateTime odt) {
|
||||||
return odt != null ? Timestamp.from(odt.toInstant()) : null;
|
return odt != null ? Timestamp.from(odt.toInstant()) : null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,8 +3,12 @@ package com.snp.batch.jobs.aistarget.batch.writer;
|
|||||||
import com.snp.batch.common.batch.writer.BaseWriter;
|
import com.snp.batch.common.batch.writer.BaseWriter;
|
||||||
import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
|
import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
|
||||||
import com.snp.batch.jobs.aistarget.cache.AisTargetCacheManager;
|
import com.snp.batch.jobs.aistarget.cache.AisTargetCacheManager;
|
||||||
|
import com.snp.batch.jobs.aistarget.chnprmship.ChnPrmShipCacheManager;
|
||||||
import com.snp.batch.jobs.aistarget.classifier.AisClassTypeClassifier;
|
import com.snp.batch.jobs.aistarget.classifier.AisClassTypeClassifier;
|
||||||
|
import com.snp.batch.jobs.aistarget.classifier.SignalKindCode;
|
||||||
|
import com.snp.batch.jobs.aistarget.kafka.AisTargetKafkaProducer;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.lang.Nullable;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@ -14,11 +18,15 @@ import java.util.List;
|
|||||||
*
|
*
|
||||||
* 동작:
|
* 동작:
|
||||||
* 1. ClassType 분류 (Core20 캐시 기반 A/B 분류)
|
* 1. ClassType 분류 (Core20 캐시 기반 A/B 분류)
|
||||||
* 2. 캐시에 최신 위치 정보 업데이트 (classType, core20Mmsi 포함)
|
* 2. SignalKindCode 치환 (vesselType + extraInfo → MDA 범례코드)
|
||||||
|
* 3. 캐시에 최신 위치 정보 업데이트 (classType, core20Mmsi, signalKindCode 포함)
|
||||||
|
* 4. ChnPrmShip 전용 캐시 업데이트 (대상 MMSI만 필터)
|
||||||
|
* 5. Kafka 토픽으로 AIS Target 정보 전송 (활성화된 경우에만)
|
||||||
*
|
*
|
||||||
* 참고:
|
* 참고:
|
||||||
* - DB 저장은 별도 Job(aisTargetDbSyncJob)에서 15분 주기로 수행
|
* - DB 저장은 별도 Job(aisTargetDbSyncJob)에서 15분 주기로 수행
|
||||||
* - 이 Writer는 캐시 업데이트만 담당
|
* - Kafka 전송 실패는 기본적으로 로그만 남기고 다음 처리 계속
|
||||||
|
* - Kafka가 비활성화(enabled=false)이면 kafkaProducer가 null이므로 전송 단계를 스킵
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Component
|
@Component
|
||||||
@ -26,13 +34,20 @@ public class AisTargetDataWriter extends BaseWriter<AisTargetEntity> {
|
|||||||
|
|
||||||
private final AisTargetCacheManager cacheManager;
|
private final AisTargetCacheManager cacheManager;
|
||||||
private final AisClassTypeClassifier classTypeClassifier;
|
private final AisClassTypeClassifier classTypeClassifier;
|
||||||
|
@Nullable
|
||||||
|
private final AisTargetKafkaProducer kafkaProducer;
|
||||||
|
private final ChnPrmShipCacheManager chnPrmShipCacheManager;
|
||||||
|
|
||||||
public AisTargetDataWriter(
|
public AisTargetDataWriter(
|
||||||
AisTargetCacheManager cacheManager,
|
AisTargetCacheManager cacheManager,
|
||||||
AisClassTypeClassifier classTypeClassifier) {
|
AisClassTypeClassifier classTypeClassifier,
|
||||||
|
@Nullable AisTargetKafkaProducer kafkaProducer,
|
||||||
|
ChnPrmShipCacheManager chnPrmShipCacheManager) {
|
||||||
super("AisTarget");
|
super("AisTarget");
|
||||||
this.cacheManager = cacheManager;
|
this.cacheManager = cacheManager;
|
||||||
this.classTypeClassifier = classTypeClassifier;
|
this.classTypeClassifier = classTypeClassifier;
|
||||||
|
this.kafkaProducer = kafkaProducer;
|
||||||
|
this.chnPrmShipCacheManager = chnPrmShipCacheManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@ -43,10 +58,33 @@ public class AisTargetDataWriter extends BaseWriter<AisTargetEntity> {
|
|||||||
// - Core20 캐시의 IMO와 매칭하여 classType(A/B), core20Mmsi 설정
|
// - Core20 캐시의 IMO와 매칭하여 classType(A/B), core20Mmsi 설정
|
||||||
classTypeClassifier.classifyAll(items);
|
classTypeClassifier.classifyAll(items);
|
||||||
|
|
||||||
// 2. 캐시 업데이트 (classType, core20Mmsi 포함)
|
// 2. SignalKindCode 치환 (vesselType + extraInfo → MDA 범례코드)
|
||||||
|
items.forEach(item -> {
|
||||||
|
SignalKindCode kindCode = SignalKindCode.resolve(item.getVesselType(), item.getExtraInfo());
|
||||||
|
item.setSignalKindCode(kindCode.getCode());
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. 캐시 업데이트 (classType, core20Mmsi, signalKindCode 포함)
|
||||||
cacheManager.putAll(items);
|
cacheManager.putAll(items);
|
||||||
|
|
||||||
log.debug("AIS Target 캐시 업데이트 완료: {} 건 (캐시 크기: {})",
|
log.debug("AIS Target 캐시 업데이트 완료: {} 건 (캐시 크기: {})",
|
||||||
items.size(), cacheManager.size());
|
items.size(), cacheManager.size());
|
||||||
|
|
||||||
|
// 4. ChnPrmShip 전용 캐시 업데이트 (대상 MMSI만 필터)
|
||||||
|
chnPrmShipCacheManager.putIfTarget(items);
|
||||||
|
|
||||||
|
// 5. Kafka 전송 (kafkaProducer 빈이 존재하는 경우에만)
|
||||||
|
if (kafkaProducer == null) {
|
||||||
|
log.debug("AIS Kafka Producer 미등록 - topic 전송 스킵");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
AisTargetKafkaProducer.PublishSummary summary = kafkaProducer.publish(items);
|
||||||
|
log.info("AIS Kafka 전송 완료 - topic: {}, 요청: {}, 성공: {}, 실패: {}, 스킵: {}",
|
||||||
|
kafkaProducer.getTopic(),
|
||||||
|
summary.getRequestedCount(),
|
||||||
|
summary.getSuccessCount(),
|
||||||
|
summary.getFailedCount(),
|
||||||
|
summary.getSkippedCount());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,131 @@
|
|||||||
|
package com.snp.batch.jobs.aistarget.chnprmship;
|
||||||
|
|
||||||
|
import com.github.benmanes.caffeine.cache.Cache;
|
||||||
|
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||||
|
import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
|
||||||
|
import jakarta.annotation.PostConstruct;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 중국 허가선박 전용 캐시
|
||||||
|
*
|
||||||
|
* - 대상 MMSI(~1,400척)만 별도 관리
|
||||||
|
* - TTL: expireAfterWrite (마지막 put 이후 N일 경과 시 만료)
|
||||||
|
* - 순수 캐시 조회 전용 (DB fallback 없음)
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ChnPrmShipCacheManager {
|
||||||
|
|
||||||
|
private final ChnPrmShipProperties properties;
|
||||||
|
private Cache<Long, AisTargetEntity> cache;
|
||||||
|
|
||||||
|
@PostConstruct
|
||||||
|
public void init() {
|
||||||
|
this.cache = Caffeine.newBuilder()
|
||||||
|
.maximumSize(properties.getMaxSize())
|
||||||
|
.expireAfterWrite(properties.getTtlDays(), TimeUnit.DAYS)
|
||||||
|
.recordStats()
|
||||||
|
.build();
|
||||||
|
|
||||||
|
log.info("ChnPrmShip 캐시 초기화 - TTL: {}일, 최대 크기: {}",
|
||||||
|
properties.getTtlDays(), properties.getMaxSize());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 대상 MMSI에 해당하는 항목만 필터링하여 캐시에 저장
|
||||||
|
*
|
||||||
|
* @param items 전체 AIS Target 데이터 (배치 수집 결과)
|
||||||
|
* @return 저장된 건수
|
||||||
|
*/
|
||||||
|
public int putIfTarget(List<AisTargetEntity> items) {
|
||||||
|
if (items == null || items.isEmpty()) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int updated = 0;
|
||||||
|
for (AisTargetEntity item : items) {
|
||||||
|
if (!properties.isTarget(item.getMmsi())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
AisTargetEntity existing = cache.getIfPresent(item.getMmsi());
|
||||||
|
if (existing == null || isNewerOrEqual(item, existing)) {
|
||||||
|
cache.put(item.getMmsi(), item);
|
||||||
|
updated++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updated > 0) {
|
||||||
|
log.debug("ChnPrmShip 캐시 업데이트 - 입력: {}, 대상 저장: {}, 현재 크기: {}",
|
||||||
|
items.size(), updated, cache.estimatedSize());
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 시간 범위 내 캐시 데이터 조회
|
||||||
|
*
|
||||||
|
* @param minutes 조회 범위 (분)
|
||||||
|
* @return 시간 범위 내 데이터 목록
|
||||||
|
*/
|
||||||
|
public List<AisTargetEntity> getByTimeRange(int minutes) {
|
||||||
|
OffsetDateTime threshold = OffsetDateTime.now(ZoneOffset.UTC).minusMinutes(minutes);
|
||||||
|
|
||||||
|
return cache.asMap().values().stream()
|
||||||
|
.filter(entity -> entity.getMessageTimestamp() != null)
|
||||||
|
.filter(entity -> entity.getMessageTimestamp().isAfter(threshold))
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 워밍업용 직접 저장 (시간 비교 없이 저장)
|
||||||
|
*/
|
||||||
|
public void putAll(List<AisTargetEntity> entities) {
|
||||||
|
if (entities == null || entities.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (AisTargetEntity entity : entities) {
|
||||||
|
if (entity != null && entity.getMmsi() != null) {
|
||||||
|
cache.put(entity.getMmsi(), entity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public long size() {
|
||||||
|
return cache.estimatedSize();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, Object> getStats() {
|
||||||
|
var stats = cache.stats();
|
||||||
|
return Map.of(
|
||||||
|
"estimatedSize", cache.estimatedSize(),
|
||||||
|
"maxSize", properties.getMaxSize(),
|
||||||
|
"ttlDays", properties.getTtlDays(),
|
||||||
|
"targetMmsiCount", properties.getMmsiSet().size(),
|
||||||
|
"hitCount", stats.hitCount(),
|
||||||
|
"missCount", stats.missCount(),
|
||||||
|
"hitRate", String.format("%.2f%%", stats.hitRate() * 100)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isNewerOrEqual(AisTargetEntity candidate, AisTargetEntity existing) {
|
||||||
|
if (candidate.getMessageTimestamp() == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (existing.getMessageTimestamp() == null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return !candidate.getMessageTimestamp().isBefore(existing.getMessageTimestamp());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,79 @@
|
|||||||
|
package com.snp.batch.jobs.aistarget.chnprmship;
|
||||||
|
|
||||||
|
import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
|
||||||
|
import com.snp.batch.jobs.aistarget.batch.repository.AisTargetRepository;
|
||||||
|
import com.snp.batch.jobs.aistarget.classifier.SignalKindCode;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.boot.ApplicationArguments;
|
||||||
|
import org.springframework.boot.ApplicationRunner;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기동 시 ChnPrmShip 캐시 워밍업
|
||||||
|
*
|
||||||
|
* DB(ais_target)에서 대상 MMSI의 최근 데이터를 조회하여 캐시를 채운다.
|
||||||
|
* 이후 매 분 배치 수집에서 실시간 데이터가 캐시를 갱신한다.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ChnPrmShipCacheWarmer implements ApplicationRunner {
|
||||||
|
|
||||||
|
private static final int DB_QUERY_CHUNK_SIZE = 500;
|
||||||
|
|
||||||
|
private final ChnPrmShipProperties properties;
|
||||||
|
private final ChnPrmShipCacheManager cacheManager;
|
||||||
|
private final AisTargetRepository aisTargetRepository;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void run(ApplicationArguments args) {
|
||||||
|
if (!properties.isWarmupEnabled()) {
|
||||||
|
log.info("ChnPrmShip 캐시 워밍업 비활성화");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (properties.getMmsiSet().isEmpty()) {
|
||||||
|
log.warn("ChnPrmShip 대상 MMSI가 없어 워밍업을 건너뜁니다");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
OffsetDateTime since = OffsetDateTime.now(ZoneOffset.UTC)
|
||||||
|
.minusDays(properties.getWarmupDays());
|
||||||
|
|
||||||
|
log.info("ChnPrmShip 캐시 워밍업 시작 - 대상: {}건, 조회 범위: 최근 {}일 (since: {})",
|
||||||
|
properties.getMmsiSet().size(), properties.getWarmupDays(), since);
|
||||||
|
long startTime = System.currentTimeMillis();
|
||||||
|
|
||||||
|
List<Long> mmsiList = new ArrayList<>(properties.getMmsiSet());
|
||||||
|
int totalLoaded = 0;
|
||||||
|
|
||||||
|
for (int i = 0; i < mmsiList.size(); i += DB_QUERY_CHUNK_SIZE) {
|
||||||
|
List<Long> chunk = mmsiList.subList(i,
|
||||||
|
Math.min(i + DB_QUERY_CHUNK_SIZE, mmsiList.size()));
|
||||||
|
|
||||||
|
List<AisTargetEntity> fromDb = aisTargetRepository.findLatestByMmsiInSince(chunk, since);
|
||||||
|
|
||||||
|
// signalKindCode 치환 (DB 데이터는 치환이 안 되어 있을 수 있음)
|
||||||
|
fromDb.forEach(entity -> {
|
||||||
|
if (entity.getSignalKindCode() == null) {
|
||||||
|
SignalKindCode kindCode = SignalKindCode.resolve(
|
||||||
|
entity.getVesselType(), entity.getExtraInfo());
|
||||||
|
entity.setSignalKindCode(kindCode.getCode());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
cacheManager.putAll(fromDb);
|
||||||
|
totalLoaded += fromDb.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
long elapsed = System.currentTimeMillis() - startTime;
|
||||||
|
log.info("ChnPrmShip 캐시 워밍업 완료 - 대상: {}, 로딩: {}건, 소요: {}ms",
|
||||||
|
properties.getMmsiSet().size(), totalLoaded, elapsed);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,82 @@
|
|||||||
|
package com.snp.batch.jobs.aistarget.chnprmship;
|
||||||
|
|
||||||
|
import jakarta.annotation.PostConstruct;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
import org.springframework.core.io.DefaultResourceLoader;
|
||||||
|
import org.springframework.core.io.Resource;
|
||||||
|
|
||||||
|
import java.io.BufferedReader;
|
||||||
|
import java.io.InputStreamReader;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 중국 허가선박(ChnPrmShip) 설정
|
||||||
|
*
|
||||||
|
* 대상 MMSI 목록을 리소스 파일에서 로딩하여 Set으로 보관한다.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@ConfigurationProperties(prefix = "app.batch.chnprmship")
|
||||||
|
public class ChnPrmShipProperties {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MMSI 목록 리소스 경로
|
||||||
|
*/
|
||||||
|
private String mmsiResourcePath = "classpath:chnprmship-mmsi.txt";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 캐시 TTL (일)
|
||||||
|
* - 마지막 put() 이후 이 기간이 지나면 만료
|
||||||
|
*/
|
||||||
|
private int ttlDays = 2;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 최대 캐시 크기
|
||||||
|
*/
|
||||||
|
private int maxSize = 2000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기동 시 DB 워밍업 활성화 여부
|
||||||
|
*/
|
||||||
|
private boolean warmupEnabled = true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DB 워밍업 조회 범위 (일)
|
||||||
|
*/
|
||||||
|
private int warmupDays = 2;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로딩된 대상 MMSI 집합
|
||||||
|
*/
|
||||||
|
private Set<Long> mmsiSet = Collections.emptySet();
|
||||||
|
|
||||||
|
@PostConstruct
|
||||||
|
public void init() {
|
||||||
|
try {
|
||||||
|
Resource resource = new DefaultResourceLoader().getResource(mmsiResourcePath);
|
||||||
|
try (BufferedReader reader = new BufferedReader(
|
||||||
|
new InputStreamReader(resource.getInputStream(), StandardCharsets.UTF_8))) {
|
||||||
|
mmsiSet = reader.lines()
|
||||||
|
.map(String::trim)
|
||||||
|
.filter(line -> !line.isEmpty() && !line.startsWith("#"))
|
||||||
|
.map(Long::parseLong)
|
||||||
|
.collect(Collectors.toUnmodifiableSet());
|
||||||
|
}
|
||||||
|
log.info("ChnPrmShip MMSI 로딩 완료 - {}건 (경로: {})", mmsiSet.size(), mmsiResourcePath);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("ChnPrmShip MMSI 로딩 실패 - 경로: {}, 오류: {}", mmsiResourcePath, e.getMessage());
|
||||||
|
mmsiSet = Collections.emptySet();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isTarget(Long mmsi) {
|
||||||
|
return mmsi != null && mmsiSet.contains(mmsi);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
불러오는 중...
Reference in New Issue
Block a user