Merge pull request 'release: 2026-04-07' (#3) from develop into main
Some checks failed
Build and Deploy Batch / build-and-deploy (push) Has been cancelled

Reviewed-on: #3
This commit is contained in:
HYOJIN 2026-04-07 10:26:52 +09:00
커밋 332ab4b375
382개의 변경된 파일48246개의 추가작업 그리고 2개의 파일을 삭제

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

1
.gitattributes vendored Normal file
파일 보기

@ -0,0 +1 @@
* text=auto

파일 보기

@ -0,0 +1,49 @@
name: Build and Deploy Batch
on:
push:
branches:
- main
jobs:
build-and-deploy:
runs-on: ubuntu-latest
container:
image: maven:3.9-eclipse-temurin-17
steps:
- name: Checkout
run: |
git clone --depth=1 --branch=${GITHUB_REF_NAME} \
http://gitea:3000/${GITHUB_REPOSITORY}.git .
- name: Configure Maven settings
run: |
mkdir -p ~/.m2
cat > ~/.m2/settings.xml << 'SETTINGS'
<settings>
<mirrors>
<mirror>
<id>nexus</id>
<mirrorOf>*</mirrorOf>
<url>https://nexus.gc-si.dev/repository/maven-public/</url>
</mirror>
</mirrors>
<servers>
<server>
<id>nexus</id>
<username>${{ secrets.NEXUS_USERNAME }}</username>
<password>${{ secrets.NEXUS_PASSWORD }}</password>
</server>
</servers>
</settings>
SETTINGS
- name: Build
run: mvn clean package -DskipTests -B
- name: Deploy
run: |
cp target/snp-batch-validation-*.jar /deploy/snp-batch/app.jar
date '+%Y-%m-%d %H:%M:%S' > /deploy/snp-batch/.deploy-trigger
echo "Deployed at $(cat /deploy/snp-batch/.deploy-trigger)"
ls -la /deploy/snp-batch/

108
.gitignore vendored Normal file
파일 보기

@ -0,0 +1,108 @@
# Compiled class file
*.class
# Log file
*.log
# BlueJ files
*.ctxt
# Mobile Tools for Java (J2ME)
.mtj.tmp/
# Package Files
*.jar
*.war
*.nar
*.ear
*.zip
*.tar.gz
*.rar
# virtual machine crash logs
hs_err_pid*
replay_pid*
# Maven
target/
pom.xml.tag
pom.xml.releaseBackup
pom.xml.versionsBackup
pom.xml.next
release.properties
dependency-reduced-pom.xml
buildNumber.properties
.mvn/timing.properties
.mvn/wrapper/maven-wrapper.jar
.mvn/wrapper/maven-wrapper.properties
mvnw
mvnw.cmd
# Gradle
.gradle/
build/
!gradle/wrapper/gradle-wrapper.jar
!**/src/main/**/build/
!**/src/test/**/build/
# IntelliJ IDEA
.idea/
*.iws
*.iml
*.ipr
out/
!**/src/main/**/out/
!**/src/test/**/out/
# Eclipse
.apt_generated
.classpath
.factorypath
.project
.settings
.springBeans
.sts4-cache
bin/
!**/src/main/**/bin/
!**/src/test/**/bin/
# VS Code
.vscode/
# NetBeans
/nbproject/private/
/nbbuild/
/dist/
/nbdist/
/.nb-gradle/
# Mac
.DS_Store
# Windows
Thumbs.db
ehthumbs.db
Desktop.ini
# Application specific
application-local.yml
*.env
.env.*
# Database
*.db
*.sqlite
*.sqlite3
# Logs
logs/
*.log.*
# Frontend (Vite + React)
frontend/node_modules/
frontend/node/
src/main/resources/static/
# Claude Code (개인 파일만 무시, 팀 파일은 추적)
.claude/settings.local.json
.claude/scripts/

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>

1
.sdkmanrc Normal file
파일 보기

@ -0,0 +1 @@
java=17.0.18-amzn

99
CLAUDE.md Normal file
파일 보기

@ -0,0 +1,99 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## 프로젝트 개요
S&P Maritime API에서 선박/항만/사건 데이터를 수집하여 PostgreSQL에 저장하는 배치 시스템. React 기반 관리 UI 포함.
## 빌드 & 실행
```bash
# Java 버전 설정
sdk use java 17.0.18-amzn
# 프론트엔드 빌드 (src/main/resources/static/으로 출력)
cd frontend && npm install && npm run build && cd ..
# 백엔드 빌드 (프론트 빌드 후 실행)
mvn clean package -DskipTests -Dskip.npm -Dskip.installnodenpm
# 로컬 실행
mvn spring-boot:run
# 테스트
mvn test
# 프론트엔드 개발 서버 (localhost:5173, API는 8041로 프록시)
cd frontend && npm run dev
```
## 서버 설정
- 포트: 8041, Context Path: `/snp-collector`
- Swagger UI: `http://localhost:8041/snp-collector/swagger-ui/index.html`
- 프론트엔드 vite base: `/snp-collector/` (context-path와 일치해야 함)
- 프론트엔드 빌드 출력: `frontend/``src/main/resources/static/` (emptyOutDir: true)
## 배치 Job 아키텍처
### 베이스 클래스 계층 (`common/batch/`)
```
BaseJobConfig<I, O> # 단일 Step chunk-oriented Job
└── BaseMultiStepJobConfig<I, O> # 다중 Step Job (createJobFlow() 오버라이드)
└── BasePartitionedJobConfig<I, O> # 파티셔닝 병렬 처리 Job
```
- **BaseApiReader<T>**: WebClient 기반 Maritime API 호출. GET/POST 지원. BatchApiLog 자동 기록.
- **BaseProcessor<I, O>**: DTO→Entity 변환. `processItem()` 구현.
- **BaseWriter<T>**: `writeItems()` 구현. BaseJdbcRepository의 saveAll() 사용 (기존 ID 조회 → insert/update 분리 → PreparedStatement 배치).
- **BaseEntity**: 공통 감사 필드 (createdAt, updatedAt, jobExecutionId).
### 새 Job 추가 패턴
각 Job은 `jobs/batch/{도메인}/` 하위에 config, dto, entity, reader, processor, writer, repository 구성:
1. `*JobConfig` extends BaseJobConfig (또는 BaseMultiStepJobConfig/BasePartitionedJobConfig)
2. `*Reader` extends BaseApiReader — `fetchDataFromApi()` 또는 `fetchNextBatch()` 구현
3. `*Processor` extends BaseProcessor — `processItem()` 구현
4. `*Writer` extends BaseWriter — `writeItems()` 구현
5. `*Repository` extends BaseJdbcRepository — SQL insert/update 구현
### Job 실행 흐름
1. **스케줄**: Quartz JDBC Store → `QuartzBatchJob``QuartzJobService` → Spring Batch Job 실행
2. **수동 실행**: `BatchController.POST /api/batch/jobs/{jobName}/execute`
3. **재수집**: 실패 레코드 자동 재시도 (`AutoRetryJobExecutionListener`, 최대 3회)
4. **스케줄 초기화**: `SchedulerInitializer`가 앱 시작 시 DB의 `JobScheduleEntity`를 Quartz에 등록
## 주요 API 경로 (/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 | 타임라인 |
## 프론트엔드
- React 19 + TypeScript + Vite + Tailwind CSS
- 라우팅: React Router (basename `/snp-collector`)
- 페이지: Dashboard, Jobs, Executions, ExecutionDetail, Recollects, RecollectDetail, Schedules, Timeline
- API 클라이언트: `frontend/src/api/batchApi.ts`
- 전역 상태: ToastContext, ThemeContext
## Lint/Format
- 백엔드: 별도 lint 도구 미설정 (checkstyle, spotless 없음). IDE 기본 포매터 사용.
- 프론트엔드: `cd frontend && npm run lint` (ESLint)
## 배포
- Gitea Actions (`.gitea/workflows/deploy.yml`)
- Maven Docker 이미지 (maven:3.9-eclipse-temurin-17)
- 빌드 산출물: `/deploy/snp-batch/app.jar`

파일 보기

@ -1,3 +1,91 @@
# snp-collector # SNP Collector (snp-collector)
S&P Collector - 해양 데이터 수집 배치 시스템 S&P Maritime API에서 선박/항만/사건 데이터를 수집하여 PostgreSQL에 저장하는 해양 데이터 배치 시스템. React 기반 관리 UI 포함.
## 기술 스택
- 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-collector-1.0.0.jar`
### 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
```
### IntelliJ IDEA
Run Configuration → Spring Boot:
- Main class: `com.snp.batch.SnpCollectorApplication`
- Active profiles: `local`
## 서버 배포
```bash
# 1. 빌드 (위 빌드 절차 수행)
# 2. JAR 전송
scp target/snp-collector-1.0.0.jar {서버}:{경로}/
# 3. 실행
java -jar snp-collector-1.0.0.jar --spring.profiles.active=dev
```
## 접속 정보
| 항목 | URL |
|------|-----|
| 관리 UI | `http://localhost:8041/snp-collector/` |
| Swagger | `http://localhost:8041/snp-collector/swagger-ui/index.html` |
## 프로파일
| 프로파일 | 용도 | DB |
|----------|------|----|
| `local` | 로컬 개발 | 개발 DB |
| `dev` | 개발 서버 | 개발 DB |
| `prod` | 운영 서버 | 운영 DB |
## Maven 빌드 플래그 요약
| 플래그 | 용도 |
|--------|------|
| `-DskipTests` | 테스트 스킵 |
| `-Dskip.npm` | npm install/build 스킵 |
| `-Dskip.installnodenpm` | Node/npm 자동 설치 스킵 |

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
파일 보기

@ -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
파일 보기

@ -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,
},
},
])

17
frontend/index.html Normal file
파일 보기

@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/x-icon" href="favicon.ico" />
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="apple-touch-icon.png" />
<link rel="manifest" href="site.webmanifest" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>S&amp;P Collector</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

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다. Load Diff

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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  크기: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 707 B

Binary file not shown.

After

Width:  |  Height:  |  크기: 1.6 KiB

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  크기: 15 KiB

파일 보기

@ -0,0 +1 @@
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}

56
frontend/src/App.tsx Normal file
파일 보기

@ -0,0 +1,56 @@
import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route, Navigate } 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="h-screen bg-wing-bg text-wing-text flex flex-col overflow-hidden">
<div className="flex-shrink-0 px-4 pt-4 max-w-7xl mx-auto w-full">
<Navbar />
</div>
<div className="flex-1 overflow-auto px-4 pb-4 pt-6 max-w-7xl mx-auto w-full">
<Suspense fallback={<LoadingSpinner />}>
<Routes>
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="/dashboard" 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-collector">
<ToastProvider>
<AppLayout />
</ToastProvider>
</BrowserRouter>
</ThemeProvider>
);
}

파일 보기

@ -0,0 +1,533 @@
const BASE = import.meta.env.DEV ? '/snp-collector/api/batch' : '/snp-collector/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;
failedRecordCount: number | 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;
failedRecords?: FailedRecordDto[] | 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;
}
export interface ApiLogPageResponse {
content: ApiLogEntryDto[];
page: number;
size: number;
totalElements: number;
totalPages: number;
}
export type ApiLogStatus = 'ALL' | 'SUCCESS' | 'ERROR';
export interface FailedRecordDto {
id: number;
jobName: string;
recordKey: string;
errorMessage: string | null;
retryCount: number;
status: string;
createdAt: string;
}
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;
displayName: string | null;
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;
failedRecordCounts: Record<number, 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;
}
// ── Last Collection Status ───────────────────────────────────
export interface LastCollectionStatusDto {
apiKey: string;
apiDesc: string | null;
lastSuccessDate: string | null;
updatedAt: string | null;
elapsedMinutes: number;
}
// ── Job Display Name ──────────────────────────────────────────
export interface JobDisplayName {
id: number;
jobName: string;
displayName: string;
apiKey: 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}`);
},
retryFailedRecords: (jobName: string, failedCount: number, jobExecutionId: number) => {
const qs = new URLSearchParams({
sourceJobExecutionId: String(jobExecutionId),
executionMode: 'RECOLLECT',
executor: 'MANUAL_RETRY',
reason: `실패 건 수동 재수집 (${failedCount}건)`,
});
return postJson<{ success: boolean; message: string; executionId?: number }>(
`${BASE}/jobs/${jobName}/execute?${qs.toString()}`);
},
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()}`);
},
getStepApiLogs: (stepExecutionId: number, params?: {
page?: number; size?: number; status?: ApiLogStatus;
}) => {
const qs = new URLSearchParams();
qs.set('page', String(params?.page ?? 0));
qs.set('size', String(params?.size ?? 50));
if (params?.status && params.status !== 'ALL') qs.set('status', params.status);
return fetchJson<ApiLogPageResponse>(
`${BASE}/steps/${stepExecutionId}/api-logs?${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 }) =>
postJson<{ success: boolean; message: string }>(`${BASE}/collection-periods/${apiKey}/update`, body),
// Last Collection Status
getLastCollectionStatuses: () =>
fetchJson<LastCollectionStatusDto[]>(`${BASE}/last-collections`),
// Display Names
getDisplayNames: () =>
fetchJson<JobDisplayName[]>(`${BASE}/display-names`),
resolveFailedRecords: (ids: number[]) =>
postJson<{ success: boolean; message: string; resolvedCount?: number }>(
`${BASE}/failed-records/resolve`, { ids }),
resetRetryCount: (ids: number[]) =>
postJson<{ success: boolean; message: string; resetCount?: number }>(
`${BASE}/failed-records/reset-retry`, { ids }),
exportRecollectionHistories: (params: {
apiKey?: string;
jobName?: string;
status?: string;
fromDate?: string;
toDate?: string;
}) => {
const qs = new URLSearchParams();
if (params.apiKey) qs.set('apiKey', params.apiKey);
if (params.jobName) qs.set('jobName', params.jobName);
if (params.status) qs.set('status', params.status);
if (params.fromDate) qs.set('fromDate', params.fromDate);
if (params.toDate) qs.set('toDate', params.toDate);
window.open(`${BASE}/recollection-histories/export?${qs.toString()}`);
},
};

파일 보기

@ -0,0 +1,170 @@
import { useState, useCallback, useEffect } from 'react';
import { batchApi, type ApiLogPageResponse, type ApiLogStatus } from '../api/batchApi';
import { formatDateTime } from '../utils/formatters';
import Pagination from './Pagination';
import CopyButton from './CopyButton';
interface ApiLogSectionProps {
stepExecutionId: number;
summary: { totalCalls: number; successCount: number; errorCount: number };
}
export default function ApiLogSection({ stepExecutionId, summary }: ApiLogSectionProps) {
const [open, setOpen] = useState(false);
const [status, setStatus] = useState<ApiLogStatus>('ALL');
const [page, setPage] = useState(0);
const [logData, setLogData] = useState<ApiLogPageResponse | null>(null);
const [loading, setLoading] = useState(false);
const fetchLogs = useCallback(async (p: number, s: ApiLogStatus) => {
setLoading(true);
try {
const data = await batchApi.getStepApiLogs(stepExecutionId, { page: p, size: 10, status: s });
setLogData(data);
} catch {
setLogData(null);
} finally {
setLoading(false);
}
}, [stepExecutionId]);
useEffect(() => {
if (open) {
fetchLogs(page, status);
}
}, [open, page, status, fetchLogs]);
const handleStatusChange = (s: ApiLogStatus) => {
setStatus(s);
setPage(0);
};
const filters: { key: ApiLogStatus; label: string; count: number }[] = [
{ key: 'ALL', label: '전체', count: summary.totalCalls },
{ key: 'SUCCESS', label: '성공', count: summary.successCount },
{ key: 'ERROR', label: '에러', count: summary.errorCount },
];
return (
<div className="mt-2">
<button
onClick={() => setOpen((v) => !v)}
className="inline-flex items-center gap-1 text-xs font-medium text-blue-600 hover:text-blue-800 transition-colors"
>
<svg
className={`w-3 h-3 transition-transform ${open ? 'rotate-90' : ''}`}
fill="none" viewBox="0 0 24 24" stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
({summary.totalCalls.toLocaleString()})
</button>
{open && (
<div className="mt-2">
{/* 상태 필터 탭 */}
<div className="flex gap-1 mb-2">
{filters.map(({ key, label, count }) => (
<button
key={key}
onClick={() => handleStatusChange(key)}
className={`px-2.5 py-1 text-xs rounded-full font-medium transition-colors ${
status === key
? key === 'ERROR'
? 'bg-red-500/15 text-red-500'
: key === 'SUCCESS'
? 'bg-emerald-500/15 text-emerald-500'
: 'bg-blue-500/15 text-blue-500'
: 'bg-wing-card text-wing-muted hover:bg-wing-hover'
}`}
>
{label} ({count.toLocaleString()})
</button>
))}
</div>
{loading ? (
<div className="flex items-center justify-center py-6">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
<span className="ml-2 text-xs text-blue-500">...</span>
</div>
) : logData && logData.content.length > 0 ? (
<>
<div className="overflow-x-auto">
<table className="w-full text-xs text-left">
<thead className="bg-blue-500/15 text-blue-500 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-500/15">
{logData.content.map((log, idx) => {
const isError = (log.statusCode != null && log.statusCode >= 400) || log.errorMessage;
return (
<tr
key={log.logId}
className={isError ? 'bg-red-500/10' : 'bg-wing-surface hover:bg-blue-500/10'}
>
<td className="px-2 py-1.5 text-blue-500">{page * 10 + idx + 1}</td>
<td className="px-2 py-1.5 max-w-[200px]">
<div className="flex items-center gap-0.5">
<span className="font-mono text-wing-text truncate" title={log.requestUri}>
{log.requestUri}
</span>
<CopyButton text={log.requestUri} />
</div>
</td>
<td className="px-2 py-1.5 font-semibold text-wing-text">{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-wing-text">
{log.responseTimeMs?.toLocaleString() ?? '-'}
</td>
<td className="px-2 py-1.5 text-right text-wing-text">
{log.responseCount?.toLocaleString() ?? '-'}
</td>
<td className="px-2 py-1.5 text-blue-600 whitespace-nowrap">
{formatDateTime(log.createdAt)}
</td>
<td className="px-2 py-1.5 text-red-500 max-w-[150px] truncate" title={log.errorMessage || ''}>
{log.errorMessage || '-'}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* 페이지네이션 */}
<Pagination
page={page}
totalPages={logData.totalPages}
totalElements={logData.totalElements}
pageSize={10}
onPageChange={setPage}
/>
</>
) : (
<p className="text-xs text-wing-muted py-3 text-center"> .</p>
)}
</div>
)}
</div>
);
}

파일 보기

@ -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;
}

파일 보기

@ -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>
);
}

파일 보기

@ -0,0 +1,48 @@
import { useState } from 'react';
interface CopyButtonProps {
text: string;
}
export default function CopyButton({ text }: CopyButtonProps) {
const [copied, setCopied] = useState(false);
const handleCopy = async (e: React.MouseEvent) => {
e.stopPropagation();
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}
};
return (
<button
onClick={handleCopy}
title={copied ? '복사됨!' : 'URI 복사'}
className="inline-flex items-center p-0.5 rounded hover:bg-blue-200 transition-colors shrink-0"
>
{copied ? (
<svg className="w-3.5 h-3.5 text-emerald-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
) : (
<svg className="w-3.5 h-3.5 text-blue-400 hover:text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
)}
</button>
);
}

파일 보기

@ -0,0 +1,22 @@
interface DetailStatCardProps {
label: string;
value: number;
gradient: string;
icon: string;
}
export default function DetailStatCard({ label, value, gradient, icon }: DetailStatCardProps) {
return (
<div className={`rounded-xl p-5 text-white shadow-md ${gradient}`}>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-white/80">{label}</p>
<p className="mt-1 text-3xl font-bold">
{value.toLocaleString()}
</p>
</div>
<span className="text-3xl opacity-80">{icon}</span>
</div>
</div>
);
}

파일 보기

@ -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>
);
}

파일 보기

@ -0,0 +1,92 @@
import { useState } from 'react';
interface GuideSection {
title: string;
content: string;
}
interface Props {
open: boolean;
pageTitle: string;
sections: GuideSection[];
onClose: () => void;
}
export default function GuideModal({ open, pageTitle, sections, 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-2xl w-full mx-4 max-h-[80vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-wing-text">{pageTitle} </h3>
<button
onClick={onClose}
className="p-1 text-wing-muted hover:text-wing-text transition-colors"
>
<svg xmlns="http://www.w3.org/2000/svg" className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="space-y-4">
{sections.map((section, i) => (
<GuideAccordion key={i} title={section.title} content={section.content} defaultOpen={i === 0} />
))}
</div>
<div className="flex justify-end mt-6">
<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>
);
}
function GuideAccordion({ title, content, defaultOpen }: { title: string; content: string; defaultOpen: boolean }) {
const [isOpen, setIsOpen] = useState(defaultOpen);
return (
<div className="border border-wing-border rounded-lg overflow-hidden">
<button
onClick={() => setIsOpen(!isOpen)}
className="w-full flex items-center justify-between px-4 py-3 text-sm font-medium text-wing-text bg-wing-card hover:bg-wing-hover transition-colors text-left"
>
<span>{title}</span>
<svg
xmlns="http://www.w3.org/2000/svg"
className={`w-4 h-4 text-wing-muted transition-transform ${isOpen ? 'rotate-180' : ''}`}
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
{isOpen && (
<div className="px-4 py-3 text-sm text-wing-muted leading-relaxed whitespace-pre-line">
{content}
</div>
)}
</div>
);
}
export function HelpButton({ onClick }: { onClick: () => void }) {
return (
<button
onClick={onClick}
title="사용 가이드"
className="inline-flex items-center justify-center w-7 h-7 rounded-full border border-wing-border text-wing-muted hover:text-wing-accent hover:border-wing-accent transition-colors text-sm font-semibold"
>
?
</button>
);
}

파일 보기

@ -0,0 +1,15 @@
interface InfoItemProps {
label: string;
value: string;
}
export default function InfoItem({ label, value }: InfoItemProps) {
return (
<div>
<dt className="text-xs font-medium text-wing-muted uppercase tracking-wide">
{label}
</dt>
<dd className="mt-1 text-sm text-wing-text break-words">{value || '-'}</dd>
</div>
);
}

파일 보기

@ -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>
);
}

파일 보기

@ -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>
);
}

파일 보기

@ -0,0 +1,116 @@
import { useLocation, useNavigate } from 'react-router-dom';
import { useThemeContext } from '../contexts/ThemeContext';
interface MenuItem {
id: string;
label: string;
path: string;
}
interface MenuSection {
id: string;
label: string;
shortLabel: string;
icon: React.ReactNode;
defaultPath: string;
children: MenuItem[];
}
const MENU_STRUCTURE: MenuSection[] = [
{
id: 'collector',
label: 'S&P Collector',
shortLabel: 'Collector',
icon: (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
</svg>
),
defaultPath: '/dashboard',
children: [
{ id: 'dashboard', label: '대시보드', path: '/dashboard' },
{ id: 'executions', label: '실행 이력', path: '/executions' },
{ id: 'recollects', label: '재수집 이력', path: '/recollects' },
{ id: 'jobs', label: '작업 관리', path: '/jobs' },
{ id: 'schedules', label: '스케줄', path: '/schedules' },
{ id: 'timeline', label: '타임라인', path: '/schedule-timeline' },
],
},
];
function getCurrentSection(pathname: string): MenuSection | null {
for (const section of MENU_STRUCTURE) {
if (section.children.some((c) => pathname === c.path || pathname.startsWith(c.path + '/'))) {
return section;
}
}
return null;
}
export default function Navbar() {
const location = useLocation();
const navigate = useNavigate();
const { theme, toggle } = useThemeContext();
const currentSection = getCurrentSection(location.pathname);
// 메인 화면에서는 숨김
if (!currentSection) return null;
const isActivePath = (path: string) => {
return location.pathname === path || location.pathname.startsWith(path + '/');
};
return (
<div className="mb-6">
{/* 1단: 섹션 탭 */}
<div className="bg-slate-900 px-6 pt-3 rounded-t-xl flex items-center">
<div className="flex-1 flex items-center justify-start gap-1">
{MENU_STRUCTURE.map((section) => (
<button
key={section.id}
onClick={() => {
if (currentSection?.id !== section.id) {
navigate(section.defaultPath);
}
}}
className={`flex items-center gap-2 px-5 py-2.5 rounded-t-lg text-sm font-medium transition-all ${
currentSection?.id === section.id
? 'bg-wing-bg text-wing-text shadow-sm'
: 'text-slate-400 hover:text-white hover:bg-slate-800'
}`}
>
{section.icon}
<span>{section.shortLabel}</span>
</button>
))}
</div>
<button
onClick={toggle}
className="px-2.5 py-1.5 rounded-lg text-sm text-slate-400 hover:text-white hover:bg-slate-800 transition-colors"
title={theme === 'dark' ? '라이트 모드' : '다크 모드'}
>
{theme === 'dark' ? '☀️' : '🌙'}
</button>
</div>
{/* 2단: 서브 탭 */}
<div className="bg-wing-surface border-b border-wing-border px-6 rounded-b-xl shadow-md">
<div className="flex gap-1 -mb-px justify-end">
{currentSection?.children.map((child) => (
<button
key={child.id}
onClick={() => navigate(child.path)}
className={`px-4 py-3 text-sm font-medium transition-all border-b-2 ${
isActivePath(child.path)
? 'border-blue-600 text-blue-600'
: 'border-transparent text-wing-muted hover:text-wing-text hover:border-wing-border'
}`}
>
{child.label}
</button>
))}
</div>
</div>
</div>
);
}

파일 보기

@ -0,0 +1,145 @@
interface PaginationProps {
page: number;
totalPages: number;
totalElements: number;
pageSize: number;
onPageChange: (page: number) => void;
}
/**
* (Truncated Page Number)
* - 7
* - 7 1 + / + ellipsis
*/
function getPageNumbers(current: number, total: number): (number | 'ellipsis')[] {
if (total <= 7) {
return Array.from({ length: total }, (_, i) => i);
}
const pages: (number | 'ellipsis')[] = [];
const SIBLING = 1;
const leftSibling = Math.max(current - SIBLING, 0);
const rightSibling = Math.min(current + SIBLING, total - 1);
const showLeftEllipsis = leftSibling > 1;
const showRightEllipsis = rightSibling < total - 2;
pages.push(0);
if (showLeftEllipsis) {
pages.push('ellipsis');
} else {
for (let i = 1; i < leftSibling; i++) {
pages.push(i);
}
}
for (let i = leftSibling; i <= rightSibling; i++) {
if (i !== 0 && i !== total - 1) {
pages.push(i);
}
}
if (showRightEllipsis) {
pages.push('ellipsis');
} else {
for (let i = rightSibling + 1; i < total - 1; i++) {
pages.push(i);
}
}
if (total > 1) {
pages.push(total - 1);
}
return pages;
}
export default function Pagination({
page,
totalPages,
totalElements,
pageSize,
onPageChange,
}: PaginationProps) {
if (totalPages <= 1) return null;
const start = page * pageSize + 1;
const end = Math.min((page + 1) * pageSize, totalElements);
const pages = getPageNumbers(page, totalPages);
const btnBase =
'inline-flex items-center justify-center w-7 h-7 text-xs rounded transition-colors';
const btnEnabled = 'hover:bg-wing-hover text-wing-muted';
const btnDisabled = 'opacity-30 cursor-not-allowed text-wing-muted';
return (
<div className="flex items-center justify-between mt-2 text-xs text-wing-muted">
<span>
{totalElements.toLocaleString()} {start.toLocaleString()}~
{end.toLocaleString()}
</span>
<div className="flex items-center gap-0.5">
{/* First */}
<button
onClick={() => onPageChange(0)}
disabled={page === 0}
className={`${btnBase} ${page === 0 ? btnDisabled : btnEnabled}`}
title="처음"
>
&laquo;
</button>
{/* Prev */}
<button
onClick={() => onPageChange(page - 1)}
disabled={page === 0}
className={`${btnBase} ${page === 0 ? btnDisabled : btnEnabled}`}
title="이전"
>
&lsaquo;
</button>
{/* Page Numbers */}
{pages.map((p, idx) =>
p === 'ellipsis' ? (
<span key={`e-${idx}`} className="w-7 h-7 inline-flex items-center justify-center text-wing-muted">
&hellip;
</span>
) : (
<button
key={p}
onClick={() => onPageChange(p)}
className={`${btnBase} ${
p === page
? 'bg-wing-accent text-white font-semibold'
: btnEnabled
}`}
>
{p + 1}
</button>
),
)}
{/* Next */}
<button
onClick={() => onPageChange(page + 1)}
disabled={page >= totalPages - 1}
className={`${btnBase} ${page >= totalPages - 1 ? btnDisabled : btnEnabled}`}
title="다음"
>
&rsaquo;
</button>
{/* Last */}
<button
onClick={() => onPageChange(totalPages - 1)}
disabled={page >= totalPages - 1}
className={`${btnBase} ${page >= totalPages - 1 ? btnDisabled : btnEnabled}`}
title="마지막"
>
&raquo;
</button>
</div>
</div>
);
}

파일 보기

@ -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';
}
}

파일 보기

@ -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>
);
}

파일 보기

@ -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);
}

파일 보기

@ -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;
}

파일 보기

@ -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]);
}

파일 보기

@ -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;
}

파일 보기

@ -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
파일 보기

@ -0,0 +1,3 @@
@import "tailwindcss";
@import "./theme/tokens.css";
@import "./theme/base.css";

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>,
)

파일 보기

@ -0,0 +1,495 @@
import { useState, useCallback, useEffect, useMemo } from 'react';
import { Link } from 'react-router-dom';
import {
batchApi,
type DashboardResponse,
type DashboardStats,
type ExecutionStatisticsDto,
type RecollectionStatsResponse,
type JobDisplayName,
} 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';
import GuideModal, { HelpButton } from '../components/GuideModal';
const POLLING_INTERVAL = 5000;
const DASHBOARD_GUIDE = [
{
title: '통계 카드',
content: '화면 상단에 전체 스케줄, 활성/비활성 스케줄 수, 전체 작업 수, 최근 24시간 실패 건수를 한눈에 보여줍니다.',
},
{
title: '실행 중인 작업',
content: '현재 실행 중인 배치 작업 목록을 실시간으로 보여줍니다.\n5초마다 자동으로 갱신됩니다.\n오래 실행 중인 작업이 있으면 상단에 경고 배너가 표시되며, "전체 강제 종료" 버튼으로 일괄 중지할 수 있습니다.',
},
{
title: '최근 실행 이력',
content: '최근 완료된 배치 작업 5건을 보여줍니다.\n각 행의 작업명, 상태, 시작 시간, 소요 시간을 확인할 수 있습니다.\n"전체 보기"를 클릭하면 실행 이력 화면으로 이동합니다.',
},
{
title: '최근 실패 이력',
content: '최근 24시간 내 실패한 작업이 있을 때만 표시됩니다.\n실패 원인을 빠르게 파악할 수 있도록 종료 코드와 메시지를 함께 보여줍니다.',
},
{
title: '재수집 현황',
content: '마지막 수집 완료일시를 API별로 보여줍니다.\n최근 5건의 재수집 이력도 함께 확인할 수 있습니다.',
},
{
title: '실행 통계 차트',
content: '최근 30일간의 배치 실행 통계를 바 차트로 보여줍니다.\n초록색은 성공, 빨간색은 실패, 회색은 기타 상태를 나타냅니다.',
},
];
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);
const [guideOpen, setGuideOpen] = useState(false);
const [abandoning, setAbandoning] = useState(false);
const [statistics, setStatistics] = useState<ExecutionStatisticsDto | null>(null);
const [recollectionStats, setRecollectionStats] = useState<RecollectionStatsResponse | null>(null);
const [displayNames, setDisplayNames] = useState<JobDisplayName[]>([]);
const loadStatistics = useCallback(async () => {
try {
const data = await batchApi.getStatistics(30);
setStatistics(data);
} catch {
/* 통계 로드 실패는 무시 */
}
}, []);
useEffect(() => {
loadStatistics();
}, [loadStatistics]);
const loadRecollectionStats = useCallback(async () => {
try {
const data = await batchApi.getRecollectionStats();
setRecollectionStats(data);
} catch {
/* 통계 로드 실패는 무시 */
}
}, []);
useEffect(() => {
loadRecollectionStats();
}, [loadRecollectionStats]);
useEffect(() => {
batchApi.getDisplayNames().then(setDisplayNames).catch(() => {});
}, []);
const displayNameMap = useMemo<Record<string, string>>(() => {
const map: Record<string, string> = {};
for (const dn of displayNames) {
if (dn.apiKey) map[dn.apiKey] = dn.displayName;
map[dn.jobName] = dn.displayName;
}
return map;
}, [displayNames]);
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 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">
<div className="flex items-center gap-2">
<h1 className="text-2xl font-bold text-wing-text"></h1>
<HelpButton onClick={() => setGuideOpen(true)} />
</div>
</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>
{/* 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">{displayNameMap[job.jobName] || 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"
>
&rarr;
</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">{displayNameMap[exec.jobName] || 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">{displayNameMap[fail.jobName] || 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>
)}
{/* 재수집 현황 */}
{recollectionStats && recollectionStats.totalCount > 0 && (
<section className="bg-wing-surface rounded-xl shadow-md p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-wing-text"> </h2>
<Link to="/recollects" className="text-sm text-wing-accent hover:text-wing-accent no-underline">
&rarr;
</Link>
</div>
<div className="grid grid-cols-2 sm:grid-cols-5 gap-3 mb-4">
<div className="bg-blue-50 rounded-lg p-3 text-center">
<p className="text-2xl font-bold text-blue-700">{recollectionStats.totalCount}</p>
<p className="text-xs text-blue-500 mt-1"></p>
</div>
<div className="bg-emerald-50 rounded-lg p-3 text-center">
<p className="text-2xl font-bold text-emerald-700">{recollectionStats.completedCount}</p>
<p className="text-xs text-emerald-500 mt-1"></p>
</div>
<div className="bg-red-50 rounded-lg p-3 text-center">
<p className="text-2xl font-bold text-red-700">{recollectionStats.failedCount}</p>
<p className="text-xs text-red-500 mt-1"></p>
</div>
<div className="bg-amber-50 rounded-lg p-3 text-center">
<p className="text-2xl font-bold text-amber-700">{recollectionStats.runningCount}</p>
<p className="text-xs text-amber-500 mt-1"> </p>
</div>
<div className="bg-violet-50 rounded-lg p-3 text-center">
<p className="text-2xl font-bold text-violet-700">{recollectionStats.overlapCount}</p>
<p className="text-xs text-violet-500 mt-1"></p>
</div>
</div>
{/* 최근 재수집 이력 (최대 5건) */}
{recollectionStats.recentHistories.length > 0 && (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-wing-border text-left text-wing-muted">
<th className="pb-2 font-medium"> ID</th>
<th className="pb-2 font-medium"></th>
<th className="pb-2 font-medium"></th>
<th className="pb-2 font-medium"> </th>
<th className="pb-2 font-medium"></th>
</tr>
</thead>
<tbody>
{recollectionStats.recentHistories.slice(0, 5).map((h) => (
<tr key={h.historyId} className="border-b border-wing-border/50">
<td className="py-3">
<Link to={`/recollects/${h.historyId}`} className="text-wing-accent hover:text-wing-accent no-underline font-medium">
#{h.historyId}
</Link>
</td>
<td className="py-3 text-wing-text">{displayNameMap[h.apiKey] || h.apiKeyName || h.jobName}</td>
<td className="py-3 text-wing-muted">{h.executor || '-'}</td>
<td className="py-3 text-wing-muted">{formatDateTime(h.executionStartTime)}</td>
<td className="py-3">
<StatusBadge status={h.executionStatus} />
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
)}
{/* F8: Execution Statistics Chart */}
{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>
)}
<GuideModal
open={guideOpen}
pageTitle="대시보드"
sections={DASHBOARD_GUIDE}
onClose={() => setGuideOpen(false)}
/>
</div>
);
}

파일 보기

@ -0,0 +1,708 @@
import { useState, useCallback } from 'react';
import { useParams, useSearchParams, useNavigate } from 'react-router-dom';
import { batchApi, type JobExecutionDetailDto, type StepExecutionDto, type FailedRecordDto } 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';
import Pagination from '../components/Pagination';
import DetailStatCard from '../components/DetailStatCard';
import ApiLogSection from '../components/ApiLogSection';
import InfoItem from '../components/InfoItem';
import GuideModal, { HelpButton } from '../components/GuideModal';
const POLLING_INTERVAL_MS = 5000;
const EXECUTION_DETAIL_GUIDE = [
{
title: '실행 기본 정보',
content: '실행의 시작/종료 시간, 소요 시간, 종료 코드, 에러 메시지 등 기본 정보를 보여줍니다.\n실행 중인 경우 5초마다 자동으로 갱신됩니다.',
},
{
title: '처리 통계',
content: '4개의 통계 카드로 전체 처리 현황을 요약합니다.\n• 읽기(Read): 외부 API에서 조회한 건수\n• 쓰기(Write): DB에 저장된 건수\n• 건너뜀(Skip): 처리하지 않은 건수\n• 필터(Filter): 조건에 의해 제외된 건수',
},
{
title: 'Step 실행 정보',
content: '배치 작업은 하나 이상의 Step으로 구성됩니다.\n각 Step의 상태, 처리 건수, 커밋/롤백 횟수를 확인할 수 있습니다.\nAPI 호출 정보에서는 총 호출 수, 성공/에러 수, 평균 응답 시간을 보여줍니다.',
},
{
title: 'API 호출 로그',
content: '각 Step에서 호출한 외부 API의 상세 로그를 확인할 수 있습니다.\n요청 URL, 응답 코드, 응답 시간 등을 페이지 단위로 조회합니다.',
},
{
title: '실패 건 관리',
content: '처리 중 실패한 레코드가 있으면 목록으로 표시됩니다.\n• 실패 건 재수집: 실패한 데이터를 다시 수집합니다\n• 일괄 RESOLVED: 모든 실패 건을 해결됨으로 처리합니다\n• 재시도 초기화: 재시도 횟수를 초기화하여 자동 재수집 대상에 포함시킵니다',
},
];
interface StepCardProps {
step: StepExecutionDto;
jobName: string;
jobExecutionId: number;
}
function StepCard({ step, jobName, jobExecutionId }: 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 호출 정보: apiLogSummary가 있으면 개별 로그 리스트, 없으면 기존 apiCallInfo 요약 */}
{step.apiLogSummary ? (
<div className="mt-4 rounded-lg bg-blue-500/10 border border-blue-500/20 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-wing-surface px-2 py-1.5 text-center">
<p className="text-sm font-bold text-wing-text">{step.apiLogSummary.totalCalls.toLocaleString()}</p>
<p className="text-[10px] text-wing-muted"> </p>
</div>
<div className="rounded bg-wing-surface px-2 py-1.5 text-center">
<p className="text-sm font-bold text-emerald-600">{step.apiLogSummary.successCount.toLocaleString()}</p>
<p className="text-[10px] text-wing-muted"></p>
</div>
<div className="rounded bg-wing-surface px-2 py-1.5 text-center">
<p className={`text-sm font-bold ${step.apiLogSummary.errorCount > 0 ? 'text-red-500' : 'text-wing-text'}`}>
{step.apiLogSummary.errorCount.toLocaleString()}
</p>
<p className="text-[10px] text-wing-muted"></p>
</div>
<div className="rounded bg-wing-surface px-2 py-1.5 text-center">
<p className="text-sm font-bold text-blue-600">{Math.round(step.apiLogSummary.avgResponseMs).toLocaleString()}</p>
<p className="text-[10px] text-wing-muted">(ms)</p>
</div>
<div className="rounded bg-wing-surface px-2 py-1.5 text-center">
<p className="text-sm font-bold text-red-500">{step.apiLogSummary.maxResponseMs.toLocaleString()}</p>
<p className="text-[10px] text-wing-muted">(ms)</p>
</div>
<div className="rounded bg-wing-surface px-2 py-1.5 text-center">
<p className="text-sm font-bold text-emerald-500">{step.apiLogSummary.minResponseMs.toLocaleString()}</p>
<p className="text-[10px] text-wing-muted">(ms)</p>
</div>
</div>
{step.apiLogSummary.totalCalls > 0 && (
<ApiLogSection stepExecutionId={step.stepExecutionId} summary={step.apiLogSummary} />
)}
</div>
) : step.apiCallInfo && (
<div className="mt-4 rounded-lg bg-blue-500/10 border border-blue-500/20 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.failedRecords && step.failedRecords.length > 0 && (
<FailedRecordsToggle records={step.failedRecords} jobName={jobName} jobExecutionId={jobExecutionId} />
)}
{step.exitMessage && (
<div className="mt-4 rounded-lg bg-red-500/10 border border-red-500/20 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 [guideOpen, setGuideOpen] = useState(false);
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>&larr;</span>
</button>
<EmptyState
icon="&#x26A0;"
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>&larr;</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>
<div className="flex items-center gap-2">
<h1 className="text-2xl font-bold text-wing-text">
#{detail.executionId}
</h1>
<HelpButton onClick={() => setGuideOpen(true)} />
</div>
<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">
<DetailStatCard
label="읽기 (Read)"
value={detail.readCount}
gradient="bg-gradient-to-br from-blue-500 to-blue-600"
icon="&#x1F4E5;"
/>
<DetailStatCard
label="쓰기 (Write)"
value={detail.writeCount}
gradient="bg-gradient-to-br from-emerald-500 to-emerald-600"
icon="&#x1F4E4;"
/>
<DetailStatCard
label="건너뜀 (Skip)"
value={detail.skipCount}
gradient="bg-gradient-to-br from-amber-500 to-amber-600"
icon="&#x23ED;"
/>
<DetailStatCard
label="필터 (Filter)"
value={detail.filterCount}
gradient="bg-gradient-to-br from-purple-500 to-purple-600"
icon="&#x1F50D;"
/>
</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}
jobName={detail.jobName}
jobExecutionId={executionId}
/>
))}
</div>
)}
</div>
<GuideModal
open={guideOpen}
onClose={() => setGuideOpen(false)}
pageTitle="실행 상세"
sections={EXECUTION_DETAIL_GUIDE}
/>
</div>
);
}
const FAILED_PAGE_SIZE = 10;
function FailedRecordsToggle({ records, jobName, jobExecutionId }: { records: FailedRecordDto[]; jobName: string; jobExecutionId: number }) {
const [open, setOpen] = useState(false);
const [showConfirm, setShowConfirm] = useState(false);
const [showResolveConfirm, setShowResolveConfirm] = useState(false);
const [retrying, setRetrying] = useState(false);
const [resolving, setResolving] = useState(false);
const [showResetConfirm, setShowResetConfirm] = useState(false);
const [resetting, setResetting] = useState(false);
const [page, setPage] = useState(0);
const navigate = useNavigate();
const failedRecords = records.filter((r) => r.status === 'FAILED');
const totalPages = Math.ceil(records.length / FAILED_PAGE_SIZE);
const pagedRecords = records.slice(page * FAILED_PAGE_SIZE, (page + 1) * FAILED_PAGE_SIZE);
const statusColor = (status: string) => {
switch (status) {
case 'RESOLVED': return 'text-emerald-600 bg-emerald-50';
case 'RETRY_PENDING': return 'text-amber-600 bg-amber-50';
default: return 'text-red-600 bg-red-50';
}
};
const MAX_RETRY_COUNT = 3;
const retryStatusLabel = (record: FailedRecordDto) => {
if (record.status !== 'FAILED') return null;
if (record.retryCount >= MAX_RETRY_COUNT) return { label: '재시도 초과', color: 'text-red-600 bg-red-100' };
if (record.retryCount > 0) return { label: `재시도 ${record.retryCount}/${MAX_RETRY_COUNT}`, color: 'text-amber-600 bg-amber-100' };
return { label: '대기', color: 'text-blue-600 bg-blue-100' };
};
const exceededRecords = failedRecords.filter((r) => r.retryCount >= MAX_RETRY_COUNT);
const handleRetry = async () => {
setRetrying(true);
try {
const result = await batchApi.retryFailedRecords(jobName, failedRecords.length, jobExecutionId);
if (result.success) {
setShowConfirm(false);
if (result.executionId) {
navigate(`/executions/${result.executionId}`);
} else {
alert(result.message || '재수집이 요청되었습니다.');
}
} else {
alert(result.message || '재수집 실행에 실패했습니다.');
}
} catch {
alert('재수집 실행에 실패했습니다.');
} finally {
setRetrying(false);
}
};
const handleResolve = async () => {
setResolving(true);
try {
const ids = failedRecords.map((r) => r.id);
await batchApi.resolveFailedRecords(ids);
setShowResolveConfirm(false);
navigate(0);
} catch {
alert('일괄 RESOLVED 처리에 실패했습니다.');
} finally {
setResolving(false);
}
};
const handleResetRetry = async () => {
setResetting(true);
try {
const ids = exceededRecords.map((r) => r.id);
await batchApi.resetRetryCount(ids);
setShowResetConfirm(false);
navigate(0);
} catch {
alert('재시도 초기화에 실패했습니다.');
} finally {
setResetting(false);
}
};
return (
<div className="mt-4 rounded-lg bg-red-500/10 border border-red-500/20 p-3">
<div className="flex items-center justify-between">
<button
onClick={() => setOpen((v) => !v)}
className="inline-flex items-center gap-1 text-xs font-medium text-red-600 hover:text-red-800 transition-colors"
>
<svg
className={`w-3 h-3 transition-transform ${open ? 'rotate-90' : ''}`}
fill="none" viewBox="0 0 24 24" stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
({records.length.toLocaleString()}, FAILED {failedRecords.length})
</button>
{failedRecords.length > 0 && (
<div className="flex items-center gap-1.5">
{exceededRecords.length > 0 && (
<button
onClick={() => setShowResetConfirm(true)}
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-medium text-amber-700 bg-amber-50 hover:bg-amber-100 border border-amber-200 rounded-md transition-colors"
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
({exceededRecords.length})
</button>
)}
<button
onClick={() => setShowResolveConfirm(true)}
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-medium text-emerald-700 bg-emerald-50 hover:bg-emerald-100 border border-emerald-200 rounded-md transition-colors"
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
RESOLVED ({failedRecords.length})
</button>
<button
onClick={() => setShowConfirm(true)}
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-medium text-white bg-red-500 hover:bg-red-600 rounded-md transition-colors"
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
({failedRecords.length})
</button>
</div>
)}
</div>
{open && (
<div className="mt-2">
<div className="overflow-x-auto">
<table className="w-full text-xs text-left">
<thead className="bg-red-500/20 text-red-700">
<tr>
<th className="px-2 py-1.5 font-medium">Record Key</th>
<th className="px-2 py-1.5 font-medium"> </th>
<th className="px-2 py-1.5 font-medium text-center"></th>
<th className="px-2 py-1.5 font-medium text-center"></th>
<th className="px-2 py-1.5 font-medium"> </th>
</tr>
</thead>
<tbody className="divide-y divide-red-500/20">
{pagedRecords.map((record) => (
<tr
key={record.id}
className="bg-wing-surface hover:bg-red-500/10"
>
<td className="px-2 py-1.5 font-mono text-red-900">
{record.recordKey}
</td>
<td className="px-2 py-1.5 text-red-600 max-w-[200px] truncate" title={record.errorMessage || ''}>
{record.errorMessage || '-'}
</td>
<td className="px-2 py-1.5 text-center">
{(() => {
const info = retryStatusLabel(record);
return info ? (
<span className={`inline-flex px-1.5 py-0.5 text-[10px] font-medium rounded-full ${info.color}`}>
{info.label}
</span>
) : (
<span className="text-wing-muted">-</span>
);
})()}
</td>
<td className="px-2 py-1.5 text-center">
<span className={`inline-flex px-1.5 py-0.5 text-[10px] font-medium rounded-full ${statusColor(record.status)}`}>
{record.status}
</span>
</td>
<td className="px-2 py-1.5 text-red-500 whitespace-nowrap">
{formatDateTime(record.createdAt)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<Pagination
page={page}
totalPages={totalPages}
totalElements={records.length}
pageSize={FAILED_PAGE_SIZE}
onPageChange={setPage}
/>
</div>
)}
{/* 재수집 확인 다이얼로그 */}
{showConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-wing-surface rounded-xl shadow-2xl p-6 w-full max-w-md mx-4">
<h3 className="text-lg font-semibold text-wing-text mb-2">
</h3>
<p className="text-sm text-wing-muted mb-3">
{failedRecords.length} IMO에 .
</p>
<div className="bg-wing-card rounded-lg p-3 mb-4 max-h-40 overflow-y-auto">
<div className="flex flex-wrap gap-1">
{failedRecords.map((r) => (
<span
key={r.id}
className="inline-flex px-2 py-0.5 text-xs font-mono bg-red-500/20 text-red-700 rounded"
>
{r.recordKey}
</span>
))}
</div>
</div>
<div className="flex justify-end gap-2">
<button
onClick={() => setShowConfirm(false)}
disabled={retrying}
className="px-4 py-2 text-sm font-medium text-wing-muted bg-wing-card hover:bg-wing-hover rounded-lg transition-colors disabled:opacity-50"
>
</button>
<button
onClick={handleRetry}
disabled={retrying}
className="px-4 py-2 text-sm font-medium text-white bg-red-500 hover:bg-red-600 rounded-lg transition-colors disabled:opacity-50 inline-flex items-center gap-1"
>
{retrying ? (
<>
<div className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-white border-t-transparent" />
...
</>
) : (
'재수집 실행'
)}
</button>
</div>
</div>
</div>
)}
{/* 일괄 RESOLVED 확인 다이얼로그 */}
{showResolveConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-wing-surface rounded-xl shadow-2xl p-6 w-full max-w-md mx-4">
<h3 className="text-lg font-semibold text-wing-text mb-2">
RESOLVED
</h3>
<p className="text-sm text-wing-muted mb-4">
FAILED {failedRecords.length} RESOLVED로 .
.
</p>
<div className="flex justify-end gap-2">
<button
onClick={() => setShowResolveConfirm(false)}
disabled={resolving}
className="px-4 py-2 text-sm font-medium text-wing-muted bg-wing-card hover:bg-wing-hover rounded-lg transition-colors disabled:opacity-50"
>
</button>
<button
onClick={handleResolve}
disabled={resolving}
className="px-4 py-2 text-sm font-medium text-white bg-emerald-500 hover:bg-emerald-600 rounded-lg transition-colors disabled:opacity-50 inline-flex items-center gap-1"
>
{resolving ? (
<>
<div className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-white border-t-transparent" />
...
</>
) : (
'RESOLVED 처리'
)}
</button>
</div>
</div>
</div>
)}
{/* 재시도 초기화 확인 다이얼로그 */}
{showResetConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-wing-surface rounded-xl shadow-2xl p-6 w-full max-w-md mx-4">
<h3 className="text-lg font-semibold text-wing-text mb-2">
</h3>
<p className="text-sm text-wing-muted mb-4">
{exceededRecords.length} retryCount를 0 .
.
</p>
<div className="flex justify-end gap-2">
<button
onClick={() => setShowResetConfirm(false)}
disabled={resetting}
className="px-4 py-2 text-sm font-medium text-wing-muted bg-wing-card hover:bg-wing-hover rounded-lg transition-colors disabled:opacity-50"
>
</button>
<button
onClick={handleResetRetry}
disabled={resetting}
className="px-4 py-2 text-sm font-medium text-white bg-amber-500 hover:bg-amber-600 rounded-lg transition-colors disabled:opacity-50 inline-flex items-center gap-1"
>
{resetting ? (
<>
<div className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-white border-t-transparent" />
...
</>
) : (
'초기화 실행'
)}
</button>
</div>
</div>
</div>
)}
</div>
);
}

파일 보기

@ -0,0 +1,659 @@
import { useState, useMemo, useCallback, useEffect } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { batchApi, type JobExecutionDto, type ExecutionSearchResponse, type JobDisplayName } 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';
import GuideModal, { HelpButton } from '../components/GuideModal';
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;
const EXECUTIONS_GUIDE = [
{
title: '작업 필터',
content: '상단의 드롭다운에서 조회할 작업을 선택할 수 있습니다.\n여러 작업을 동시에 선택할 수 있으며, 단축 버튼으로 빠르게 필터링할 수 있습니다.\n• 전체: 모든 작업 표시\n• AIS 제외: AIS 관련 작업을 제외하고 표시\n• AIS만: AIS 관련 작업만 표시',
},
{
title: '상태 필터',
content: '완료 / 실패 / 실행중 / 중지됨 버튼으로 상태별 필터링이 가능합니다.\n"전체"를 선택하면 모든 상태의 실행 이력을 볼 수 있습니다.',
},
{
title: '날짜 검색',
content: '시작일과 종료일을 지정하여 특정 기간의 실행 이력을 조회할 수 있습니다.\n"검색" 버튼을 클릭하면 조건에 맞는 결과가 표시됩니다.\n"초기화" 버튼으로 검색 조건을 제거하고 최신 이력으로 돌아갑니다.',
},
{
title: '실행 중인 작업 제어',
content: '실행 중인 작업의 행에서 "중지" 또는 "강제 종료" 버튼을 사용할 수 있습니다.\n• 중지: 현재 Step 완료 후 안전하게 종료\n• 강제 종료: 즉시 중단 (데이터 정합성 주의)',
},
{
title: '실패 로그 확인',
content: '상태가 "FAILED"인 행을 클릭하면 실패 상세 정보를 확인할 수 있습니다.\n종료 코드(Exit Code)와 에러 메시지로 실패 원인을 파악하세요.\n상태가 "COMPLETED"이지만 실패 건수가 있으면 경고 아이콘이 표시됩니다.',
},
];
export default function Executions() {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const jobFromQuery = searchParams.get('job') || '';
const [jobs, setJobs] = useState<string[]>([]);
const [displayNames, setDisplayNames] = useState<JobDisplayName[]>([]);
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 [guideOpen, setGuideOpen] = useState(false);
const { showToast } = useToastContext();
useEffect(() => {
batchApi.getDisplayNames().then(setDisplayNames).catch(() => {});
}, []);
const displayNameMap = useMemo<Record<string, string>>(() => {
const map: Record<string, string> = {};
for (const dn of displayNames) {
map[dn.jobName] = dn.displayName;
}
return map;
}, [displayNames]);
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>
<div className="flex items-center gap-2">
<h1 className="text-2xl font-bold text-wing-text"> </h1>
<HelpButton onClick={() => setGuideOpen(true)} />
</div>
<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">{displayNameMap[job] || job}</span>
</label>
))}
</div>
</>
)}
</div>
<div className="flex gap-1.5">
<button
onClick={() => setSelectedJobs([])}
className={`px-2 py-1 text-xs rounded-md transition-colors ${
selectedJobs.length === 0
? 'bg-wing-accent text-white'
: 'bg-wing-card text-wing-muted hover:bg-wing-hover'
}`}
>
</button>
</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"
>
{displayNameMap[job] || job}
<button
onClick={() => toggleJob(job)}
className="hover:text-wing-text transition-colors"
>
&times;
</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">
{displayNameMap[exec.jobName] || exec.jobName}
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-1.5">
{/* F9: FAILED 상태 클릭 시 실패 로그 모달 */}
{exec.status === 'FAILED' ? (
<button
onClick={() => setFailLogTarget(exec)}
className="cursor-pointer"
title="클릭하여 실패 로그 확인"
>
<StatusBadge status={exec.status} />
</button>
) : (
<StatusBadge status={exec.status} />
)}
{exec.status === 'COMPLETED' && exec.failedRecordCount != null && exec.failedRecordCount > 0 && (
<span
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] font-semibold text-amber-700 bg-amber-50 border border-amber-200 rounded-full"
title={`미해결 실패 레코드 ${exec.failedRecordCount}`}
>
<svg className="w-2.5 h-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{exec.failedRecordCount}
</span>
)}
</div>
</td>
<td 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>
<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)}
/>
<GuideModal
open={guideOpen}
onClose={() => setGuideOpen(false)}
pageTitle="실행 이력"
sections={EXECUTIONS_GUIDE}
/>
{/* 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>
);
}

562
frontend/src/pages/Jobs.tsx Normal file
파일 보기

@ -0,0 +1,562 @@
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, calculateDuration } from '../utils/formatters';
import GuideModal, { HelpButton } from '../components/GuideModal';
const POLLING_INTERVAL = 30000;
const JOBS_GUIDE = [
{
title: '상태 필터',
content: '상단의 탭 버튼으로 작업 상태별 필터링이 가능합니다.\n전체 / 실행 중 / 성공 / 실패 / 미실행 중 선택하세요.\n각 탭 옆의 숫자는 해당 상태의 작업 수입니다.',
},
{
title: '검색 및 정렬',
content: '검색창에 작업명을 입력하면 실시간으로 필터링됩니다.\n정렬 옵션: 작업명순, 최신 실행순(기본), 상태별(실패 우선)\n테이블/카드 뷰 전환 버튼으로 보기 방식을 변경할 수 있습니다.',
},
{
title: '작업 실행',
content: '"실행" 버튼을 클릭하면 확인 팝업이 표시됩니다.\n확인 후 해당 배치 작업이 즉시 실행됩니다.\n실행 중인 작업은 좌측에 초록색 점이 표시됩니다.',
},
{
title: '이력 보기',
content: '"이력 보기" 버튼을 클릭하면 해당 작업의 실행 이력 화면으로 이동합니다.\n과거 실행 결과, 소요 시간 등을 상세히 확인할 수 있습니다.',
},
];
type StatusFilterKey = 'ALL' | 'STARTED' | 'COMPLETED' | 'FAILED' | 'NONE';
type SortKey = 'name' | 'recent' | 'status';
type ViewMode = 'card' | 'table';
interface StatusTabConfig {
key: StatusFilterKey;
label: string;
}
const STATUS_TABS: StatusTabConfig[] = [
{ key: 'ALL', label: '전체' },
{ key: 'STARTED', label: '실행 중' },
{ key: 'COMPLETED', label: '성공' },
{ key: 'FAILED', label: '실패' },
{ key: 'NONE', label: '미실행' },
];
const STATUS_ORDER: Record<string, number> = {
FAILED: 0,
STARTED: 1,
COMPLETED: 2,
};
function getStatusOrder(job: JobDetailDto): number {
if (!job.lastExecution) return 3;
return STATUS_ORDER[job.lastExecution.status] ?? 4;
}
function matchesStatusFilter(job: JobDetailDto, filter: StatusFilterKey): boolean {
if (filter === 'ALL') return true;
if (filter === 'NONE') return job.lastExecution === null;
return job.lastExecution?.status === filter;
}
export default function Jobs() {
const navigate = useNavigate();
const { showToast } = useToastContext();
const [jobs, setJobs] = useState<JobDetailDto[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState<StatusFilterKey>('ALL');
const [sortKey, setSortKey] = useState<SortKey>('recent');
const [viewMode, setViewMode] = useState<ViewMode>('table');
const [guideOpen, setGuideOpen] = useState(false);
// Execute modal (individual card)
const [executeModalOpen, setExecuteModalOpen] = useState(false);
const [targetJob, setTargetJob] = useState('');
const [executing, setExecuting] = useState(false);
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);
/** displayName 우선, 없으면 jobName */
const getJobLabel = useCallback((job: JobDetailDto) => job.displayName || job.jobName, []);
const statusCounts = useMemo(() => {
const searchFiltered = searchTerm.trim()
? jobs.filter((job) => {
const term = searchTerm.toLowerCase();
return job.jobName.toLowerCase().includes(term)
|| (job.displayName?.toLowerCase().includes(term) ?? false);
})
: jobs;
return STATUS_TABS.reduce<Record<StatusFilterKey, number>>(
(acc, tab) => {
acc[tab.key] = searchFiltered.filter((job) => matchesStatusFilter(job, tab.key)).length;
return acc;
},
{ ALL: 0, STARTED: 0, COMPLETED: 0, FAILED: 0, NONE: 0 },
);
}, [jobs, searchTerm]);
const filteredJobs = useMemo(() => {
let result = jobs;
if (searchTerm.trim()) {
const term = searchTerm.toLowerCase();
result = result.filter((job) =>
job.jobName.toLowerCase().includes(term)
|| (job.displayName?.toLowerCase().includes(term) ?? false),
);
}
result = result.filter((job) => matchesStatusFilter(job, statusFilter));
result = [...result].sort((a, b) => {
if (sortKey === 'name') {
return getJobLabel(a).localeCompare(getJobLabel(b));
}
if (sortKey === 'recent') {
const aTime = a.lastExecution?.startTime ? new Date(a.lastExecution.startTime).getTime() : 0;
const bTime = b.lastExecution?.startTime ? new Date(b.lastExecution.startTime).getTime() : 0;
return bTime - aTime;
}
if (sortKey === 'status') {
return getStatusOrder(a) - getStatusOrder(b);
}
return 0;
});
return result;
}, [jobs, searchTerm, statusFilter, sortKey]);
const handleExecuteClick = (jobName: string) => {
setTargetJob(jobName);
setExecuteModalOpen(true);
};
const handleConfirmExecute = async () => {
if (!targetJob) return;
setExecuting(true);
try {
const result = await batchApi.executeJob(targetJob);
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">
<div className="flex items-center gap-2">
<h1 className="text-2xl font-bold text-wing-text"> </h1>
<HelpButton onClick={() => setGuideOpen(true)} />
</div>
<span className="text-sm text-wing-muted">
{jobs.length}
</span>
</div>
{/* Status Filter Tabs */}
<div className="flex flex-wrap gap-2">
{STATUS_TABS.map((tab) => (
<button
key={tab.key}
onClick={() => setStatusFilter(tab.key)}
className={`inline-flex items-center gap-1.5 px-4 py-1.5 rounded-full text-sm font-medium transition-colors ${
statusFilter === tab.key
? 'bg-wing-accent text-white'
: 'bg-wing-card text-wing-muted hover:text-wing-text'
}`}
>
{tab.label}
<span
className={`inline-flex items-center justify-center min-w-[1.25rem] h-5 px-1 rounded-full text-xs font-semibold ${
statusFilter === tab.key
? 'bg-white/25 text-white'
: 'bg-wing-border text-wing-muted'
}`}
>
{statusCounts[tab.key]}
</span>
</button>
))}
</div>
{/* Search + Sort + View Toggle */}
<div className="bg-wing-surface rounded-xl shadow-md p-4">
<div className="flex gap-3 items-center flex-wrap">
{/* Search */}
<div className="relative flex-1 min-w-[200px]">
<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>
{/* Sort dropdown */}
<select
value={sortKey}
onChange={(e) => setSortKey(e.target.value as SortKey)}
className="px-3 py-2 border border-wing-border rounded-lg text-sm bg-wing-surface text-wing-text
focus:ring-2 focus:ring-wing-accent focus:border-wing-accent outline-none"
>
<option value="name"></option>
<option value="recent"> </option>
<option value="status">( )</option>
</select>
{/* View mode toggle */}
<div className="flex rounded-lg border border-wing-border overflow-hidden">
<button
onClick={() => setViewMode('table')}
title="테이블 보기"
className={`px-3 py-2 transition-colors ${
viewMode === 'table'
? 'bg-wing-accent text-white'
: 'bg-wing-surface 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="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<button
onClick={() => setViewMode('card')}
title="카드 보기"
className={`px-3 py-2 transition-colors border-l border-wing-border ${
viewMode === 'card'
? 'bg-wing-accent text-white'
: 'bg-wing-surface 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="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"
/>
</svg>
</button>
</div>
</div>
{searchTerm && (
<p className="mt-2 text-xs text-wing-muted">
{filteredJobs.length}
</p>
)}
</div>
{/* Job List */}
{filteredJobs.length === 0 ? (
<div className="bg-wing-surface rounded-xl shadow-md p-6">
<EmptyState
icon="🔍"
message={searchTerm || statusFilter !== 'ALL' ? '검색 결과가 없습니다.' : '등록된 작업이 없습니다.'}
sub={searchTerm || statusFilter !== 'ALL' ? '다른 검색어나 필터를 사용해 보세요.' : undefined}
/>
</div>
) : viewMode === 'card' ? (
/* Card View */
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredJobs.map((job) => {
const isRunning = job.lastExecution?.status === 'STARTED';
const duration = job.lastExecution
? calculateDuration(job.lastExecution.startTime, job.lastExecution.endTime)
: null;
const showDuration =
job.lastExecution?.endTime != null && duration !== null && duration !== '-';
return (
<div
key={job.jobName}
className={`bg-wing-surface rounded-xl shadow-md p-6
hover:shadow-lg hover:-translate-y-0.5 transition-all
${isRunning ? 'border-l-4 border-emerald-500' : ''}`}
>
<div className="flex items-start justify-between mb-3">
<div>
<h3 className="text-sm font-semibold text-wing-text break-all leading-tight">
{getJobLabel(job)}
</h3>
</div>
<div className="flex items-center gap-2 ml-2 shrink-0">
{isRunning && (
<span className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" />
)}
{job.lastExecution && (
<StatusBadge status={job.lastExecution.status} />
)}
</div>
</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>
{showDuration && (
<p className="text-xs text-wing-muted">
: {duration}
</p>
)}
{isRunning && !showDuration && (
<p className="text-xs text-emerald-500">
시간: 실행 ...
</p>
)}
</>
) : (
<p className="text-wing-muted text-xs"> </p>
)}
<div className="flex items-center gap-2 pt-0.5">
{job.scheduleCron ? (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-semibold bg-emerald-500/15 text-emerald-500">
</span>
) : (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-wing-card text-wing-muted">
</span>
)}
{job.scheduleCron && (
<span className="font-mono text-xs bg-wing-card px-2 py-0.5 rounded text-wing-muted">
{job.scheduleCron}
</span>
)}
</div>
</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>
) : (
/* Table View */
<div className="bg-wing-surface rounded-xl shadow-md overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-wing-border bg-wing-card">
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">
</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">
</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">
</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">
</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">
</th>
<th className="text-right px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">
</th>
</tr>
</thead>
<tbody className="divide-y divide-wing-border">
{filteredJobs.map((job) => {
const isRunning = job.lastExecution?.status === 'STARTED';
const duration = job.lastExecution
? calculateDuration(job.lastExecution.startTime, job.lastExecution.endTime)
: '-';
return (
<tr
key={job.jobName}
className={`hover:bg-wing-hover transition-colors ${
isRunning ? 'border-l-4 border-emerald-500' : ''
}`}
>
<td className="px-4 py-3 font-medium text-wing-text break-all">
<div className="flex items-center gap-2">
{isRunning && (
<span className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse shrink-0" />
)}
<span>{getJobLabel(job)}</span>
</div>
</td>
<td className="px-4 py-3">
{job.lastExecution ? (
<StatusBadge status={job.lastExecution.status} />
) : (
<span className="text-xs text-wing-muted"></span>
)}
</td>
<td className="px-4 py-3 text-wing-muted">
{job.lastExecution
? formatDateTime(job.lastExecution.startTime)
: '-'}
</td>
<td className="px-4 py-3 text-wing-muted">
{job.lastExecution ? duration : '-'}
</td>
<td className="px-4 py-3">
{job.scheduleCron ? (
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-semibold bg-emerald-100 text-emerald-700">
</span>
) : (
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-semibold bg-wing-card text-wing-muted">
</span>
)}
</td>
<td className="px-4 py-3">
<div className="flex justify-end gap-2">
<button
onClick={() => handleExecuteClick(job.jobName)}
className="px-3 py-1.5 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="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>
</div>
)}
<GuideModal
open={guideOpen}
pageTitle="배치 작업 목록"
sections={JOBS_GUIDE}
onClose={() => setGuideOpen(false)}
/>
{/* 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">
&quot;{jobs.find((j) => j.jobName === targetJob)?.displayName || targetJob}&quot; ?
</p>
<div className="flex justify-end gap-3 mt-6">
<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>
);
}

파일 보기

@ -0,0 +1,775 @@
import { useState, useCallback, useEffect, useMemo } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import {
batchApi,
type RecollectionDetailResponse,
type StepExecutionDto,
type FailedRecordDto,
type JobDisplayName,
} 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';
import Pagination from '../components/Pagination';
import DetailStatCard from '../components/DetailStatCard';
import ApiLogSection from '../components/ApiLogSection';
import InfoItem from '../components/InfoItem';
import GuideModal, { HelpButton } from '../components/GuideModal';
const POLLING_INTERVAL_MS = 10_000;
function StepCard({ step, jobName, jobExecutionId }: { step: StepExecutionDto; jobName: string; jobExecutionId: number }) {
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-500/10 border border-blue-500/20 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-wing-surface 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-wing-surface 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-wing-surface 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-wing-surface 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-wing-surface 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-wing-surface 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.totalCalls > 0 && (
<ApiLogSection stepExecutionId={step.stepExecutionId} summary={summary} />
)}
</div>
)}
{/* 호출 실패 데이터 토글 */}
{step.failedRecords && step.failedRecords.length > 0 && (
<FailedRecordsToggle records={step.failedRecords} jobName={jobName} jobExecutionId={jobExecutionId} />
)}
{step.exitMessage && (
<div className="mt-4 rounded-lg bg-red-500/10 border border-red-500/20 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>
);
}
const RECOLLECT_DETAIL_GUIDE = [
{
title: '재수집 기본 정보',
content: '재수집 실행자, 실행일시, 소요 시간, 재수집 사유 등 기본 정보를 보여줍니다.\n재수집 기간(시작~종료)도 함께 확인할 수 있습니다.',
},
{
title: '처리 통계',
content: '재수집 처리 현황을 4개 카드로 요약합니다.\n• 읽기(Read): API에서 조회한 건수\n• 쓰기(Write): DB에 저장된 건수\n• 건너뜀(Skip): 변경 없어 건너뛴 건수\n• API 호출: 외부 API 총 호출 수',
},
{
title: '기간 중복 이력',
content: '동일 기간에 수행된 다른 수집/재수집 이력이 있으면 표시됩니다.\n중복 수집 여부를 확인하여 데이터 정합성을 검증할 수 있습니다.',
},
{
title: 'Step 실행 정보',
content: '배치 작업은 하나 이상의 Step으로 구성됩니다.\n각 Step의 상태, 처리 건수, 커밋/롤백 횟수를 확인할 수 있습니다.\nAPI 호출 정보에서는 총 호출 수, 성공/에러 수, 평균 응답 시간을 보여줍니다.',
},
{
title: 'API 호출 로그',
content: '각 Step에서 호출한 외부 API의 상세 로그를 확인할 수 있습니다.\n요청 URL, 응답 코드, 응답 시간 등을 페이지 단위로 조회합니다.',
},
{
title: '실패 건 관리',
content: '처리 중 실패한 레코드가 있으면 목록으로 표시됩니다.\n• 실패 건 재수집: 실패한 데이터를 다시 수집합니다\n• 일괄 RESOLVED: 모든 실패 건을 해결됨으로 처리합니다\n• 재시도 초기화: 재시도 횟수를 초기화하여 자동 재수집 대상에 포함시킵니다',
},
];
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 [displayNames, setDisplayNames] = useState<JobDisplayName[]>([]);
const [guideOpen, setGuideOpen] = useState(false);
useEffect(() => {
batchApi.getDisplayNames().then(setDisplayNames).catch(() => {});
}, []);
const displayNameMap = useMemo<Record<string, string>>(() => {
const map: Record<string, string> = {};
for (const dn of displayNames) {
if (dn.apiKey) map[dn.apiKey] = dn.displayName;
map[dn.jobName] = dn.displayName;
}
return map;
}, [displayNames]);
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>&larr;</span>
</button>
<EmptyState
icon="&#x26A0;"
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>&larr;</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>
<div className="flex items-center gap-2">
<h1 className="text-2xl font-bold text-wing-text">
#{history.historyId}
</h1>
<HelpButton onClick={() => setGuideOpen(true)} />
</div>
<p className="mt-1 text-sm text-wing-muted">
{displayNameMap[history.apiKey] || history.apiKeyName || history.apiKey} &middot; {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">
<DetailStatCard
label="읽기 (Read)"
value={history.readCount ?? 0}
gradient="bg-gradient-to-br from-blue-500 to-blue-600"
icon="&#x1F4E5;"
/>
<DetailStatCard
label="쓰기 (Write)"
value={history.writeCount ?? 0}
gradient="bg-gradient-to-br from-emerald-500 to-emerald-600"
icon="&#x1F4E4;"
/>
<DetailStatCard
label="건너뜀 (Skip)"
value={history.skipCount ?? 0}
gradient="bg-gradient-to-br from-amber-500 to-amber-600"
icon="&#x23ED;"
/>
<DetailStatCard
label="API 호출"
value={history.apiCallCount ?? 0}
gradient="bg-gradient-to-br from-purple-500 to-purple-600"
icon="&#x1F310;"
/>
</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-500/10 border border-red-500/20 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">
{displayNameMap[oh.apiKey] || 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} jobName={history.jobName} jobExecutionId={history.jobExecutionId ?? 0} />
))}
</div>
)}
</div>
<GuideModal
open={guideOpen}
pageTitle="재수집 상세"
sections={RECOLLECT_DETAIL_GUIDE}
onClose={() => setGuideOpen(false)}
/>
</div>
);
}
const FAILED_PAGE_SIZE = 10;
function FailedRecordsToggle({ records, jobName, jobExecutionId }: { records: FailedRecordDto[]; jobName: string; jobExecutionId: number }) {
const [open, setOpen] = useState(false);
const [showConfirm, setShowConfirm] = useState(false);
const [showResolveConfirm, setShowResolveConfirm] = useState(false);
const [retrying, setRetrying] = useState(false);
const [resolving, setResolving] = useState(false);
const [showResetConfirm, setShowResetConfirm] = useState(false);
const [resetting, setResetting] = useState(false);
const [page, setPage] = useState(0);
const navigate = useNavigate();
const failedRecords = records.filter((r) => r.status === 'FAILED');
const totalPages = Math.ceil(records.length / FAILED_PAGE_SIZE);
const pagedRecords = records.slice(page * FAILED_PAGE_SIZE, (page + 1) * FAILED_PAGE_SIZE);
const statusColor = (status: string) => {
switch (status) {
case 'RESOLVED': return 'text-emerald-600 bg-emerald-50';
case 'RETRY_PENDING': return 'text-amber-600 bg-amber-50';
default: return 'text-red-600 bg-red-50';
}
};
const MAX_RETRY_COUNT = 3;
const retryStatusLabel = (record: FailedRecordDto) => {
if (record.status !== 'FAILED') return null;
if (record.retryCount >= MAX_RETRY_COUNT) return { label: '재시도 초과', color: 'text-red-600 bg-red-100' };
if (record.retryCount > 0) return { label: `재시도 ${record.retryCount}/${MAX_RETRY_COUNT}`, color: 'text-amber-600 bg-amber-100' };
return { label: '대기', color: 'text-blue-600 bg-blue-100' };
};
const exceededRecords = failedRecords.filter((r) => r.retryCount >= MAX_RETRY_COUNT);
const handleRetry = async () => {
setRetrying(true);
try {
const result = await batchApi.retryFailedRecords(jobName, failedRecords.length, jobExecutionId);
if (result.success) {
setShowConfirm(false);
if (result.executionId) {
navigate(`/executions/${result.executionId}`);
} else {
alert(result.message || '재수집이 요청되었습니다.');
}
} else {
alert(result.message || '재수집 실행에 실패했습니다.');
}
} catch {
alert('재수집 실행에 실패했습니다.');
} finally {
setRetrying(false);
}
};
const handleResolve = async () => {
setResolving(true);
try {
const ids = failedRecords.map((r) => r.id);
await batchApi.resolveFailedRecords(ids);
setShowResolveConfirm(false);
navigate(0);
} catch {
alert('일괄 RESOLVED 처리에 실패했습니다.');
} finally {
setResolving(false);
}
};
const handleResetRetry = async () => {
setResetting(true);
try {
const ids = exceededRecords.map((r) => r.id);
await batchApi.resetRetryCount(ids);
setShowResetConfirm(false);
navigate(0);
} catch {
alert('재시도 초기화에 실패했습니다.');
} finally {
setResetting(false);
}
};
return (
<div className="mt-4 rounded-lg bg-red-500/10 border border-red-500/20 p-3">
<div className="flex items-center justify-between">
<button
onClick={() => setOpen((v) => !v)}
className="inline-flex items-center gap-1 text-xs font-medium text-red-600 hover:text-red-800 transition-colors"
>
<svg
className={`w-3 h-3 transition-transform ${open ? 'rotate-90' : ''}`}
fill="none" viewBox="0 0 24 24" stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
({records.length.toLocaleString()}, FAILED {failedRecords.length})
</button>
{failedRecords.length > 0 && (
<div className="flex items-center gap-1.5">
{exceededRecords.length > 0 && (
<button
onClick={() => setShowResetConfirm(true)}
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-medium text-amber-700 bg-amber-50 hover:bg-amber-100 border border-amber-200 rounded-md transition-colors"
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
({exceededRecords.length})
</button>
)}
<button
onClick={() => setShowResolveConfirm(true)}
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-medium text-emerald-700 bg-emerald-50 hover:bg-emerald-100 border border-emerald-200 rounded-md transition-colors"
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
RESOLVED ({failedRecords.length})
</button>
<button
onClick={() => setShowConfirm(true)}
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-medium text-white bg-red-500 hover:bg-red-600 rounded-md transition-colors"
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
({failedRecords.length})
</button>
</div>
)}
</div>
{open && (
<div className="mt-2">
<div className="overflow-x-auto">
<table className="w-full text-xs text-left">
<thead className="bg-red-500/20 text-red-700">
<tr>
<th className="px-2 py-1.5 font-medium">Record Key</th>
<th className="px-2 py-1.5 font-medium"> </th>
<th className="px-2 py-1.5 font-medium text-center"></th>
<th className="px-2 py-1.5 font-medium text-center"></th>
<th className="px-2 py-1.5 font-medium"> </th>
</tr>
</thead>
<tbody className="divide-y divide-red-500/20">
{pagedRecords.map((record) => (
<tr
key={record.id}
className="bg-wing-surface hover:bg-red-500/10"
>
<td className="px-2 py-1.5 font-mono text-red-900">
{record.recordKey}
</td>
<td className="px-2 py-1.5 text-red-600 max-w-[200px] truncate" title={record.errorMessage || ''}>
{record.errorMessage || '-'}
</td>
<td className="px-2 py-1.5 text-center">
{(() => {
const info = retryStatusLabel(record);
return info ? (
<span className={`inline-flex px-1.5 py-0.5 text-[10px] font-medium rounded-full ${info.color}`}>
{info.label}
</span>
) : (
<span className="text-wing-muted">-</span>
);
})()}
</td>
<td className="px-2 py-1.5 text-center">
<span className={`inline-flex px-1.5 py-0.5 text-[10px] font-medium rounded-full ${statusColor(record.status)}`}>
{record.status}
</span>
</td>
<td className="px-2 py-1.5 text-red-500 whitespace-nowrap">
{formatDateTime(record.createdAt)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<Pagination
page={page}
totalPages={totalPages}
totalElements={records.length}
pageSize={FAILED_PAGE_SIZE}
onPageChange={setPage}
/>
</div>
)}
{/* 재수집 확인 다이얼로그 */}
{showConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-wing-surface rounded-xl shadow-2xl p-6 w-full max-w-md mx-4">
<h3 className="text-lg font-semibold text-wing-text mb-2">
</h3>
<p className="text-sm text-wing-muted mb-3">
{failedRecords.length} IMO에 .
</p>
<div className="bg-wing-card rounded-lg p-3 mb-4 max-h-40 overflow-y-auto">
<div className="flex flex-wrap gap-1">
{failedRecords.map((r) => (
<span
key={r.id}
className="inline-flex px-2 py-0.5 text-xs font-mono bg-red-500/20 text-red-700 rounded"
>
{r.recordKey}
</span>
))}
</div>
</div>
<div className="flex justify-end gap-2">
<button
onClick={() => setShowConfirm(false)}
disabled={retrying}
className="px-4 py-2 text-sm font-medium text-wing-muted bg-wing-card hover:bg-wing-hover rounded-lg transition-colors disabled:opacity-50"
>
</button>
<button
onClick={handleRetry}
disabled={retrying}
className="px-4 py-2 text-sm font-medium text-white bg-red-500 hover:bg-red-600 rounded-lg transition-colors disabled:opacity-50 inline-flex items-center gap-1"
>
{retrying ? (
<>
<div className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-white border-t-transparent" />
...
</>
) : (
'재수집 실행'
)}
</button>
</div>
</div>
</div>
)}
{/* 일괄 RESOLVED 확인 다이얼로그 */}
{showResolveConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-wing-surface rounded-xl shadow-2xl p-6 w-full max-w-md mx-4">
<h3 className="text-lg font-semibold text-wing-text mb-2">
RESOLVED
</h3>
<p className="text-sm text-wing-muted mb-4">
FAILED {failedRecords.length} RESOLVED로 .
.
</p>
<div className="flex justify-end gap-2">
<button
onClick={() => setShowResolveConfirm(false)}
disabled={resolving}
className="px-4 py-2 text-sm font-medium text-wing-muted bg-wing-card hover:bg-wing-hover rounded-lg transition-colors disabled:opacity-50"
>
</button>
<button
onClick={handleResolve}
disabled={resolving}
className="px-4 py-2 text-sm font-medium text-white bg-emerald-500 hover:bg-emerald-600 rounded-lg transition-colors disabled:opacity-50 inline-flex items-center gap-1"
>
{resolving ? (
<>
<div className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-white border-t-transparent" />
...
</>
) : (
'RESOLVED 처리'
)}
</button>
</div>
</div>
</div>
)}
{/* 재시도 초기화 확인 다이얼로그 */}
{showResetConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-wing-surface rounded-xl shadow-2xl p-6 w-full max-w-md mx-4">
<h3 className="text-lg font-semibold text-wing-text mb-2">
</h3>
<p className="text-sm text-wing-muted mb-4">
{exceededRecords.length} retryCount를 0 .
.
</p>
<div className="flex justify-end gap-2">
<button
onClick={() => setShowResetConfirm(false)}
disabled={resetting}
className="px-4 py-2 text-sm font-medium text-wing-muted bg-wing-card hover:bg-wing-hover rounded-lg transition-colors disabled:opacity-50"
>
</button>
<button
onClick={handleResetRetry}
disabled={resetting}
className="px-4 py-2 text-sm font-medium text-white bg-amber-500 hover:bg-amber-600 rounded-lg transition-colors disabled:opacity-50 inline-flex items-center gap-1"
>
{resetting ? (
<>
<div className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-white border-t-transparent" />
...
</>
) : (
'초기화 실행'
)}
</button>
</div>
</div>
</div>
)}
</div>
);
}

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다. Load Diff

파일 보기

@ -0,0 +1,841 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { batchApi, type ScheduleResponse, type JobDisplayName } 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';
import GuideModal, { HelpButton } from '../components/GuideModal';
type ScheduleMode = 'new' | 'existing';
type ScheduleViewMode = 'card' | 'table';
type ActiveFilterKey = 'ALL' | 'ACTIVE' | 'INACTIVE';
type ScheduleSortKey = 'name' | 'nextFire' | 'active';
interface ActiveTabConfig {
key: ActiveFilterKey;
label: string;
}
const ACTIVE_TABS: ActiveTabConfig[] = [
{ key: 'ALL', label: '전체' },
{ key: 'ACTIVE', label: '활성' },
{ key: 'INACTIVE', label: '비활성' },
];
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';
}
}
const SCHEDULES_GUIDE = [
{
title: '스케줄이란?',
content: '스케줄은 배치 작업을 자동으로 실행하는 설정입니다.\nCron 표현식으로 실행 주기를 지정하면 해당 시간에 자동 실행됩니다.\n활성화된 스케줄만 자동 실행되며, 비활성화하면 일시 중지됩니다.',
},
{
title: '스케줄 등록/수정',
content: '"+ 새 스케줄" 버튼 또는 기존 스케줄의 "편집" 버튼을 클릭하면 설정 팝업이 열립니다.\n1. 작업 선택: 자동 실행할 배치 작업을 선택합니다\n2. Cron 표현식: 실행 주기를 설정합니다 (프리셋 버튼으로 간편 설정 가능)\n3. 설명: 스케줄에 대한 메모를 입력합니다 (선택)\n\n"다음 5회 실행 예정" 미리보기로 설정이 올바른지 확인하세요.',
},
{
title: 'Cron 표현식',
content: 'Cron 표현식은 "초 분 시 일 월 요일" 6자리로 구성됩니다.\n예시:\n• 0 0/15 * * * ? → 매 15분마다\n• 0 0 0 * * ? → 매일 자정\n• 0 0 12 * * ? → 매일 정오\n• 0 0 0 ? * MON → 매주 월요일 자정\n\n프리셋 버튼을 활용하면 직접 입력하지 않아도 됩니다.',
},
{
title: '스케줄 관리',
content: '• 편집: 스케줄 설정(Cron, 설명)을 수정합니다\n• 활성화/비활성화: 자동 실행을 켜거나 끕니다\n• 삭제: 스케줄을 완전히 제거합니다\n\n상태 표시:\n• 활성 (초록): 정상 동작 중\n• 비활성 (회색): 일시 중지 상태\n• NORMAL: 트리거 정상\n• PAUSED: 트리거 일시 중지\n• BLOCKED: 이전 실행이 아직 진행 중\n• ERROR: 트리거 오류 발생',
},
];
export default function Schedules() {
const { showToast } = useToastContext();
// Guide modal state
const [guideOpen, setGuideOpen] = useState(false);
// 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);
const [displayNames, setDisplayNames] = useState<JobDisplayName[]>([]);
// View mode state
const [viewMode, setViewMode] = useState<ScheduleViewMode>('table');
// Search / filter / sort state
const [searchTerm, setSearchTerm] = useState('');
const [activeFilter, setActiveFilter] = useState<ActiveFilterKey>('ALL');
const [sortKey, setSortKey] = useState<ScheduleSortKey>('name');
// Confirm modal state
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
// Form modal state
const [formOpen, setFormOpen] = useState(false);
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();
batchApi.getDisplayNames().then(setDisplayNames).catch(() => {});
}, [loadJobs, loadSchedules]);
const displayNameMap = useMemo<Record<string, string>>(() => {
const map: Record<string, string> = {};
for (const dn of displayNames) {
map[dn.jobName] = dn.displayName;
}
return map;
}, [displayNames]);
const activeCounts = useMemo(() => {
const searchFiltered = searchTerm.trim()
? schedules.filter((s) => {
const term = searchTerm.toLowerCase();
return s.jobName.toLowerCase().includes(term)
|| (displayNameMap[s.jobName]?.toLowerCase().includes(term) ?? false)
|| (s.description?.toLowerCase().includes(term) ?? false);
})
: schedules;
return ACTIVE_TABS.reduce<Record<ActiveFilterKey, number>>(
(acc, tab) => {
acc[tab.key] = searchFiltered.filter((s) => {
if (tab.key === 'ALL') return true;
if (tab.key === 'ACTIVE') return s.active;
return !s.active;
}).length;
return acc;
},
{ ALL: 0, ACTIVE: 0, INACTIVE: 0 },
);
}, [schedules, searchTerm, displayNameMap]);
const filteredSchedules = useMemo(() => {
let result = schedules;
// 검색 필터
if (searchTerm.trim()) {
const term = searchTerm.toLowerCase();
result = result.filter((s) =>
s.jobName.toLowerCase().includes(term)
|| (displayNameMap[s.jobName]?.toLowerCase().includes(term) ?? false)
|| (s.description?.toLowerCase().includes(term) ?? false),
);
}
// 활성/비활성 필터
if (activeFilter === 'ACTIVE') {
result = result.filter((s) => s.active);
} else if (activeFilter === 'INACTIVE') {
result = result.filter((s) => !s.active);
}
// 정렬
result = [...result].sort((a, b) => {
if (sortKey === 'name') {
const aName = displayNameMap[a.jobName] || a.jobName;
const bName = displayNameMap[b.jobName] || b.jobName;
return aName.localeCompare(bName);
}
if (sortKey === 'nextFire') {
const aTime = a.nextFireTime ? new Date(a.nextFireTime).getTime() : Number.MAX_SAFE_INTEGER;
const bTime = b.nextFireTime ? new Date(b.nextFireTime).getTime() : Number.MAX_SAFE_INTEGER;
return aTime - bTime;
}
if (sortKey === 'active') {
if (a.active === b.active) {
const aName = displayNameMap[a.jobName] || a.jobName;
const bName = displayNameMap[b.jobName] || b.jobName;
return aName.localeCompare(bName);
}
return a.active ? -1 : 1;
}
return 0;
});
return result;
}, [schedules, searchTerm, activeFilter, sortKey, displayNameMap]);
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();
setFormOpen(false);
resetForm();
} 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();
// Close form if deleted schedule was being edited
if (selectedJob === schedule.jobName) {
resetForm();
setFormOpen(false);
}
} catch (err) {
const message = err instanceof Error ? err.message : '삭제 실패';
showToast(message, 'error');
}
setConfirmAction(null);
};
const resetForm = () => {
setSelectedJob('');
setCronExpression('');
setDescription('');
setScheduleMode('new');
};
const handleEditFromCard = (schedule: ScheduleResponse) => {
setSelectedJob(schedule.jobName);
setCronExpression(schedule.cronExpression);
setDescription(schedule.description ?? '');
setScheduleMode('existing');
setFormOpen(true);
};
const handleNewSchedule = () => {
resetForm();
setFormOpen(true);
};
const getScheduleLabel = (schedule: ScheduleResponse) =>
displayNameMap[schedule.jobName] || schedule.jobName;
if (listLoading) return <LoadingSpinner />;
return (
<div className="space-y-6">
{/* Form Modal */}
{formOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/40" onClick={() => setFormOpen(false)} />
<div className="relative bg-wing-surface rounded-xl shadow-2xl p-6 w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-bold text-wing-text">
{scheduleMode === 'existing' ? '스케줄 수정' : '스케줄 등록'}
</h2>
<button
onClick={() => setFormOpen(false)}
className="p-1 text-wing-muted hover:text-wing-text transition-colors"
>
<svg xmlns="http://www.w3.org/2000/svg" className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="space-y-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 || scheduleMode === 'existing'}
>
<option value="">-- --</option>
{jobs.map((job) => (
<option key={job} value={job}>
{displayNameMap[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>
<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>
<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>
{/* Modal Footer */}
<div className="mt-6 flex justify-end gap-2">
<button
onClick={() => setFormOpen(false)}
className="px-4 py-2 text-sm font-medium text-wing-muted bg-wing-card rounded-lg hover:bg-wing-hover transition-colors"
>
</button>
<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>
</div>
)}
{/* Header */}
<div className="flex items-center justify-between flex-wrap gap-3">
<div className="flex items-center gap-2">
<h1 className="text-2xl font-bold text-wing-text"> </h1>
<HelpButton onClick={() => setGuideOpen(true)} />
</div>
<div className="flex items-center gap-2">
<button
onClick={handleNewSchedule}
className="px-3 py-1.5 text-sm font-medium text-white bg-wing-accent rounded-lg hover:bg-wing-accent/80 transition-colors"
>
+
</button>
<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>
<span className="text-sm text-wing-muted">
{schedules.length}
</span>
</div>
</div>
{/* Active Filter Tabs */}
<div className="flex flex-wrap gap-2">
{ACTIVE_TABS.map((tab) => (
<button
key={tab.key}
onClick={() => setActiveFilter(tab.key)}
className={`inline-flex items-center gap-1.5 px-4 py-1.5 rounded-full text-sm font-medium transition-colors ${
activeFilter === tab.key
? 'bg-wing-accent text-white'
: 'bg-wing-card text-wing-muted hover:text-wing-text'
}`}
>
{tab.label}
<span
className={`inline-flex items-center justify-center min-w-[1.25rem] h-5 px-1 rounded-full text-xs font-semibold ${
activeFilter === tab.key
? 'bg-white/25 text-white'
: 'bg-wing-border text-wing-muted'
}`}
>
{activeCounts[tab.key]}
</span>
</button>
))}
</div>
{/* Search + Sort + View Toggle */}
<div className="bg-wing-surface rounded-xl shadow-md p-4">
<div className="flex gap-3 items-center flex-wrap">
{/* Search */}
<div className="relative flex-1 min-w-[200px]">
<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>
{/* Sort dropdown */}
<select
value={sortKey}
onChange={(e) => setSortKey(e.target.value as ScheduleSortKey)}
className="px-3 py-2 border border-wing-border rounded-lg text-sm bg-wing-surface text-wing-text
focus:ring-2 focus:ring-wing-accent focus:border-wing-accent outline-none"
>
<option value="name"></option>
<option value="nextFire"> </option>
<option value="active"> </option>
</select>
{/* View mode toggle */}
<div className="flex rounded-lg border border-wing-border overflow-hidden">
<button
onClick={() => setViewMode('table')}
title="테이블 보기"
className={`px-3 py-2 transition-colors ${
viewMode === 'table'
? 'bg-wing-accent text-white'
: 'bg-wing-surface 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="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<button
onClick={() => setViewMode('card')}
title="카드 보기"
className={`px-3 py-2 transition-colors border-l border-wing-border ${
viewMode === 'card'
? 'bg-wing-accent text-white'
: 'bg-wing-surface 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="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"
/>
</svg>
</button>
</div>
</div>
{searchTerm && (
<p className="mt-2 text-xs text-wing-muted">
{filteredSchedules.length}
</p>
)}
</div>
{/* Schedule List */}
{filteredSchedules.length === 0 ? (
<div className="bg-wing-surface rounded-xl shadow-md p-6">
<EmptyState
icon="🔍"
message={searchTerm || activeFilter !== 'ALL' ? '검색 결과가 없습니다.' : '등록된 스케줄이 없습니다.'}
sub={searchTerm || activeFilter !== 'ALL' ? '다른 검색어나 필터를 사용해 보세요.' : "'+ 새 스케줄' 버튼을 클릭하여 등록하세요"}
/>
</div>
) : viewMode === 'card' ? (
/* Card View */
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredSchedules.map((schedule) => (
<div
key={schedule.id}
className={`bg-wing-surface rounded-xl shadow-md p-6
hover:shadow-lg hover:-translate-y-0.5 transition-all
${!schedule.active ? 'opacity-60' : ''}`}
>
<div className="flex items-start justify-between mb-3">
<div>
<h3 className="text-sm font-semibold text-wing-text break-all leading-tight">
{getScheduleLabel(schedule)}
</h3>
</div>
<div className="flex items-center gap-2 ml-2 shrink-0">
<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>
{/* Detail Info */}
<div className="mb-4 space-y-1">
<div className="flex items-center gap-2">
<span className="font-mono text-xs bg-wing-card px-2 py-0.5 rounded text-wing-muted">
{schedule.cronExpression}
</span>
</div>
<p className="text-xs text-wing-muted">
: {formatDateTime(schedule.nextFireTime)}
</p>
{schedule.previousFireTime && (
<p className="text-xs text-wing-muted">
: {formatDateTime(schedule.previousFireTime)}
</p>
)}
</div>
{/* Action Buttons */}
<div className="flex gap-2">
<button
onClick={() => handleEditFromCard(schedule)}
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>
<button
onClick={() => setConfirmAction({ type: 'toggle', schedule })}
className={`flex-1 px-3 py-2 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-2 text-xs font-medium text-red-700 bg-red-50 rounded-lg
hover:bg-red-100 transition-colors"
>
</button>
</div>
</div>
))}
</div>
) : (
/* Table View */
<div className="bg-wing-surface rounded-xl shadow-md overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-wing-border bg-wing-card">
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider"></th>
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">Cron </th>
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider"></th>
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider"> </th>
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider"> </th>
<th className="text-right px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider"></th>
</tr>
</thead>
<tbody className="divide-y divide-wing-border">
{filteredSchedules.map((schedule) => (
<tr
key={schedule.id}
className={`hover:bg-wing-hover transition-colors ${!schedule.active ? 'opacity-60' : ''}`}
>
<td className="px-4 py-3 font-medium text-wing-text break-all">
{getScheduleLabel(schedule)}
</td>
<td className="px-4 py-3">
<span className="font-mono text-xs bg-wing-card px-2 py-0.5 rounded">{schedule.cronExpression}</span>
</td>
<td className="px-4 py-3">
<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>
</td>
<td className="px-4 py-3 text-wing-muted">{formatDateTime(schedule.nextFireTime)}</td>
<td className="px-4 py-3 text-wing-muted">{schedule.previousFireTime ? formatDateTime(schedule.previousFireTime) : '-'}</td>
<td className="px-4 py-3">
<div className="flex justify-end gap-2">
<button
onClick={() => handleEditFromCard(schedule)}
className="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>
<button
onClick={() => setConfirmAction({ type: 'toggle', schedule })}
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${
schedule.active
? 'text-amber-600 bg-amber-50 hover:bg-amber-100'
: 'text-emerald-600 bg-emerald-50 hover:bg-emerald-100'
}`}
>
{schedule.active ? '비활성화' : '활성화'}
</button>
<button
onClick={() => setConfirmAction({ type: 'delete', schedule })}
className="px-3 py-1.5 text-xs font-medium text-red-600 bg-red-50 rounded-lg
hover:bg-red-100 transition-colors"
>
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</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)}
/>
)}
<GuideModal
open={guideOpen}
onClose={() => setGuideOpen(false)}
pageTitle="스케줄 관리"
sections={SCHEDULES_GUIDE}
/>
</div>
);
}

파일 보기

@ -0,0 +1,513 @@
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
import { Link } from 'react-router-dom';
import { batchApi, type ExecutionInfo, type JobDisplayName, 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';
import GuideModal, { HelpButton } from '../components/GuideModal';
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';
}
const TIMELINE_GUIDE = [
{
title: '타임라인이란?',
content: '타임라인은 배치 작업의 실행 스케줄과 결과를 시각적으로 보여주는 화면입니다.\n세로축은 작업 목록, 가로축은 시간대를 나타냅니다.\n각 셀의 색상으로 실행 상태를 한눈에 파악할 수 있습니다.',
},
{
title: '보기 모드',
content: '3가지 보기 모드를 제공합니다.\n• Day: 하루 단위 (시간대별 상세 보기)\n• Week: 일주일 단위\n• Month: 한 달 단위\n\n이전/다음 버튼으로 기간을 이동하고, "오늘" 버튼으로 현재 날짜로 돌아옵니다.',
},
{
title: '색상 범례',
content: '각 셀의 색상은 실행 상태를 나타냅니다.\n• 초록색: 완료 (성공적으로 실행됨)\n• 빨간색: 실패 (오류 발생)\n• 파란색: 실행 중 (현재 진행 중)\n• 보라색: 예정 (아직 실행 전)\n• 회색: 없음 (해당 시간대에 실행 기록 없음)',
},
{
title: '상세 보기',
content: '셀 위에 마우스를 올리면 툴팁으로 작업명, 기간, 상태 등 요약 정보를 보여줍니다.\n셀을 클릭하면 하단에 상세 패널이 열리며, 해당 시간대의 실행 이력 목록을 확인할 수 있습니다.\n"상세" 링크를 클릭하면 실행 상세 화면으로 이동합니다.',
},
];
export default function Timeline() {
const { showToast } = useToastContext();
// Guide modal state
const [guideOpen, setGuideOpen] = useState(false);
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);
const [displayNames, setDisplayNames] = useState<JobDisplayName[]>([]);
useEffect(() => {
batchApi.getDisplayNames().then(setDisplayNames).catch(() => {});
}, []);
const displayNameMap = useMemo<Record<string, string>>(() => {
const map: Record<string, string> = {};
for (const dn of displayNames) {
map[dn.jobName] = dn.displayName;
}
return map;
}, [displayNames]);
// 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"
>
&larr;
</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"
>
&rarr;
</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>
{/* Help */}
<HelpButton onClick={() => setGuideOpen(true)} />
</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={displayNameMap[schedule.jobName] || schedule.jobName}
>
{displayNameMap[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">{displayNameMap[tooltip.jobName] || 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">
{displayNameMap[selectedCell.jobName] || 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">
<Link
to={`/executions/${exec.executionId}`}
className="text-xs text-wing-accent hover:text-wing-accent font-medium no-underline"
>
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
<GuideModal
open={guideOpen}
onClose={() => setGuideOpen(false)}
pageTitle="타임라인"
sections={TIMELINE_GUIDE}
/>
</div>
);
}

101
frontend/src/theme/base.css Normal file
파일 보기

@ -0,0 +1,101 @@
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);
}
/* Main Menu Cards */
.gc-cards {
padding: 2rem 0;
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-auto-rows: 1fr;
gap: 2rem;
width: 80%;
margin: 0 auto;
}
@media (max-width: 768px) {
.gc-cards {
grid-template-columns: 1fr;
width: 90%;
}
}
.gc-card {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: 2.5rem 2rem;
border: 1px solid var(--wing-border);
border-radius: 12px;
background: var(--wing-surface);
text-decoration: none !important;
color: inherit !important;
transition: all 0.2s ease;
height: 100%;
}
.gc-card:hover {
border-color: #4183c4;
box-shadow: 0 4px 16px rgba(65, 131, 196, 0.15);
transform: translateY(-2px);
}
.gc-card-icon {
color: #4183c4;
margin-bottom: 1rem;
}
.gc-card-icon-guide {
color: #21ba45;
}
.gc-card-icon-nexus {
color: #f2711c;
}
.gc-card h3 {
font-size: 1.3rem;
margin-bottom: 0.5rem;
color: var(--wing-text);
}
.gc-card p {
font-size: 0.95rem;
color: var(--wing-muted);
line-height: 1.5;
margin-bottom: 1rem;
}
.gc-card-link {
font-size: 0.9rem;
color: #4183c4;
font-weight: 600;
margin-top: auto;
}
.gc-card:hover .gc-card-link {
text-decoration: underline;
}

파일 보기

@ -0,0 +1,84 @@
/* 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;
--wing-rag-red-bg: rgba(127, 29, 29, 0.15);
--wing-rag-red-text: #fca5a5;
--wing-rag-amber-bg: rgba(120, 53, 15, 0.15);
--wing-rag-amber-text: #fcd34d;
--wing-rag-green-bg: rgba(5, 46, 22, 0.15);
--wing-rag-green-text: #86efac;
}
/* 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;
--wing-rag-red-bg: #fef2f2;
--wing-rag-red-text: #b91c1c;
--wing-rag-amber-bg: #fffbeb;
--wing-rag-amber-text: #b45309;
--wing-rag-green-bg: #f0fdf4;
--wing-rag-green-text: #15803d;
}
@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);
--color-wing-rag-red-bg: var(--wing-rag-red-bg);
--color-wing-rag-red-text: var(--wing-rag-red-text);
--color-wing-rag-amber-bg: var(--wing-rag-amber-bg);
--color-wing-rag-amber-text: var(--wing-rag-amber-text);
--color-wing-rag-green-bg: var(--wing-rag-green-bg);
--color-wing-rag-green-text: var(--wing-rag-green-text);
--font-sans: 'Noto Sans KR', sans-serif;
}

파일 보기

@ -0,0 +1,154 @@
/**
* 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);
if (step <= 0) return range(min, max);
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;
}

파일 보기

@ -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);
}

파일 보기

@ -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
파일 보기

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

파일 보기

@ -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
파일 보기

@ -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-collector/api': {
target: 'http://localhost:8041',
changeOrigin: true,
},
},
},
base: '/snp-collector/',
build: {
outDir: '../src/main/resources/static',
emptyOutDir: true,
},
})

6
package-lock.json generated Normal file
파일 보기

@ -0,0 +1,6 @@
{
"name": "snp-batch-validation",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

206
pom.xml Normal file
파일 보기

@ -0,0 +1,206 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.1</version>
<relativePath/>
</parent>
<groupId>com.snp</groupId>
<artifactId>snp-collector</artifactId>
<version>1.0.0</version>
<name>SNP Collector</name>
<description>S&amp;P Collector - 해양 데이터 수집 배치 시스템</description>
<properties>
<java.version>17</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<!-- Dependency versions -->
<spring-boot.version>3.2.1</spring-boot.version>
<spring-batch.version>5.1.0</spring-batch.version>
<postgresql.version>42.7.6</postgresql.version>
<lombok.version>1.18.30</lombok.version>
<quartz.version>2.5.0</quartz.version>
</properties>
<dependencies>
<!-- Spring Boot Starter Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Spring Boot Starter Batch -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-batch</artifactId>
</dependency>
<!-- Spring Boot Starter Data JPA -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- PostgreSQL Driver -->
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>${postgresql.version}</version>
</dependency>
<!-- Spring Boot Starter Thymeleaf (for Web GUI) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<!-- Spring Boot Starter Quartz (for Job Scheduling) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-quartz</artifactId>
</dependency>
<!-- Jackson for JSON processing -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<!-- Lombok for reducing boilerplate code -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
<optional>true</optional>
</dependency>
<!-- Spring Boot DevTools -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>
<!-- Spring Boot Actuator for monitoring -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- WebClient for REST API calls -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<!-- Springdoc OpenAPI (Swagger) for API Documentation -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.3.0</version>
</dependency>
<!-- Caffeine Cache -->
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
<version>3.1.8</version>
</dependency>
<!-- Test Dependencies -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.batch</groupId>
<artifactId>spring-batch-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.google.code.findbugs</groupId>
<artifactId>jsr305</artifactId>
<version>3.0.2</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>${spring-boot.version}</version>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</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>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
<configuration>
<source>17</source>
<target>17</target>
<encoding>UTF-8</encoding>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</build>
</project>

파일 보기

@ -0,0 +1,16 @@
package com.snp.batch;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@EnableScheduling
@ConfigurationPropertiesScan
public class SnpCollectorApplication {
public static void main(String[] args) {
SpringApplication.run(SnpCollectorApplication.class, args);
}
}

파일 보기

@ -0,0 +1,149 @@
package com.snp.batch.api.logging;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import org.springframework.web.util.ContentCachingRequestWrapper;
import org.springframework.web.util.ContentCachingResponseWrapper;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.UUID;
/**
* API 요청/응답 로깅 필터
*
* 로그 파일: logs/api-access.log
* 기록 내용: 요청 IP, HTTP Method, URI, 파라미터, 응답 상태, 처리 시간
*/
@Slf4j
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class ApiAccessLoggingFilter extends OncePerRequestFilter {
private static final int MAX_PAYLOAD_LENGTH = 1000;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
// 정적 리소스 actuator 제외
String uri = request.getRequestURI();
if (shouldSkip(uri)) {
filterChain.doFilter(request, response);
return;
}
// 요청 래핑 (body 읽기용)
ContentCachingRequestWrapper requestWrapper = new ContentCachingRequestWrapper(request);
ContentCachingResponseWrapper responseWrapper = new ContentCachingResponseWrapper(response);
String requestId = UUID.randomUUID().toString().substring(0, 8);
long startTime = System.currentTimeMillis();
try {
filterChain.doFilter(requestWrapper, responseWrapper);
} finally {
long duration = System.currentTimeMillis() - startTime;
logRequest(requestId, requestWrapper, responseWrapper, duration);
responseWrapper.copyBodyToResponse();
}
}
private boolean shouldSkip(String uri) {
return uri.startsWith("/actuator")
|| uri.startsWith("/css")
|| uri.startsWith("/js")
|| uri.startsWith("/images")
|| uri.startsWith("/favicon")
|| uri.endsWith(".html")
|| uri.endsWith(".css")
|| uri.endsWith(".js")
|| uri.endsWith(".ico");
}
private void logRequest(String requestId,
ContentCachingRequestWrapper request,
ContentCachingResponseWrapper response,
long duration) {
String clientIp = getClientIp(request);
String method = request.getMethod();
String uri = request.getRequestURI();
String queryString = request.getQueryString();
int status = response.getStatus();
StringBuilder logMessage = new StringBuilder();
logMessage.append(String.format("[%s] %s %s %s",
requestId, clientIp, method, uri));
// Query String
if (queryString != null && !queryString.isEmpty()) {
logMessage.append("?").append(truncate(queryString, 200));
}
// Request Body (POST/PUT/PATCH)
if (isBodyRequest(method)) {
String body = getRequestBody(request);
if (!body.isEmpty()) {
logMessage.append(" | body=").append(truncate(body, MAX_PAYLOAD_LENGTH));
}
}
// Response
logMessage.append(String.format(" | status=%d | %dms", status, duration));
// 상태에 따른 로그 레벨
if (status >= 500) {
log.error(logMessage.toString());
} else if (status >= 400) {
log.warn(logMessage.toString());
} else {
log.info(logMessage.toString());
}
}
private String getClientIp(HttpServletRequest request) {
String ip = request.getHeader("X-Forwarded-For");
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("X-Real-IP");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getRemoteAddr();
}
// 여러 IP가 있는 경우 번째만
if (ip != null && ip.contains(",")) {
ip = ip.split(",")[0].trim();
}
return ip;
}
private boolean isBodyRequest(String method) {
return "POST".equalsIgnoreCase(method)
|| "PUT".equalsIgnoreCase(method)
|| "PATCH".equalsIgnoreCase(method);
}
private String getRequestBody(ContentCachingRequestWrapper request) {
byte[] content = request.getContentAsByteArray();
if (content.length == 0) {
return "";
}
return new String(content, StandardCharsets.UTF_8)
.replaceAll("\\s+", " ")
.trim();
}
private String truncate(String str, int maxLength) {
if (str == null) return "";
if (str.length() <= maxLength) return str;
return str.substring(0, maxLength) + "...";
}
}

파일 보기

@ -0,0 +1,138 @@
package com.snp.batch.common.batch.config;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.core.step.builder.StepBuilder;
import org.springframework.batch.item.ItemProcessor;
import org.springframework.batch.item.ItemReader;
import org.springframework.batch.item.ItemWriter;
import org.springframework.transaction.PlatformTransactionManager;
/**
* Batch Job 설정을 위한 추상 클래스
* Reader Processor Writer 패턴의 표준 Job 구성 제공
*
* @param <I> 입력 타입 (Reader 출력, Processor 입력)
* @param <O> 출력 타입 (Processor 출력, Writer 입력)
*/
@Slf4j
@RequiredArgsConstructor
public abstract class BaseJobConfig<I, O> {
protected final JobRepository jobRepository;
protected final PlatformTransactionManager transactionManager;
/**
* Job 이름 반환 (하위 클래스에서 구현)
* : "shipDataImportJob"
*/
protected abstract String getJobName();
/**
* Step 이름 반환 (선택사항, 기본: {jobName}Step)
*/
protected String getStepName() {
return getJobName() + "Step";
}
/**
* Reader 생성 (하위 클래스에서 구현)
*/
protected abstract ItemReader<I> createReader();
/**
* Processor 생성 (하위 클래스에서 구현)
* 처리 로직이 없는 경우 null 반환 가능
*/
protected abstract ItemProcessor<I, O> createProcessor();
/**
* Writer 생성 (하위 클래스에서 구현)
*/
protected abstract ItemWriter<O> createWriter();
/**
* Chunk 크기 반환 (선택사항, 기본: 100)
*/
protected int getChunkSize() {
return 100;
}
/**
* Job 시작 실행 (선택사항)
* Job Listener 등록 사용
*/
protected void configureJob(JobBuilder jobBuilder) {
// 기본 구현: 아무것도 하지 않음
// 하위 클래스에서 필요시 오버라이드
// : jobBuilder.listener(jobExecutionListener())
}
/**
* Step 커스터마이징 (선택사항)
* Step Listener, FaultTolerant 설정 사용
*/
protected void configureStep(StepBuilder stepBuilder) {
// 기본 구현: 아무것도 하지 않음
// 하위 클래스에서 필요시 오버라이드
// : stepBuilder.listener(stepExecutionListener())
// stepBuilder.faultTolerant().skip(Exception.class).skipLimit(10)
}
/**
* Step 생성 (표준 구현 제공)
*/
public Step step() {
log.info("Step 생성: {}", getStepName());
ItemProcessor<I, O> processor = createProcessor();
StepBuilder stepBuilder = new StepBuilder(getStepName(), jobRepository);
// Processor가 있는 경우
if (processor != null) {
var chunkBuilder = stepBuilder
.<I, O>chunk(getChunkSize(), transactionManager)
.reader(createReader())
.processor(processor)
.writer(createWriter());
// 커스텀 설정 적용
configureStep(stepBuilder);
return chunkBuilder.build();
}
// Processor가 없는 경우 (I == O 타입 가정)
else {
@SuppressWarnings("unchecked")
var chunkBuilder = stepBuilder
.<I, I>chunk(getChunkSize(), transactionManager)
.reader(createReader())
.writer((ItemWriter<? super I>) createWriter());
// 커스텀 설정 적용
configureStep(stepBuilder);
return chunkBuilder.build();
}
}
/**
* Job 생성 (표준 구현 제공)
*/
public Job job() {
log.info("Job 생성: {}", getJobName());
JobBuilder jobBuilder = new JobBuilder(getJobName(), jobRepository);
// 커스텀 설정 적용
configureJob(jobBuilder);
return jobBuilder
.start(step())
.build();
}
}

파일 보기

@ -0,0 +1,44 @@
package com.snp.batch.common.batch.config;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.item.ItemProcessor;
import org.springframework.batch.item.ItemReader;
import org.springframework.batch.item.ItemWriter;
import org.springframework.transaction.PlatformTransactionManager;
/**
* 기존 단일 스텝 기능을 유지하면서 멀티 스텝 구성을 지원하는 확장 클래스
*/
public abstract class BaseMultiStepJobConfig<I, O> extends BaseJobConfig<I, O> {
public BaseMultiStepJobConfig(JobRepository jobRepository, PlatformTransactionManager transactionManager) {
super(jobRepository, transactionManager);
}
/**
* 하위 클래스에서 멀티 스텝 흐름을 정의합니다.
*/
protected abstract Job createJobFlow(JobBuilder jobBuilder);
/**
* 부모의 job() 메서드를 오버라이드하여 멀티 스텝 흐름을 태웁니다.
*/
@Override
public Job job() {
JobBuilder jobBuilder = new JobBuilder(getJobName(), jobRepository);
configureJob(jobBuilder); // 기존 리스너 설정 유지
return createJobFlow(jobBuilder);
}
// 단일 스텝용 Reader/Processor/Writer는 사용하지 않을 경우
// 기본적으로 null이나 예외를 던지도록 구현하여 구현 부담을 줄일 있습니다.
@Override
protected ItemReader<I> createReader() { return null; }
@Override
protected ItemProcessor<I, O> createProcessor() { return null; }
@Override
protected ItemWriter<O> createWriter() { return null; }
}

파일 보기

@ -0,0 +1,82 @@
package com.snp.batch.common.batch.config;
import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.job.flow.FlowExecutionStatus;
import org.springframework.batch.core.job.flow.JobExecutionDecider;
import org.springframework.batch.core.partition.support.Partitioner;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.core.step.builder.StepBuilder;
import org.springframework.batch.core.step.tasklet.Tasklet;
import org.springframework.core.task.TaskExecutor;
import org.springframework.transaction.PlatformTransactionManager;
/**
* 파티션 기반 병렬 처리 Job 구성을 위한 추상 클래스.
* 목록 조회 파티션 병렬 처리 후처리 패턴의 공통 인프라 제공.
*
* @param <I> 입력 타입 (Reader 출력, Processor 입력)
* @param <O> 출력 타입 (Processor 출력, Writer 입력)
*/
@Slf4j
public abstract class BasePartitionedJobConfig<I, O> extends BaseMultiStepJobConfig<I, O> {
public BasePartitionedJobConfig(JobRepository jobRepository, PlatformTransactionManager transactionManager) {
super(jobRepository, transactionManager);
}
/**
* 파티션 Step을 생성합니다.
*
* @param stepName 파티션 Step 이름
* @param workerStepName Worker Step 이름 (partitioner 등록에 사용)
* @param partitioner Partitioner 인스턴스
* @param workerStep Worker Step 인스턴스
* @param taskExecutor 병렬 실행용 TaskExecutor
* @param gridSize 파티션
* @return 구성된 파티션 Step
*/
protected Step createPartitionedStep(String stepName, String workerStepName,
Partitioner partitioner, Step workerStep,
TaskExecutor taskExecutor, int gridSize) {
return new StepBuilder(stepName, jobRepository)
.partitioner(workerStepName, partitioner)
.step(workerStep)
.taskExecutor(taskExecutor)
.gridSize(gridSize)
.build();
}
/**
* 건수 기반 Decider를 생성합니다.
* JobExecutionContext의 지정된 값이 0이면 EMPTY_RESPONSE, 아니면 NORMAL 반환.
*
* @param contextKey JobExecutionContext에서 조회할 int 이름
* @param jobName 로그에 표시할 Job 이름
* @return 건수 기반 JobExecutionDecider
*/
protected JobExecutionDecider createKeyCountDecider(String contextKey, String jobName) {
return (jobExecution, stepExecution) -> {
int totalCount = jobExecution.getExecutionContext().getInt(contextKey, 0);
if (totalCount == 0) {
log.info("[{}] Decider: EMPTY_RESPONSE - {} 0건으로 후속 스텝 스킵", jobName, contextKey);
return new FlowExecutionStatus("EMPTY_RESPONSE");
}
log.info("[{}] Decider: NORMAL - {} {} 건 처리 시작", jobName, contextKey, totalCount);
return new FlowExecutionStatus("NORMAL");
};
}
/**
* LastExecution 업데이트 Step을 생성합니다.
*
* @param stepName Step 이름
* @param tasklet LastExecutionUpdateTasklet 인스턴스
* @return 구성된 LastExecution 업데이트 Step
*/
protected Step createLastExecutionUpdateStep(String stepName, Tasklet tasklet) {
return new StepBuilder(stepName, jobRepository)
.tasklet(tasklet, transactionManager)
.build();
}
}

파일 보기

@ -0,0 +1,64 @@
package com.snp.batch.common.batch.entity;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;
import java.time.LocalDateTime;
/**
* 모든 Entity의 공통 베이스 클래스 - JDBC 전용
* 생성/수정 감사(Audit) 필드 제공
*
* 필드들은 Repository의 Insert/Update 자동으로 설정됩니다.
* BaseJdbcRepository가 감사 필드를 자동으로 관리합니다.
*/
@Data
@SuperBuilder
@NoArgsConstructor
@AllArgsConstructor
public abstract class BaseEntity {
/**
* 생성 일시
* 컬럼: created_at (TIMESTAMP)
*/
private LocalDateTime createdAt;
/**
* 수정 일시
* 컬럼: updated_at (TIMESTAMP)
*/
private LocalDateTime updatedAt;
/**
* 생성자
* 컬럼: created_by (VARCHAR(100))
*/
private String createdBy;
/**
* 수정자
* 컬럼: updated_by (VARCHAR(100))
*/
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,113 @@
package com.snp.batch.common.batch.listener;
import com.snp.batch.global.repository.BatchFailedRecordRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.core.BatchStatus;
import org.springframework.batch.core.JobExecution;
import org.springframework.batch.core.JobExecutionListener;
import org.springframework.batch.core.StepExecution;
import org.springframework.stereotype.Component;
import java.util.Arrays;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
/**
* 배치 Job 완료 실패 레코드가 있으면 자동으로 재수집을 트리거하는 리스너.
*
* 동작 조건:
* - Job 상태가 COMPLETED일 때만 실행
* - executionMode가 RECOLLECT가 아닌 일반 모드일 때만 실행 (무한 루프 방지)
* - StepExecution의 ExecutionContext에 failedRecordKeys가 존재할 때만 실행
* - 모든 Step의 failedRecordKeys를 Job 레벨에서 병합한 1회만 triggerRetryAsync 호출
* - retryCount가 MAX_AUTO_RETRY_COUNT 이상인 키는 재수집에서 제외 (무한 루프 방지)
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class AutoRetryJobExecutionListener implements JobExecutionListener {
private static final String FAILED_RECORD_KEYS = "failedRecordKeys";
private static final String FAILED_JOB_EXECUTION_ID = "failedJobExecutionId";
private static final String FAILED_API_KEY = "failedApiKey";
private static final int MAX_AUTO_RETRY_COUNT = 3;
private final AutoRetryTriggerService autoRetryTriggerService;
private final BatchFailedRecordRepository batchFailedRecordRepository;
@Override
public void beforeJob(JobExecution jobExecution) {
// no-op
}
@Override
public void afterJob(JobExecution jobExecution) {
String executionMode = jobExecution.getJobParameters()
.getString("executionMode", "NORMAL");
// 재수집 모드에서는 자동 재수집을 트리거하지 않음 (무한 루프 방지)
if ("RECOLLECT".equals(executionMode)) {
return;
}
// Job이 정상 완료된 경우에만 재수집 트리거
if (jobExecution.getStatus() != BatchStatus.COMPLETED) {
return;
}
String jobName = jobExecution.getJobInstance().getJobName();
// 모든 Step의 failedRecordKeys를 Set으로 병합 (중복 제거)
Set<String> mergedKeys = new LinkedHashSet<>();
Long sourceJobExecutionId = jobExecution.getId();
String apiKey = null;
for (StepExecution stepExecution : jobExecution.getStepExecutions()) {
String failedKeys = stepExecution.getExecutionContext()
.getString(FAILED_RECORD_KEYS, null);
if (failedKeys == null || failedKeys.isBlank()) {
continue;
}
Arrays.stream(failedKeys.split(","))
.map(String::trim)
.filter(key -> !key.isBlank())
.forEach(mergedKeys::add);
// apiKey: non-null인 번째 사용
if (apiKey == null) {
apiKey = stepExecution.getExecutionContext()
.getString(FAILED_API_KEY, null);
}
}
if (mergedKeys.isEmpty()) {
return;
}
// retryCount가 MAX_AUTO_RETRY_COUNT 이상인 필터링
List<String> exceededKeys = batchFailedRecordRepository.findExceededRetryKeys(
jobName, List.copyOf(mergedKeys), MAX_AUTO_RETRY_COUNT);
if (!exceededKeys.isEmpty()) {
log.warn("[AutoRetry] {} Job: 최대 재시도 횟수({})를 초과한 키 {}건 제외: {}",
jobName, MAX_AUTO_RETRY_COUNT, exceededKeys.size(), exceededKeys);
mergedKeys.removeAll(exceededKeys);
}
if (mergedKeys.isEmpty()) {
log.warn("[AutoRetry] {} Job: 모든 실패 키가 최대 재시도 횟수를 초과하여 재수집을 건너뜁니다.", jobName);
return;
}
log.info("[AutoRetry] {} Job 완료 후 실패 건 {}건 감지 → 자동 재수집 트리거",
jobName, mergedKeys.size());
// sourceJobExecutionId 기반으로 1회만 triggerRetryAsync 호출 (실패 키는 DB에서 직접 조회)
autoRetryTriggerService.triggerRetryAsync(
jobName, mergedKeys.size(), sourceJobExecutionId, apiKey);
}
}

파일 보기

@ -0,0 +1,66 @@
package com.snp.batch.common.batch.listener;
import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.JobExecution;
import org.springframework.batch.core.JobParameters;
import org.springframework.batch.core.JobParametersBuilder;
import org.springframework.batch.core.launch.JobLauncher;
import org.springframework.context.annotation.Lazy;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.util.Map;
/**
* 자동 재수집 Job 비동기 트리거 서비스.
* JobExecutionListener 내부 self-invocation으로는 @Async 프록시가 동작하지 않으므로
* 별도 빈으로 분리하여 프록시를 통한 비동기 호출을 보장합니다.
*/
@Slf4j
@Service
public class AutoRetryTriggerService {
private final JobLauncher jobLauncher;
private final Map<String, Job> jobMap;
public AutoRetryTriggerService(JobLauncher jobLauncher, @Lazy Map<String, Job> jobMap) {
this.jobLauncher = jobLauncher;
this.jobMap = jobMap;
}
@Async("autoRetryExecutor")
public void triggerRetryAsync(String jobName, int failedCount,
Long sourceJobExecutionId, String apiKey) {
try {
Job job = jobMap.get(jobName);
if (job == null) {
log.error("[AutoRetry] Job을 찾을 수 없습니다: {}", jobName);
return;
}
JobParametersBuilder builder = new JobParametersBuilder()
.addLong("timestamp", System.currentTimeMillis())
.addString("sourceJobExecutionId", String.valueOf(sourceJobExecutionId))
.addString("executionMode", "RECOLLECT")
.addString("reason", "자동 재수집 (실패 건 자동 처리)")
.addString("executor", "AUTO_RETRY");
if (apiKey != null) {
builder.addString("apiKey", apiKey);
}
JobParameters retryParams = builder.toJobParameters();
log.info("[AutoRetry] 재수집 Job 실행 시작: jobName={}, 실패건={}, sourceJobExecutionId={}",
jobName, failedCount, sourceJobExecutionId);
JobExecution retryExecution = jobLauncher.run(job, retryParams);
log.info("[AutoRetry] 재수집 Job 실행 완료: jobName={}, executionId={}, status={}",
jobName, retryExecution.getId(), retryExecution.getStatus());
} catch (Exception e) {
log.error("[AutoRetry] 재수집 Job 실행 실패: jobName={}, error={}", jobName, e.getMessage(), e);
}
}
}

파일 보기

@ -0,0 +1,120 @@
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;
@Slf4j
@Component
@RequiredArgsConstructor
public class RecollectionJobExecutionListener implements JobExecutionListener {
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 = resolveApiKey(jobExecution);
String executor = jobExecution.getJobParameters().getString("executor", "SYSTEM");
String reason = jobExecution.getJobParameters().getString("reason");
try {
// 재수집 이력 기록
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();
// 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) + "...";
}
}
// 재수집 이력 완료 기록
try {
recollectionHistoryService.recordCompletion(
jobExecutionId, status,
totalRead, totalWrite, totalSkip,
totalApiCalls, null,
failureReason);
} catch (Exception e) {
log.error("[RecollectionListener] 재수집 이력 완료 기록 실패: jobExecutionId={}", jobExecutionId, e);
}
}
/**
* Job 파라미터에서 apiKey를 읽고, 없으면 jobName으로 BatchCollectionPeriod에서 조회합니다.
* 수동 재수집(UI 실패건 재수집)에서는 apiKey가 파라미터로 전달되지 않을 있으므로
* jobName apiKey 매핑을 fallback으로 사용합니다.
*/
private String resolveApiKey(JobExecution jobExecution) {
String apiKey = jobExecution.getJobParameters().getString("apiKey");
if (apiKey != null) {
return apiKey;
}
// fallback: jobName으로 BatchCollectionPeriod에서 apiKey 조회
String jobName = jobExecution.getJobInstance().getJobName();
apiKey = recollectionHistoryService.findApiKeyByJobName(jobName);
if (apiKey != null) {
log.info("[RecollectionListener] apiKey를 jobName에서 조회: jobName={}, apiKey={}", jobName, apiKey);
}
return apiKey;
}
}

파일 보기

@ -0,0 +1,61 @@
package com.snp.batch.common.batch.partition;
import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.core.partition.support.Partitioner;
import org.springframework.batch.item.ExecutionContext;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 문자열 목록을 N개 파티션으로 균등 분할하는 범용 Partitioner.
*
* <p> 파티션의 ExecutionContext에 다음 값을 저장한다.</p>
* <ul>
* <li>{@code {contextKeyName}} 해당 파티션에 할당된 목록 (CSV 형식)</li>
* <li>{@code partitionIndex} 파티션 인덱스 (0-based)</li>
* <li>{@code partitionSize} 해당 파티션의 </li>
* </ul>
*/
@Slf4j
public class StringListPartitioner implements Partitioner {
private final List<String> allKeys;
private final int partitionCount;
private final String contextKeyName;
public StringListPartitioner(List<String> allKeys, int partitionCount, String contextKeyName) {
this.allKeys = allKeys;
this.partitionCount = partitionCount;
this.contextKeyName = contextKeyName;
}
@Override
public Map<String, ExecutionContext> partition(int gridSize) {
int totalSize = allKeys.size();
int actualPartitionCount = Math.min(partitionCount, Math.max(1, totalSize));
Map<String, ExecutionContext> partitions = new LinkedHashMap<>();
int partitionSize = (int) Math.ceil((double) totalSize / actualPartitionCount);
for (int i = 0; i < actualPartitionCount; i++) {
int fromIndex = i * partitionSize;
int toIndex = Math.min(fromIndex + partitionSize, totalSize);
if (fromIndex >= totalSize) break;
List<String> partitionKeys = allKeys.subList(fromIndex, toIndex);
ExecutionContext context = new ExecutionContext();
context.putString(contextKeyName, String.join(",", partitionKeys));
context.putInt("partitionIndex", i);
context.putInt("partitionSize", partitionKeys.size());
String partitionKey = "partition" + i;
partitions.put(partitionKey, context);
log.info("[StringListPartitioner] {} : 키 {} 건 (index {}-{})",
partitionKey, partitionKeys.size(), fromIndex, toIndex - 1);
}
log.info("[StringListPartitioner] 총 {} 개 파티션 생성 (전체 키: {} 건)",
partitions.size(), totalSize);
return partitions;
}
}

파일 보기

@ -0,0 +1,61 @@
package com.snp.batch.common.batch.processor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.item.ItemProcessor;
/**
* ItemProcessor 추상 클래스 (v2.0)
* 데이터 변환 처리 로직을 위한 템플릿 제공
*
* Template Method Pattern:
* - process(): 공통 로직 (null 체크, 로깅)
* - processItem(): 하위 클래스에서 변환 로직 구현
*
* 기본 용도:
* - 단순 변환: DTO Entity
* - 데이터 필터링: null 반환 해당 아이템 스킵
* - 데이터 검증: 유효하지 않은 데이터 필터링
*
* 고급 용도 (다중 depth JSON 처리):
* - 중첩된 JSON을 여러 Entity로 분해
* - 1:N 관계 처리 (Order OrderItems)
* - CompositeWriter와 조합하여 여러 테이블에 저장
*
* 예제:
* - 단순 변환: ProductDataProcessor (DTO Entity)
* - 복잡한 처리: 복잡한 JSON 처리 예제 참고
*
* @param <I> 입력 DTO 타입
* @param <O> 출력 Entity 타입
*/
@Slf4j
public abstract class BaseProcessor<I, O> implements ItemProcessor<I, O> {
/**
* 데이터 변환 로직 (하위 클래스에서 구현)
* DTO Entity 변환 등의 비즈니스 로직 구현
*
* @param item 입력 DTO
* @return 변환된 Entity (필터링 null 반환 가능)
* @throws Exception 처리 오류 발생
*/
protected abstract O processItem(I item) throws Exception;
/**
* Spring Batch ItemProcessor 인터페이스 구현
* 데이터 변환 필터링 수행
*
* @param item 입력 DTO
* @return 변환된 Entity (null이면 해당 아이템 스킵)
* @throws Exception 처리 오류 발생
*/
@Override
public O process(I item) throws Exception {
if (item == null) {
return null;
}
// log.debug("데이터 처리 중: {}", item);
return processItem(item);
}
}

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다. Load Diff

파일 보기

@ -0,0 +1,353 @@
package com.snp.batch.common.batch.repository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.jdbc.support.KeyHolder;
import org.springframework.transaction.annotation.Transactional;
import java.sql.PreparedStatement;
import java.sql.Statement;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
/**
* JdbcTemplate 기반 Repository 추상 클래스
* 모든 Repository가 상속받아 일관된 CRUD 패턴 제공
*
* @param <T> Entity 타입
* @param <ID> ID 타입
*/
@Slf4j
@RequiredArgsConstructor
@Transactional(readOnly = true)
public abstract class BaseJdbcRepository<T, ID> {
protected final JdbcTemplate jdbcTemplate;
/**
* 대상 스키마 이름 반환 (하위 클래스에서 구현)
* application.yml의 app.batch.target-schema.name 값을 @Value로 주입받아 반환
*/
protected abstract String getTargetSchema();
/**
* 테이블명만 반환 (스키마 제외, 하위 클래스에서 구현)
*/
protected abstract String getSimpleTableName();
/**
* 전체 테이블명 반환 (스키마.테이블)
* 하위 클래스에서는 getSimpleTableName() 구현하면
*/
protected String getTableName() {
return getTargetSchema() + "." + getSimpleTableName();
}
/**
* ID 컬럼명 반환 (기본값: "id")
*/
protected String getIdColumnName() {
return "id";
}
protected String getIdColumnName(String customId) {
return customId;
}
/**
* RowMapper 반환 (하위 클래스에서 구현)
*/
protected abstract RowMapper<T> getRowMapper();
/**
* Entity에서 ID 추출 (하위 클래스에서 구현)
*/
protected abstract ID extractId(T entity);
/**
* INSERT SQL 생성 (하위 클래스에서 구현)
*/
protected abstract String getInsertSql();
/**
* UPDATE SQL 생성 (하위 클래스에서 구현)
*/
protected abstract String getUpdateSql();
/**
* INSERT용 PreparedStatement 파라미터 설정 (하위 클래스에서 구현)
*/
protected abstract void setInsertParameters(PreparedStatement ps, T entity) throws Exception;
/**
* UPDATE용 PreparedStatement 파라미터 설정 (하위 클래스에서 구현)
*/
protected abstract void setUpdateParameters(PreparedStatement ps, T entity) throws Exception;
/**
* 엔티티명 반환 (로깅용)
*/
protected abstract String getEntityName();
// ==================== CRUD 메서드 ====================
/**
* ID로 조회
*/
public Optional<T> findById(ID id) {
String sql = String.format("SELECT * FROM %s WHERE %s = ?", getTableName(), getIdColumnName());
log.debug("{} 조회: ID={}", getEntityName(), id);
List<T> results = jdbcTemplate.query(sql, getRowMapper(), id);
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
}
/**
* 전체 조회
*/
public List<T> findAll() {
String sql = String.format("SELECT * FROM %s ORDER BY %s DESC", getTableName(), getIdColumnName());
log.debug("{} 전체 조회", getEntityName());
return jdbcTemplate.query(sql, getRowMapper());
}
/**
* 개수 조회
*/
public long count() {
String sql = String.format("SELECT COUNT(*) FROM %s", getTableName());
Long count = jdbcTemplate.queryForObject(sql, Long.class);
return count != null ? count : 0L;
}
/**
* 존재 여부 확인
*/
public boolean existsById(ID id) {
String sql = String.format("SELECT COUNT(*) FROM %s WHERE %s = ?", getTableName(), getIdColumnName());
Long count = jdbcTemplate.queryForObject(sql, Long.class, id);
return count != null && count > 0;
}
/**
* 단건 저장 (INSERT 또는 UPDATE)
*/
@Transactional
public T save(T entity) {
ID id = extractId(entity);
if (id == null || !existsById(id)) {
return insert(entity);
} else {
return update(entity);
}
}
/**
* 단건 INSERT
*/
@Transactional
protected T insert(T entity) {
log.info("{} 삽입 시작", getEntityName());
KeyHolder keyHolder = new GeneratedKeyHolder();
jdbcTemplate.update(connection -> {
PreparedStatement ps = connection.prepareStatement(getInsertSql(), Statement.RETURN_GENERATED_KEYS);
try {
setInsertParameters(ps, entity);
} catch (Exception e) {
log.error("{} 삽입 파라미터 설정 실패", getEntityName(), e);
throw new RuntimeException("Failed to set insert parameters", e);
}
return ps;
}, keyHolder);
// 생성된 ID 조회
if (keyHolder.getKeys() != null && !keyHolder.getKeys().isEmpty()) {
Object idValue = keyHolder.getKeys().get(getIdColumnName());
if (idValue != null) {
@SuppressWarnings("unchecked")
ID generatedId = (ID) (idValue instanceof Number ? ((Number) idValue).longValue() : idValue);
log.info("{} 삽입 완료: ID={}", getEntityName(), generatedId);
return findById(generatedId).orElse(entity);
}
}
log.info("{} 삽입 완료 (ID 미반환)", getEntityName());
return entity;
}
/**
* 단건 UPDATE
*/
@Transactional
protected T update(T entity) {
ID id = extractId(entity);
log.info("{} 수정 시작: ID={}", getEntityName(), id);
int updated = jdbcTemplate.update(connection -> {
PreparedStatement ps = connection.prepareStatement(getUpdateSql());
try {
setUpdateParameters(ps, entity);
} catch (Exception e) {
log.error("{} 수정 파라미터 설정 실패", getEntityName(), e);
throw new RuntimeException("Failed to set update parameters", e);
}
return ps;
});
if (updated == 0) {
throw new IllegalStateException(getEntityName() + " 수정 실패: ID=" + id);
}
log.info("{} 수정 완료: ID={}", getEntityName(), id);
return findById(id).orElse(entity);
}
/**
* 배치 INSERT (대량 삽입)
*/
@Transactional
public void batchInsert(List<T> entities) {
if (entities == null || entities.isEmpty()) {
return;
}
log.info("{} 배치 삽입 시작: {} 건", getEntityName(), entities.size());
jdbcTemplate.batchUpdate(getInsertSql(), entities, entities.size(),
(ps, entity) -> {
try {
setInsertParameters(ps, entity);
} catch (Exception e) {
log.error("배치 삽입 파라미터 설정 실패", e);
throw new RuntimeException(e);
}
});
log.info("{} 배치 삽입 완료: {} 건", getEntityName(), entities.size());
}
/**
* 배치 UPDATE (대량 수정)
*/
@Transactional
public void batchUpdate(List<T> entities) {
if (entities == null || entities.isEmpty()) {
return;
}
log.info("{} 배치 수정 시작: {} 건", getEntityName(), entities.size());
jdbcTemplate.batchUpdate(getUpdateSql(), entities, entities.size(),
(ps, entity) -> {
try {
setUpdateParameters(ps, entity);
} catch (Exception e) {
log.error("배치 수정 파라미터 설정 실패", e);
throw new RuntimeException(e);
}
});
log.info("{} 배치 수정 완료: {} 건", getEntityName(), entities.size());
}
/**
* 전체 저장 (INSERT 또는 UPDATE)
*/
@Transactional
public void saveAll(List<T> entities) {
if (entities == null || entities.isEmpty()) {
return;
}
log.info("{} 전체 저장 시작: {} 건", getEntityName(), entities.size());
// INSERT와 UPDATE 분리
List<T> toInsert = entities.stream()
.filter(e -> extractId(e) == null || !existsById(extractId(e)))
.toList();
List<T> toUpdate = entities.stream()
.filter(e -> extractId(e) != null && existsById(extractId(e)))
.toList();
if (!toInsert.isEmpty()) {
batchInsert(toInsert);
}
if (!toUpdate.isEmpty()) {
batchUpdate(toUpdate);
}
log.info("{} 전체 저장 완료: 삽입={} 건, 수정={} 건", getEntityName(), toInsert.size(), toUpdate.size());
}
/**
* ID로 삭제
*/
@Transactional
public void deleteById(ID id) {
String sql = String.format("DELETE FROM %s WHERE %s = ?", getTableName(), getIdColumnName());
log.info("{} 삭제: ID={}", getEntityName(), id);
int deleted = jdbcTemplate.update(sql, id);
if (deleted == 0) {
log.warn("{} 삭제 실패 (존재하지 않음): ID={}", getEntityName(), id);
} else {
log.info("{} 삭제 완료: ID={}", getEntityName(), id);
}
}
/**
* 전체 삭제
*/
@Transactional
public void deleteAll() {
String sql = String.format("DELETE FROM %s", getTableName());
log.warn("{} 전체 삭제", getEntityName());
int deleted = jdbcTemplate.update(sql);
log.info("{} 전체 삭제 완료: {} 건", getEntityName(), deleted);
}
// ==================== 헬퍼 메서드 ====================
/**
* 현재 시각 반환 (감사 필드용)
*/
protected LocalDateTime now() {
return LocalDateTime.now();
}
/**
* 커스텀 쿼리 실행 (단건 조회)
*/
protected Optional<T> executeQueryForObject(String sql, Object... params) {
log.debug("커스텀 쿼리 실행: {}", sql);
List<T> results = jdbcTemplate.query(sql, getRowMapper(), params);
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
}
/**
* 커스텀 쿼리 실행 (다건 조회)
*/
protected List<T> executeQueryForList(String sql, Object... params) {
log.debug("커스텀 쿼리 실행: {}", sql);
return jdbcTemplate.query(sql, getRowMapper(), params);
}
/**
* 커스텀 업데이트 실행
*/
@Transactional
protected int executeUpdate(String sql, Object... params) {
log.debug("커스텀 업데이트 실행: {}", sql);
return jdbcTemplate.update(sql, params);
}
}

파일 보기

@ -0,0 +1,73 @@
package com.snp.batch.common.batch.tasklet;
import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.core.StepContribution;
import org.springframework.batch.core.scope.context.ChunkContext;
import org.springframework.batch.core.step.tasklet.Tasklet;
import org.springframework.batch.repeat.RepeatStatus;
import org.springframework.jdbc.core.JdbcTemplate;
import java.sql.Timestamp;
import java.time.LocalDateTime;
/**
* 배치 작업 완료 BATCH_LAST_EXECUTION 테이블의 LAST_SUCCESS_DATE를 업데이트하는 공통 Tasklet.
*
* <p>RECOLLECT 모드일 경우 업데이트를 스킵하며,
* Job ExecutionContext에 저장된 {@code batchToDate} 기준으로 성공 날짜를 계산합니다.
* {@code batchToDate} 없을 경우 현재 시간에서 {@code bufferHours} 차감하여 사용합니다.</p>
*/
@Slf4j
public class LastExecutionUpdateTasklet implements Tasklet {
private static final String RECOLLECT_MODE = "RECOLLECT";
private final JdbcTemplate jdbcTemplate;
private final String targetSchema;
private final String apiKey;
private final int bufferHours;
public LastExecutionUpdateTasklet(JdbcTemplate jdbcTemplate, String targetSchema,
String apiKey, int bufferHours) {
this.jdbcTemplate = jdbcTemplate;
this.targetSchema = targetSchema;
this.apiKey = apiKey;
this.bufferHours = bufferHours;
}
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
String executionMode = chunkContext.getStepContext()
.getStepExecution().getJobExecution()
.getJobParameters().getString("executionMode", "NORMAL");
if (RECOLLECT_MODE.equals(executionMode)) {
log.info(">>>>> RECOLLECT 모드 - LAST_EXECUTION 업데이트 스킵");
return RepeatStatus.FINISHED;
}
String toDateStr = chunkContext.getStepContext()
.getStepExecution().getJobExecution()
.getExecutionContext().getString("batchToDate", null);
LocalDateTime successDate;
if (toDateStr != null) {
successDate = LocalDateTime.parse(toDateStr).minusHours(bufferHours);
log.info(">>>>> BATCH_LAST_EXECUTION 업데이트 시작 (캡처된 toDate - {}시간 버퍼: {})",
bufferHours, successDate);
} else {
successDate = LocalDateTime.now().minusHours(bufferHours);
log.warn(">>>>> batchToDate가 없어 현재 시간 - {}시간 버퍼 사용: {}", bufferHours, successDate);
}
jdbcTemplate.update(
String.format(
"UPDATE %s.BATCH_LAST_EXECUTION SET LAST_SUCCESS_DATE = ?, UPDATED_AT = NOW() WHERE API_KEY = ?",
targetSchema),
Timestamp.valueOf(successDate), apiKey
);
log.info(">>>>> BATCH_LAST_EXECUTION 업데이트 완료 (LAST_SUCCESS_DATE = {})", successDate);
return RepeatStatus.FINISHED;
}
}

파일 보기

@ -0,0 +1,61 @@
package com.snp.batch.common.batch.writer;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.item.Chunk;
import org.springframework.batch.item.ItemWriter;
import java.util.ArrayList;
import java.util.List;
/**
* ItemWriter 추상 클래스
* 데이터 저장 로직을 위한 템플릿 제공
*
* Template Method Pattern:
* - write(): 공통 로직 (로깅, null 체크)
* - writeItems(): 하위 클래스에서 저장 로직 구현
*
* @param <T> Entity 타입
*/
@Slf4j
@RequiredArgsConstructor
public abstract class BaseWriter<T> implements ItemWriter<T> {
private final String entityName;
/**
* 실제 데이터 저장 로직 (하위 클래스에서 구현)
* Repository의 saveAll() 또는 batchInsert() 호출
*
* @param items 저장할 Entity 리스트
* @throws Exception 저장 오류 발생
*/
protected abstract void writeItems(List<T> items) throws Exception;
/**
* Spring Batch ItemWriter 인터페이스 구현
* Chunk 단위로 데이터를 저장
*
* @param chunk 저장할 데이터 청크
* @throws Exception 저장 오류 발생
*/
@Override
public void write(Chunk<? extends T> chunk) throws Exception {
List<T> items = new ArrayList<>(chunk.getItems());
if (items.isEmpty()) {
log.debug("{} 저장할 데이터가 없습니다", entityName);
return;
}
try {
log.info("{} 데이터 {}건 저장 시작", entityName, items.size());
writeItems(items);
log.info("{} 데이터 {}건 저장 완료", entityName, items.size());
} catch (Exception e) {
log.error("{} 데이터 저장 실패", entityName, e);
throw e;
}
}
}

파일 보기

@ -0,0 +1,75 @@
package com.snp.batch.common.web;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 통일된 API 응답 형식
*
* @param <T> 응답 데이터 타입
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "공통 API 응답 래퍼")
public class ApiResponse<T> {
@Schema(description = "성공 여부", example = "true")
private boolean success;
@Schema(description = "응답 메시지", example = "Success")
private String message;
@Schema(description = "응답 데이터")
private T data;
@Schema(description = "에러 코드 (실패 시에만 존재)", example = "NOT_FOUND", nullable = true)
private String errorCode;
/**
* 성공 응답 생성
*/
public static <T> ApiResponse<T> success(T data) {
return ApiResponse.<T>builder()
.success(true)
.message("Success")
.data(data)
.build();
}
/**
* 성공 응답 생성 (메시지 포함)
*/
public static <T> ApiResponse<T> success(String message, T data) {
return ApiResponse.<T>builder()
.success(true)
.message(message)
.data(data)
.build();
}
/**
* 실패 응답 생성
*/
public static <T> ApiResponse<T> error(String message) {
return ApiResponse.<T>builder()
.success(false)
.message(message)
.build();
}
/**
* 실패 응답 생성 (에러 코드 포함)
*/
public static <T> ApiResponse<T> error(String message, String errorCode) {
return ApiResponse.<T>builder()
.success(false)
.message(message)
.errorCode(errorCode)
.build();
}
}

파일 보기

@ -0,0 +1,37 @@
package com.snp.batch.global.cleanup;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
/**
* 배치 로그 정리 설정
*
* 로그 종류별 보존 기간() 설정
*
* 설정 예시:
* app.batch.log-cleanup:
* api-log-retention-days: 30
* batch-meta-retention-days: 90
* failed-record-retention-days: 90
* recollection-history-retention-days: 90
*/
@Getter
@Setter
@Configuration
@ConfigurationProperties(prefix = "app.batch.log-cleanup")
public class LogCleanupConfig {
/** batch_api_log 보존 기간 (일) */
private int apiLogRetentionDays = 30;
/** Spring Batch 메타 테이블 보존 기간 (일) */
private int batchMetaRetentionDays = 90;
/** batch_failed_record (RESOLVED) 보존 기간 (일) */
private int failedRecordRetentionDays = 90;
/** batch_recollection_history 보존 기간 (일) */
private int recollectionHistoryRetentionDays = 90;
}

파일 보기

@ -0,0 +1,69 @@
package com.snp.batch.global.cleanup;
import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.JobExecution;
import org.springframework.batch.core.JobExecutionListener;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.core.step.builder.StepBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.PlatformTransactionManager;
/**
* 배치 로그 정리 Job Config
*
* 스케줄: 매일 02:00 (0 0 2 * * ?)
*
* 동작:
* - 보존 기간이 지난 배치 로그 데이터를 삭제
* - batch_api_log (30일), Spring Batch 메타 (90일),
* batch_failed_record/RESOLVED (90일), batch_recollection_history (90일)
*/
@Slf4j
@Configuration
public class LogCleanupJobConfig {
private final JobRepository jobRepository;
private final PlatformTransactionManager transactionManager;
private final LogCleanupTasklet logCleanupTasklet;
public LogCleanupJobConfig(
JobRepository jobRepository,
PlatformTransactionManager transactionManager,
LogCleanupTasklet logCleanupTasklet) {
this.jobRepository = jobRepository;
this.transactionManager = transactionManager;
this.logCleanupTasklet = logCleanupTasklet;
}
@Bean(name = "logCleanupStep")
public Step logCleanupStep() {
return new StepBuilder("logCleanupStep", jobRepository)
.tasklet(logCleanupTasklet, transactionManager)
.build();
}
@Bean(name = "LogCleanupJob")
public Job logCleanupJob() {
log.info("Job 생성: LogCleanupJob");
return new JobBuilder("LogCleanupJob", jobRepository)
.listener(new JobExecutionListener() {
@Override
public void beforeJob(JobExecution jobExecution) {
log.info("[LogCleanupJob] 배치 로그 정리 Job 시작");
}
@Override
public void afterJob(JobExecution jobExecution) {
log.info("[LogCleanupJob] 배치 로그 정리 Job 완료 - 상태: {}",
jobExecution.getStatus());
}
})
.start(logCleanupStep())
.build();
}
}

파일 보기

@ -0,0 +1,148 @@
package com.snp.batch.global.cleanup;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.core.StepContribution;
import org.springframework.batch.core.scope.context.ChunkContext;
import org.springframework.batch.core.step.tasklet.Tasklet;
import org.springframework.batch.repeat.RepeatStatus;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
@Slf4j
@Component
@RequiredArgsConstructor
public class LogCleanupTasklet implements Tasklet {
private final JdbcTemplate jdbcTemplate;
private final LogCleanupConfig config;
@Value("${app.batch.target-schema.name}")
private String schema;
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception {
log.info("========================================");
log.info("배치 로그 정리 Job 시작");
log.info("========================================");
int totalDeleted = 0;
// 1. batch_api_log 정리
totalDeleted += cleanupApiLog();
// 2. Spring Batch 메타 테이블 정리 (FK 순서)
totalDeleted += cleanupBatchMeta();
// 3. batch_failed_record 정리 (RESOLVED만)
totalDeleted += cleanupFailedRecord();
// 4. batch_recollection_history 정리
totalDeleted += cleanupRecollectionHistory();
log.info("========================================");
log.info("배치 로그 정리 Job 완료 - 총 삭제: {} 건", totalDeleted);
log.info("========================================");
return RepeatStatus.FINISHED;
}
private int cleanupApiLog() {
int days = config.getApiLogRetentionDays();
String sql = String.format(
"DELETE FROM %s.batch_api_log WHERE created_at < NOW() - INTERVAL '%d days'",
schema, days);
int deleted = jdbcTemplate.update(sql);
log.info("[batch_api_log] 보존기간: {}일, 삭제: {}건", days, deleted);
return deleted;
}
private int cleanupBatchMeta() {
int days = config.getBatchMetaRetentionDays();
int totalDeleted = 0;
// FK 의존 순서: step_execution_context step_execution job_execution_context job_execution_params job_execution job_instance(orphan)
// 1. batch_step_execution_context
String sql1 = String.format(
"DELETE FROM %s.batch_step_execution_context WHERE step_execution_id IN (" +
"SELECT se.step_execution_id FROM %s.batch_step_execution se " +
"JOIN %s.batch_job_execution je ON se.job_execution_id = je.job_execution_id " +
"WHERE je.create_time < NOW() - INTERVAL '%d days')",
schema, schema, schema, days);
int deleted = jdbcTemplate.update(sql1);
totalDeleted += deleted;
log.info("[batch_step_execution_context] 삭제: {}건", deleted);
// 2. batch_step_execution
String sql2 = String.format(
"DELETE FROM %s.batch_step_execution WHERE job_execution_id IN (" +
"SELECT job_execution_id FROM %s.batch_job_execution " +
"WHERE create_time < NOW() - INTERVAL '%d days')",
schema, schema, days);
deleted = jdbcTemplate.update(sql2);
totalDeleted += deleted;
log.info("[batch_step_execution] 삭제: {}건", deleted);
// 3. batch_job_execution_context
String sql3 = String.format(
"DELETE FROM %s.batch_job_execution_context WHERE job_execution_id IN (" +
"SELECT job_execution_id FROM %s.batch_job_execution " +
"WHERE create_time < NOW() - INTERVAL '%d days')",
schema, schema, days);
deleted = jdbcTemplate.update(sql3);
totalDeleted += deleted;
log.info("[batch_job_execution_context] 삭제: {}건", deleted);
// 4. batch_job_execution_params
String sql4 = String.format(
"DELETE FROM %s.batch_job_execution_params WHERE job_execution_id IN (" +
"SELECT job_execution_id FROM %s.batch_job_execution " +
"WHERE create_time < NOW() - INTERVAL '%d days')",
schema, schema, days);
deleted = jdbcTemplate.update(sql4);
totalDeleted += deleted;
log.info("[batch_job_execution_params] 삭제: {}건", deleted);
// 5. batch_job_execution
String sql5 = String.format(
"DELETE FROM %s.batch_job_execution WHERE create_time < NOW() - INTERVAL '%d days'",
schema, days);
deleted = jdbcTemplate.update(sql5);
totalDeleted += deleted;
log.info("[batch_job_execution] 삭제: {}건", deleted);
// 6. batch_job_instance (참조 없는 인스턴스만)
String sql6 = String.format(
"DELETE FROM %s.batch_job_instance WHERE job_instance_id NOT IN (" +
"SELECT DISTINCT job_instance_id FROM %s.batch_job_execution)",
schema, schema);
deleted = jdbcTemplate.update(sql6);
totalDeleted += deleted;
log.info("[batch_job_instance] orphan 삭제: {}건", deleted);
log.info("[Spring Batch 메타] 보존기간: {}일, 총 삭제: {}건", days, totalDeleted);
return totalDeleted;
}
private int cleanupFailedRecord() {
int days = config.getFailedRecordRetentionDays();
String sql = String.format(
"DELETE FROM %s.batch_failed_record WHERE status = 'RESOLVED' AND resolved_at < NOW() - INTERVAL '%d days'",
schema, days);
int deleted = jdbcTemplate.update(sql);
log.info("[batch_failed_record] 보존기간: {}일 (RESOLVED만), 삭제: {}건", days, deleted);
return deleted;
}
private int cleanupRecollectionHistory() {
int days = config.getRecollectionHistoryRetentionDays();
String sql = String.format(
"DELETE FROM %s.batch_recollection_history WHERE created_at < NOW() - INTERVAL '%d days'",
schema, days);
int deleted = jdbcTemplate.update(sql);
log.info("[batch_recollection_history] 보존기간: {}일, 삭제: {}건", days, deleted);
return deleted;
}
}

파일 보기

@ -0,0 +1,56 @@
package com.snp.batch.global.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.core.task.TaskExecutor;
import java.util.concurrent.Executor;
@Configuration
@EnableAsync // 비동기 기능 활성화
public class AsyncConfig {
@Bean(name = "apiLogExecutor")
public Executor apiLogExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2); // 기본 스레드
executor.setMaxPoolSize(5); // 최대 스레드
executor.setQueueCapacity(500); // 대기 크기
executor.setThreadNamePrefix("ApiLogThread-");
executor.initialize();
return executor;
}
/**
* 자동 재수집 전용 Executor.
* 재수집 Job은 장시간 실행되므로 apiLogExecutor와 분리하여 별도 풀로 관리.
*/
@Bean(name = "autoRetryExecutor")
public Executor autoRetryExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(1); // 재수집은 순차적으로 충분
executor.setMaxPoolSize(2); // 동시 최대 2개까지 허용
executor.setQueueCapacity(10); // 대기 (초과 CallerRunsPolicy)
executor.setThreadNamePrefix("AutoRetry-");
executor.initialize();
return executor;
}
/**
* 배치 파티션 병렬 실행 전용 Executor.
* ShipDetailUpdate 파티셔닝 배치 Step 병렬 처리에 사용.
*/
@Bean(name = "batchPartitionExecutor")
public TaskExecutor batchPartitionExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(4); // 기본 파티션
executor.setMaxPoolSize(8); // 최대 파티션
executor.setQueueCapacity(20); // 대기
executor.setThreadNamePrefix("BatchPartition-");
executor.initialize();
return executor;
}
}

파일 보기

@ -0,0 +1,161 @@
package com.snp.batch.global.config;
import io.netty.channel.ChannelOption;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.netty.http.client.HttpClient;
import java.time.Duration;
/**
* Maritime API WebClient 설정
*
* 목적:
* - Maritime API 서버에 대한 WebClient Bean 등록
* - 동일한 API 서버를 사용하는 여러 Job에서 재사용
* - 설정 변경 곳에서만 수정
*
* 사용 Job:
* - 도메인 Job에서 공통으로 재사용
*
* 다른 API 서버 추가 :
* - 새로운 Config 클래스 생성 (: OtherApiWebClientConfig)
* - Bean 이름을 다르게 지정 (: @Bean(name = "otherApiWebClient"))
*/
@Slf4j
@Configuration
public class MaritimeApiWebClientConfig {
@Value("${app.batch.ship-api.url}")
private String maritimeApiUrl;
@Value("${app.batch.ais-api.url}")
private String maritimeAisApiUrl;
@Value("${app.batch.webservice-api.url}")
private String maritimeServiceApiUrl;
@Value("${app.batch.api-auth.username}")
private String maritimeApiUsername;
@Value("${app.batch.api-auth.password}")
private String maritimeApiPassword;
/**
* Maritime API용 WebClient Bean
*
* 설정:
* - Base URL: Maritime API 서버 주소
* - 인증: Basic Authentication
* - 버퍼: 20MB (대용량 응답 처리)
*
* @return Maritime API WebClient
*/
@Bean(name = "maritimeApiWebClient")
public WebClient maritimeApiWebClient() {
log.info("========================================");
log.info("Maritime API WebClient 생성");
log.info("Base URL: {}", maritimeApiUrl);
log.info("========================================");
HttpClient httpClient = HttpClient.create()
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 10_000) // 연결 타임아웃 10초
.responseTimeout(Duration.ofSeconds(60)); // 응답 대기 60초
return WebClient.builder()
.clientConnector(new ReactorClientHttpConnector(httpClient))
.baseUrl(maritimeApiUrl)
.defaultHeaders(headers -> headers.setBasicAuth(maritimeApiUsername, maritimeApiPassword))
.codecs(configurer -> configurer
.defaultCodecs()
.maxInMemorySize(100 * 1024 * 1024)) // 100MB 버퍼
.build();
}
@Bean(name = "maritimeAisApiWebClient")
public WebClient maritimeAisApiWebClient(){
log.info("========================================");
log.info("Maritime AIS API WebClient 생성");
log.info("Base URL: {}", maritimeAisApiUrl);
log.info("========================================");
HttpClient httpClient = HttpClient.create()
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 10_000) // 연결 타임아웃 10초
.responseTimeout(Duration.ofSeconds(60)); // 응답 대기 60초
return WebClient.builder()
.clientConnector(new ReactorClientHttpConnector(httpClient))
.baseUrl(maritimeAisApiUrl)
.defaultHeaders(headers -> headers.setBasicAuth(maritimeApiUsername, maritimeApiPassword))
.codecs(configurer -> configurer
.defaultCodecs()
.maxInMemorySize(100 * 1024 * 1024)) // 100MB 버퍼
.build();
}
@Bean(name = "maritimeServiceApiWebClient")
public WebClient maritimeServiceApiWebClient(){
log.info("========================================");
log.info("Maritime Service API WebClient 생성");
log.info("Base URL: {}", maritimeServiceApiUrl);
log.info("========================================");
HttpClient httpClient = HttpClient.create()
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 10_000)
.responseTimeout(Duration.ofMinutes(5));
return WebClient.builder()
.clientConnector(new ReactorClientHttpConnector(httpClient))
.baseUrl(maritimeServiceApiUrl)
.defaultHeaders(headers -> headers.setBasicAuth(maritimeApiUsername, maritimeApiPassword))
.codecs(configurer -> configurer
.defaultCodecs()
.maxInMemorySize(256 * 1024 * 1024)) // 256MB 버퍼
.build();
}
}
/**
* ========================================
* 다른 API 서버 추가 예시
* ========================================
*
* 1. 새로운 Config 클래스 생성:
*
* @Configuration
* public class ExternalApiWebClientConfig {
*
* @Bean(name = "externalApiWebClient")
* public WebClient externalApiWebClient(
* @Value("${app.batch.external-api.url}") String url,
* @Value("${app.batch.external-api.token}") String token) {
*
* return WebClient.builder()
* .baseUrl(url)
* .defaultHeader("Authorization", "Bearer " + token)
* .build();
* }
* }
*
* 2. JobConfig에서 사용:
*
* public ExternalJobConfig(
* ...,
* @Qualifier("externalApiWebClient") WebClient externalApiWebClient) {
* this.webClient = externalApiWebClient;
* }
*
* 3. application.yml에 설정 추가:
*
* app:
* batch:
* external-api:
* url: https://external-api.example.com
* token: ${EXTERNAL_API_TOKEN}
*/

파일 보기

@ -0,0 +1,85 @@
package com.snp.batch.global.config;
import org.quartz.spi.TriggerFiredBundle;
import org.springframework.beans.factory.config.AutowireCapableBeanFactory;
import org.springframework.boot.autoconfigure.quartz.QuartzProperties;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.quartz.SchedulerFactoryBean;
import org.springframework.scheduling.quartz.SpringBeanJobFactory;
import javax.sql.DataSource;
import java.util.Properties;
/**
* Quartz 설정
* 커스텀 SchedulerFactoryBean을 정의하면 Spring Boot auto-configuration이 비활성화되므로
* DataSource와 QuartzProperties를 명시적으로 주입해야 한다.
*/
@Configuration
public class QuartzConfig {
/**
* Quartz Scheduler Factory Bean 설정
* DataSource, QuartzProperties를 명시적으로 주입하여 JDBC Store 사용 보장
*/
@Bean
public SchedulerFactoryBean schedulerFactoryBean(
ApplicationContext applicationContext,
DataSource dataSource,
QuartzProperties quartzProperties) {
SchedulerFactoryBean factory = new SchedulerFactoryBean();
factory.setJobFactory(springBeanJobFactory(applicationContext));
factory.setDataSource(dataSource);
factory.setOverwriteExistingJobs(true);
// SchedulerInitializer에서 직접 start() 호출하므로 자동 시작 비활성화
// 자동 시작 JDBC Store의 기존 trigger가 로드되어 중복 실행 발생 가능
factory.setAutoStartup(false);
// application.yml의 spring.quartz.properties 적용
// jobStore.class는 setDataSource() LocalDataSourceJobStore로 대체하므로 제외
// driverDelegateClass는 PostgreSQLDelegate가 필요하므로 유지
Properties properties = new Properties();
quartzProperties.getProperties().forEach((key, value) -> {
if (!key.contains("jobStore.class")) {
properties.put(key, value);
}
});
factory.setQuartzProperties(properties);
return factory;
}
/**
* Spring Bean 자동 주입을 지원하는 JobFactory
*/
@Bean
public SpringBeanJobFactory springBeanJobFactory(ApplicationContext applicationContext) {
AutowiringSpringBeanJobFactory jobFactory = new AutowiringSpringBeanJobFactory();
jobFactory.setApplicationContext(applicationContext);
return jobFactory;
}
/**
* Quartz Job에서 Spring Bean 자동 주입을 가능하게 하는 Factory
*/
public static class AutowiringSpringBeanJobFactory extends SpringBeanJobFactory implements ApplicationContextAware {
private AutowireCapableBeanFactory beanFactory;
@Override
public void setApplicationContext(ApplicationContext applicationContext) {
beanFactory = applicationContext.getAutowireCapableBeanFactory();
}
@Override
protected Object createJobInstance(TriggerFiredBundle bundle) throws Exception {
Object jobInstance = super.createJobInstance(bundle);
beanFactory.autowireBean(jobInstance);
return jobInstance;
}
}
}

파일 보기

@ -0,0 +1,102 @@
package com.snp.batch.global.config;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Contact;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.info.License;
import io.swagger.v3.oas.models.servers.Server;
import org.springdoc.core.models.GroupedOpenApi;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.List;
/**
* Swagger/OpenAPI 3.0 설정
*
* Swagger UI 접속 URL:
* - Swagger UI: http://localhost:8041/snp-collector/swagger-ui/index.html
* - API 문서 (JSON): http://localhost:8041/snp-collector/v3/api-docs
* - API 문서 (YAML): http://localhost:8041/snp-collector/v3/api-docs.yaml
*/
@Configuration
public class SwaggerConfig {
@Value("${server.port:8041}")
private int serverPort;
@Value("${server.servlet.context-path:}")
private String contextPath;
@Value("${app.environment:dev}")
private String environment;
@Bean
@ConditionalOnProperty(name = "app.environment", havingValue = "dev", matchIfMissing = true)
public GroupedOpenApi batchManagementApi() {
return GroupedOpenApi.builder()
.group("1. Batch Management")
.pathsToMatch("/api/batch/**")
.addOpenApiCustomizer(openApi -> openApi.info(new Info()
.title("Batch Management API")
.description("배치 Job 실행, 이력 조회, 스케줄 관리 API")
.version("v1.0.0")))
.build();
}
@Bean
public OpenAPI openAPI() {
List<Server> servers = "prod".equals(environment)
? List.of(
new Server()
.url("https://guide.gc-si.dev" + contextPath)
.description("GC 도메인"))
: List.of(
new Server()
.url("http://localhost:" + serverPort + contextPath)
.description("로컬 개발 서버"),
new Server()
.url("http://211.208.115.83:" + serverPort + contextPath)
.description("중계 서버"),
new Server()
.url("https://guide.gc-si.dev" + contextPath)
.description("GC 도메인"));
return new OpenAPI()
.info(defaultApiInfo())
.servers(servers);
}
private Info defaultApiInfo() {
return new Info()
.title("SNP Collector REST API")
.description("""
## SNP Collector 시스템 REST API 문서
해양 데이터 수집 배치 시스템의 REST API 문서입니다.
### 제공 API
- **Batch Management API**: 배치 Job 실행, 이력 조회, 스케줄 관리
### 주요 기능
- 배치 Job 실행 중지
- Job 실행 이력 조회
- 스케줄 관리 (Quartz)
### 버전 정보
- API Version: v1.0.0
- Spring Boot: 3.2.1
- Spring Batch: 5.1.0
""")
.version("v1.0.0")
.contact(new Contact()
.name("SNP Collector Team")
.email("support@snp-collector.com")
.url("https://github.com/snp-collector"))
.license(new License()
.name("Apache 2.0")
.url("https://www.apache.org/licenses/LICENSE-2.0"));
}
}

파일 보기

@ -0,0 +1,791 @@
package com.snp.batch.global.controller;
import com.snp.batch.global.dto.*;
import com.snp.batch.global.model.BatchCollectionPeriod;
import com.snp.batch.global.model.BatchRecollectionHistory;
import com.snp.batch.global.model.JobDisplayNameEntity;
import com.snp.batch.global.repository.JobDisplayNameRepository;
import com.snp.batch.service.BatchFailedRecordService;
import com.snp.batch.service.BatchService;
import com.snp.batch.service.RecollectionHistoryService;
import com.snp.batch.service.ScheduleService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.enums.Explode;
import io.swagger.v3.oas.annotations.enums.ParameterIn;
import io.swagger.v3.oas.annotations.enums.ParameterStyle;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springdoc.core.annotations.ParameterObject;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@Slf4j
@RestController
@RequestMapping("/api/batch")
@RequiredArgsConstructor
@Tag(name = "Batch Management API", description = "배치 작업 실행 및 스케줄 관리 API")
public class BatchController {
private final BatchService batchService;
private final ScheduleService scheduleService;
private final RecollectionHistoryService recollectionHistoryService;
private final BatchFailedRecordService batchFailedRecordService;
private final JobDisplayNameRepository jobDisplayNameRepository;
@Operation(summary = "배치 작업 실행", description = "지정된 배치 작업을 즉시 실행합니다. 쿼리 파라미터로 Job Parameters 전달 가능")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "작업 실행 성공"),
@ApiResponse(responseCode = "500", description = "작업 실행 실패")
})
@PostMapping("/jobs/{jobName}/execute")
public ResponseEntity<Map<String, Object>> executeJob(
@Parameter(description = "실행할 배치 작업 이름", required = true, example = "sampleProductImportJob")
@PathVariable String jobName,
@Parameter(description = "Job Parameters (동적 파라미터)", required = false, example = "?param1=value1&param2=value2")
@RequestParam(required = false) Map<String, String> params) {
log.info("Received request to execute job: {} with params: {}", jobName, params);
try {
Long executionId = batchService.executeJob(jobName, params);
return ResponseEntity.ok(Map.of(
"success", true,
"message", "Job started successfully",
"executionId", executionId
));
} catch (Exception e) {
log.error("Error executing job: {}", jobName, e);
return ResponseEntity.internalServerError().body(Map.of(
"success", false,
"message", "Failed to start job: " + e.getMessage()
));
}
}
@Operation(summary = "배치 작업 실행", description = "지정된 배치 작업을 즉시 실행합니다. 쿼리 파라미터로 Job Parameters 전달 가능")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "작업 실행 성공"),
@ApiResponse(responseCode = "500", description = "작업 실행 실패")
})
@PostMapping("/jobs/{jobName}/executeJobTest")
public ResponseEntity<Map<String, Object>> executeJobTest(
@Parameter( description = "실행할 배치 작업 이름", required = true,example = "sampleProductImportJob")
@PathVariable String jobName,
@ParameterObject JobLaunchRequest request
) {
Map<String, String> params = new HashMap<>();
if (request.getStartDate() != null) params.put("startDate", request.getStartDate());
if (request.getStopDate() != null) params.put("stopDate", request.getStopDate());
log.info("Executing job: {} with params: {}", jobName, params);
try {
Long executionId = batchService.executeJob(jobName, params);
return ResponseEntity.ok(Map.of(
"success", true,
"message", "Job started successfully",
"executionId", executionId
));
} catch (Exception e) {
log.error("Error executing job: {}", jobName, e);
return ResponseEntity.internalServerError().body(Map.of(
"success", false,
"message", "Failed to start job: " + e.getMessage()
));
}
}
@Operation(summary = "배치 작업 목록 조회", description = "등록된 모든 배치 작업 목록을 조회합니다")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "조회 성공")
})
@GetMapping("/jobs")
public ResponseEntity<List<String>> listJobs() {
log.debug("Received request to list all jobs");
List<String> jobs = batchService.listAllJobs();
return ResponseEntity.ok(jobs);
}
@Operation(summary = "배치 작업 실행 이력 조회", description = "특정 배치 작업의 실행 이력을 조회합니다")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "조회 성공")
})
@GetMapping("/jobs/{jobName}/executions")
public ResponseEntity<List<JobExecutionDto>> getJobExecutions(
@Parameter(description = "배치 작업 이름", required = true, example = "sampleProductImportJob")
@PathVariable String jobName) {
log.info("Received request to get executions for job: {}", jobName);
List<JobExecutionDto> executions = batchService.getJobExecutions(jobName);
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}")
public ResponseEntity<JobExecutionDto> getExecutionDetails(@PathVariable Long executionId) {
log.info("Received request to get execution details for: {}", executionId);
try {
JobExecutionDto execution = batchService.getExecutionDetails(executionId);
return ResponseEntity.ok(execution);
} catch (Exception e) {
log.error("Error getting execution details: {}", executionId, e);
return ResponseEntity.notFound().build();
}
}
@GetMapping("/executions/{executionId}/detail")
public ResponseEntity<com.snp.batch.global.dto.JobExecutionDetailDto> getExecutionDetailWithSteps(@PathVariable Long executionId) {
log.info("Received request to get detailed execution for: {}", executionId);
try {
com.snp.batch.global.dto.JobExecutionDetailDto detail = batchService.getExecutionDetailWithSteps(executionId);
return ResponseEntity.ok(detail);
} catch (Exception e) {
log.error("Error getting detailed execution: {}", executionId, e);
return ResponseEntity.notFound().build();
}
}
@PostMapping("/executions/{executionId}/stop")
public ResponseEntity<Map<String, Object>> stopExecution(@PathVariable Long executionId) {
log.info("Received request to stop execution: {}", executionId);
try {
batchService.stopExecution(executionId);
return ResponseEntity.ok(Map.of(
"success", true,
"message", "Execution stop requested"
));
} catch (Exception e) {
log.error("Error stopping execution: {}", executionId, e);
return ResponseEntity.internalServerError().body(Map.of(
"success", false,
"message", "Failed to stop execution: " + e.getMessage()
));
}
}
@Operation(summary = "스케줄 목록 조회", description = "등록된 모든 스케줄을 조회합니다")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "조회 성공")
})
@GetMapping("/schedules")
public ResponseEntity<Map<String, Object>> getSchedules() {
log.info("Received request to get all schedules");
List<ScheduleResponse> schedules = scheduleService.getAllSchedules();
return ResponseEntity.ok(Map.of(
"schedules", schedules,
"count", schedules.size()
));
}
@GetMapping("/schedules/{jobName}")
public ResponseEntity<ScheduleResponse> getSchedule(@PathVariable String jobName) {
log.debug("Received request to get schedule for job: {}", jobName);
try {
ScheduleResponse schedule = scheduleService.getScheduleByJobName(jobName);
return ResponseEntity.ok(schedule);
} catch (IllegalArgumentException e) {
// 스케줄이 없는 경우 - 정상적인 시나리오 (UI에서 존재 여부 확인용)
log.debug("Schedule not found for job: {} (정상 - 존재 확인)", jobName);
return ResponseEntity.notFound().build();
} catch (Exception e) {
log.error("Error getting schedule for job: {}", jobName, e);
return ResponseEntity.notFound().build();
}
}
@Operation(summary = "스케줄 생성", description = "새로운 배치 작업 스케줄을 등록합니다")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "생성 성공"),
@ApiResponse(responseCode = "500", description = "생성 실패")
})
@PostMapping("/schedules")
public ResponseEntity<Map<String, Object>> createSchedule(
@Parameter(description = "스케줄 생성 요청 데이터", required = true)
@RequestBody ScheduleRequest request) {
log.info("Received request to create schedule for job: {}", request.getJobName());
try {
ScheduleResponse schedule = scheduleService.createSchedule(request);
return ResponseEntity.ok(Map.of(
"success", true,
"message", "Schedule created successfully",
"data", schedule
));
} catch (Exception e) {
log.error("Error creating schedule for job: {}", request.getJobName(), e);
return ResponseEntity.internalServerError().body(Map.of(
"success", false,
"message", "Failed to create schedule: " + e.getMessage()
));
}
}
@PostMapping("/schedules/{jobName}/update")
public ResponseEntity<Map<String, Object>> updateSchedule(
@PathVariable String jobName,
@RequestBody Map<String, String> request) {
log.info("Received request to update schedule for job: {}", jobName);
try {
String cronExpression = request.get("cronExpression");
String description = request.get("description");
ScheduleResponse schedule = scheduleService.updateSchedule(jobName, cronExpression, description);
return ResponseEntity.ok(Map.of(
"success", true,
"message", "Schedule updated successfully",
"data", schedule
));
} catch (Exception e) {
log.error("Error updating schedule for job: {}", jobName, e);
return ResponseEntity.internalServerError().body(Map.of(
"success", false,
"message", "Failed to update schedule: " + e.getMessage()
));
}
}
@Operation(summary = "스케줄 삭제", description = "배치 작업 스케줄을 삭제합니다")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "삭제 성공"),
@ApiResponse(responseCode = "500", description = "삭제 실패")
})
@PostMapping("/schedules/{jobName}/delete")
public ResponseEntity<Map<String, Object>> deleteSchedule(
@Parameter(description = "배치 작업 이름", required = true)
@PathVariable String jobName) {
log.info("Received request to delete schedule for job: {}", jobName);
try {
scheduleService.deleteSchedule(jobName);
return ResponseEntity.ok(Map.of(
"success", true,
"message", "Schedule deleted successfully"
));
} catch (Exception e) {
log.error("Error deleting schedule for job: {}", jobName, e);
return ResponseEntity.internalServerError().body(Map.of(
"success", false,
"message", "Failed to delete schedule: " + e.getMessage()
));
}
}
@PostMapping("/schedules/{jobName}/toggle")
public ResponseEntity<Map<String, Object>> toggleSchedule(
@PathVariable String jobName,
@RequestBody Map<String, Boolean> request) {
log.info("Received request to toggle schedule for job: {}", jobName);
try {
Boolean active = request.get("active");
ScheduleResponse schedule = scheduleService.toggleScheduleActive(jobName, active);
return ResponseEntity.ok(Map.of(
"success", true,
"message", "Schedule toggled successfully",
"data", schedule
));
} catch (Exception e) {
log.error("Error toggling schedule for job: {}", jobName, e);
return ResponseEntity.internalServerError().body(Map.of(
"success", false,
"message", "Failed to toggle schedule: " + e.getMessage()
));
}
}
@GetMapping("/timeline")
public ResponseEntity<com.snp.batch.global.dto.TimelineResponse> getTimeline(
@RequestParam String view,
@RequestParam String date) {
log.debug("Received request to get timeline: view={}, date={}", view, date);
try {
com.snp.batch.global.dto.TimelineResponse timeline = batchService.getTimeline(view, date);
return ResponseEntity.ok(timeline);
} catch (Exception e) {
log.error("Error getting timeline", e);
return ResponseEntity.internalServerError().build();
}
}
@GetMapping("/dashboard")
public ResponseEntity<com.snp.batch.global.dto.DashboardResponse> getDashboard() {
log.debug("Received request to get dashboard data");
try {
com.snp.batch.global.dto.DashboardResponse dashboard = batchService.getDashboardData();
return ResponseEntity.ok(dashboard);
} catch (Exception e) {
log.error("Error getting dashboard data", e);
return ResponseEntity.internalServerError().build();
}
}
@GetMapping("/timeline/period-executions")
public ResponseEntity<List<JobExecutionDto>> getPeriodExecutions(
@RequestParam String jobName,
@RequestParam String view,
@RequestParam String periodKey) {
log.info("Received request to get period executions: jobName={}, view={}, periodKey={}", jobName, view, periodKey);
try {
List<JobExecutionDto> executions = batchService.getPeriodExecutions(jobName, view, periodKey);
return ResponseEntity.ok(executions);
} catch (Exception e) {
log.error("Error getting period executions", e);
return ResponseEntity.internalServerError().build();
}
}
// Step API 로그 페이징 조회
@Operation(summary = "Step API 호출 로그 페이징 조회", description = "Step 실행의 개별 API 호출 로그를 페이징 + 상태 필터로 조회합니다")
@GetMapping("/steps/{stepExecutionId}/api-logs")
public ResponseEntity<JobExecutionDetailDto.ApiLogPageResponse> getStepApiLogs(
@Parameter(description = "Step 실행 ID", required = true) @PathVariable Long stepExecutionId,
@Parameter(description = "페이지 번호 (0부터)") @RequestParam(defaultValue = "0") int page,
@Parameter(description = "페이지 크기") @RequestParam(defaultValue = "50") int size,
@Parameter(description = "상태 필터 (ALL, SUCCESS, ERROR)") @RequestParam(defaultValue = "ALL") String status) {
log.debug("Get step API logs: stepExecutionId={}, page={}, size={}, status={}", stepExecutionId, page, size, status);
JobExecutionDetailDto.ApiLogPageResponse response = batchService.getStepApiLogs(
stepExecutionId, status, PageRequest.of(page, size));
return ResponseEntity.ok(response);
}
// 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));
// 목록의 jobExecutionId들로 실패건수 한번에 조회
List<Long> jobExecutionIds = histories.getContent().stream()
.map(BatchRecollectionHistory::getJobExecutionId)
.filter(Objects::nonNull)
.toList();
Map<Long, Long> failedRecordCounts = recollectionHistoryService
.getFailedRecordCounts(jobExecutionIds);
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());
response.put("failedRecordCounts", failedRecordCounts);
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("/last-collections")
public ResponseEntity<List<LastCollectionStatusResponse>> getLastCollectionStatuses() {
log.debug("Received request to get last collection statuses");
List<LastCollectionStatusResponse> statuses = batchService.getLastCollectionStatuses();
return ResponseEntity.ok(statuses);
}
// 수집 기간 관리 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의 수집 기간을 수정합니다")
@PostMapping("/collection-periods/{apiKey}/update")
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()));
}
}
// 실패 레코드 관리 API
@Operation(summary = "실패 레코드 일괄 RESOLVED 처리", description = "특정 Job의 FAILED 상태 레코드를 일괄 RESOLVED 처리합니다")
@PostMapping("/failed-records/resolve")
public ResponseEntity<Map<String, Object>> resolveFailedRecords(
@RequestBody Map<String, Object> request) {
@SuppressWarnings("unchecked")
List<Integer> rawIds = (List<Integer>) request.get("ids");
if (rawIds == null || rawIds.isEmpty()) {
return ResponseEntity.badRequest().body(Map.of(
"success", false,
"message", "ids는 필수이며 비어있을 수 없습니다"));
}
List<Long> ids = rawIds.stream().map(Integer::longValue).toList();
log.info("Resolve failed records: ids count={}", ids.size());
try {
int resolved = batchFailedRecordService.resolveByIds(ids);
return ResponseEntity.ok(Map.of(
"success", true,
"resolvedCount", resolved,
"message", resolved + "건의 실패 레코드가 RESOLVED 처리되었습니다"));
} catch (Exception e) {
log.error("Error resolving failed records", e);
return ResponseEntity.internalServerError().body(Map.of(
"success", false,
"message", "실패 레코드 RESOLVED 처리 실패: " + e.getMessage()));
}
}
@Operation(summary = "실패 레코드 재시도 횟수 초기화", description = "재시도 횟수를 초과한 FAILED 레코드의 retryCount를 0으로 초기화하여 자동 재수집 대상으로 복원합니다")
@PostMapping("/failed-records/reset-retry")
public ResponseEntity<Map<String, Object>> resetRetryCount(
@RequestBody Map<String, Object> request) {
@SuppressWarnings("unchecked")
List<Integer> rawIds = (List<Integer>) request.get("ids");
if (rawIds == null || rawIds.isEmpty()) {
return ResponseEntity.badRequest().body(Map.of(
"success", false,
"message", "ids는 필수이며 비어있을 수 없습니다"));
}
List<Long> ids = rawIds.stream().map(Integer::longValue).toList();
log.info("Reset retry count: ids count={}", ids.size());
try {
int reset = batchFailedRecordService.resetRetryCount(ids);
return ResponseEntity.ok(Map.of(
"success", true,
"resetCount", reset,
"message", reset + "건의 실패 레코드 재시도 횟수가 초기화되었습니다"));
} catch (Exception e) {
log.error("Error resetting retry count", e);
return ResponseEntity.internalServerError().body(Map.of(
"success", false,
"message", "재시도 횟수 초기화 실패: " + e.getMessage()));
}
}
// 재수집 이력 CSV 내보내기 API
@Operation(summary = "재수집 이력 CSV 내보내기", description = "필터 조건으로 재수집 이력을 CSV 파일로 내보냅니다 (최대 10,000건)")
@GetMapping("/recollection-histories/export")
public void exportRecollectionHistories(
@Parameter(description = "API Key") @RequestParam(required = false) String apiKey,
@Parameter(description = "Job 이름") @RequestParam(required = false) String jobName,
@Parameter(description = "실행 상태") @RequestParam(required = false) String status,
@Parameter(description = "시작일 (yyyy-MM-dd'T'HH:mm:ss)") @RequestParam(required = false) String fromDate,
@Parameter(description = "종료일 (yyyy-MM-dd'T'HH:mm:ss)") @RequestParam(required = false) String toDate,
HttpServletResponse response) throws IOException {
log.info("Export recollection histories: apiKey={}, jobName={}, status={}", apiKey, jobName, status);
LocalDateTime from = fromDate != null ? LocalDateTime.parse(fromDate) : null;
LocalDateTime to = toDate != null ? LocalDateTime.parse(toDate) : null;
List<BatchRecollectionHistory> histories = recollectionHistoryService
.getHistoriesForExport(apiKey, jobName, status, from, to);
response.setContentType("text/csv; charset=UTF-8");
response.setHeader("Content-Disposition", "attachment; filename=recollection-histories.csv");
// BOM for Excel UTF-8
response.getOutputStream().write(new byte[]{(byte) 0xEF, (byte) 0xBB, (byte) 0xBF});
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
PrintWriter writer = response.getWriter();
writer.println("이력ID,API Key,작업명,Job실행ID,수집시작일,수집종료일,상태,실행시작,실행종료,소요시간(ms),읽기,쓰기,스킵,API호출,실행자,사유,실패사유,중복여부,생성일");
for (BatchRecollectionHistory h : histories) {
writer.println(String.join(",",
safeStr(h.getHistoryId()),
safeStr(h.getApiKey()),
safeStr(h.getJobName()),
safeStr(h.getJobExecutionId()),
h.getRangeFromDate() != null ? h.getRangeFromDate().format(formatter) : "",
h.getRangeToDate() != null ? h.getRangeToDate().format(formatter) : "",
safeStr(h.getExecutionStatus()),
h.getExecutionStartTime() != null ? h.getExecutionStartTime().format(formatter) : "",
h.getExecutionEndTime() != null ? h.getExecutionEndTime().format(formatter) : "",
safeStr(h.getDurationMs()),
safeStr(h.getReadCount()),
safeStr(h.getWriteCount()),
safeStr(h.getSkipCount()),
safeStr(h.getApiCallCount()),
escapeCsvField(h.getExecutor()),
escapeCsvField(h.getRecollectionReason()),
escapeCsvField(h.getFailureReason()),
h.getHasOverlap() != null ? (h.getHasOverlap() ? "Y" : "N") : "",
h.getCreatedAt() != null ? h.getCreatedAt().format(formatter) : ""
));
}
writer.flush();
}
private String safeStr(Object value) {
return value != null ? value.toString() : "";
}
private String escapeCsvField(String value) {
if (value == null) {
return "";
}
if (value.contains(",") || value.contains("\"") || value.contains("\n")) {
return "\"" + value.replace("\"", "\"\"") + "\"";
}
return value;
}
// Job 한글 표시명 관리 API
@Operation(summary = "Job 표시명 전체 조회", description = "등록된 모든 Job의 한글 표시명을 조회합니다")
@GetMapping("/display-names")
public ResponseEntity<List<JobDisplayNameEntity>> getDisplayNames() {
log.debug("Received request to get all display names");
return ResponseEntity.ok(jobDisplayNameRepository.findAll());
}
@Operation(summary = "Job 표시명 수정", description = "특정 Job의 한글 표시명을 수정합니다")
@PutMapping("/display-names/{jobName}")
public ResponseEntity<Map<String, Object>> updateDisplayName(
@Parameter(description = "배치 작업 이름", required = true) @PathVariable String jobName,
@RequestBody Map<String, String> request) {
log.info("Update display name: jobName={}", jobName);
try {
String displayName = request.get("displayName");
if (displayName == null || displayName.isBlank()) {
return ResponseEntity.badRequest().body(Map.of(
"success", false,
"message", "displayName은 필수입니다"));
}
JobDisplayNameEntity entity = jobDisplayNameRepository.findByJobName(jobName)
.orElseGet(() -> JobDisplayNameEntity.builder().jobName(jobName).build());
entity.setDisplayName(displayName);
jobDisplayNameRepository.save(entity);
batchService.refreshDisplayNameCache();
return ResponseEntity.ok(Map.of(
"success", true,
"message", "표시명이 수정되었습니다",
"data", Map.of("jobName", jobName, "displayName", displayName)));
} catch (Exception e) {
log.error("Error updating display name: jobName={}", jobName, e);
return ResponseEntity.internalServerError().body(Map.of(
"success", false,
"message", "표시명 수정 실패: " + e.getMessage()));
}
}
}

파일 보기

@ -0,0 +1,23 @@
package com.snp.batch.global.controller;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
/**
* SPA(React) fallback 라우터
*
* React Router가 클라이언트 사이드 라우팅을 처리하므로,
* 모든 프론트 경로를 index.html로 포워딩한다.
*/
@Controller
public class WebViewController {
@GetMapping({"/", "/dashboard", "/jobs", "/executions", "/executions/{id:\\d+}",
"/recollects", "/recollects/{id:\\d+}",
"/schedules", "/schedule-timeline", "/monitoring",
"/dashboard/**", "/jobs/**", "/executions/**", "/recollects/**",
"/schedules/**", "/schedule-timeline/**", "/monitoring/**"})
public String forward() {
return "forward:/index.html";
}
}

파일 보기

@ -0,0 +1,78 @@
package com.snp.batch.global.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.List;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class DashboardResponse {
private Stats stats;
private List<RunningJob> runningJobs;
private List<RecentExecution> recentExecutions;
private List<RecentFailure> recentFailures;
private int staleExecutionCount;
private FailureStats failureStats;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class Stats {
private int totalSchedules;
private int activeSchedules;
private int inactiveSchedules;
private int totalJobs;
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class RunningJob {
private String jobName;
private Long executionId;
private String status;
private LocalDateTime startTime;
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class RecentExecution {
private Long executionId;
private String jobName;
private String status;
private LocalDateTime startTime;
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;
}
}

파일 보기

@ -0,0 +1,31 @@
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 String displayName;
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;
}
}

파일 보기

@ -0,0 +1,160 @@
package com.snp.batch.global.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
/**
* Job 실행 상세 정보 DTO
* JobExecution + StepExecution 정보 포함
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class JobExecutionDetailDto {
// Job Execution 기본 정보
private Long executionId;
private String jobName;
private String status;
private LocalDateTime startTime;
private LocalDateTime endTime;
private String exitCode;
private String exitMessage;
// Job Parameters
private Map<String, Object> jobParameters;
// Job Instance 정보
private Long jobInstanceId;
// 실행 통계
private Long duration; // 실행 시간 (ms)
private Integer readCount;
private Integer writeCount;
private Integer skipCount;
private Integer filterCount;
// Step 실행 정보
private List<StepExecutionDto> stepExecutions;
/**
* Step 실행 정보 DTO
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class StepExecutionDto {
private Long stepExecutionId;
private String stepName;
private String status;
private LocalDateTime startTime;
private LocalDateTime endTime;
private Integer readCount;
private Integer writeCount;
private Integer commitCount;
private Integer rollbackCount;
private Integer readSkipCount;
private Integer processSkipCount;
private Integer writeSkipCount;
private Integer filterCount;
private String exitCode;
private String exitMessage;
private Long duration; // 실행 시간 (ms)
private ApiCallInfo apiCallInfo; // API 호출 정보 - StepExecutionContext 기반 (옵셔널)
private StepApiLogSummary apiLogSummary; // API 호출 로그 요약 - batch_api_log 기반 (옵셔널)
private List<FailedRecordDto> failedRecords; // 실패 레코드 (옵셔널)
}
/**
* API 호출 정보 DTO (StepExecutionContext 기반)
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class ApiCallInfo {
private String apiUrl; // API URL
private String method; // HTTP Method (GET, POST, etc.)
private Map<String, Object> parameters; // API 파라미터
private Integer totalCalls; // 전체 API 호출 횟수
private Integer completedCalls; // 완료된 API 호출 횟수
private String lastCallTime; // 마지막 호출 시간
}
/**
* Step별 API 로그 집계 요약 (batch_api_log 테이블 기반)
* 개별 로그 목록은 별도 API(/api/batch/steps/{id}/api-logs) 페이징 조회
*/
@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; // 반환 건수
}
/**
* 실패 레코드 DTO
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class FailedRecordDto {
private Long id;
private String jobName;
private String recordKey;
private String errorMessage;
private Integer retryCount;
private String status;
private LocalDateTime createdAt;
}
/**
* API 호출 로그 페이징 응답
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class ApiLogPageResponse {
private List<ApiLogEntryDto> content;
private int page;
private int size;
private long totalElements;
private int totalPages;
}
/**
* 개별 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;
}
}

파일 보기

@ -0,0 +1,24 @@
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 JobExecutionDto {
private Long executionId;
private String jobName;
private String status;
private LocalDateTime startTime;
private LocalDateTime endTime;
private String exitCode;
private String exitMessage;
private Long failedRecordCount;
}

파일 보기

@ -0,0 +1,16 @@
package com.snp.batch.global.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
import lombok.Setter;
import org.springframework.stereotype.Service;
@Getter
@Setter
public class JobLaunchRequest {
@Schema(description = "조회 시작일 (ISO 8601)", example = "2023-12-01T00:00:00Z")
private String startDate;
@Schema(description = "조회 종료일 (ISO 8601)", example = "2023-12-02T00:00:00Z")
private String stopDate;
}

파일 보기

@ -0,0 +1,21 @@
package com.snp.batch.global.dto;
import java.time.LocalDateTime;
/**
* 마지막 수집 성공일시 모니터링 응답 DTO
*
* @param apiKey API 식별 (: "EVENT_IMPORT_API")
* @param apiDesc API 설명 (사용자 표시용, : "해양사건 수집")
* @param lastSuccessDate 마지막 수집 성공 일시
* @param updatedAt 레코드 최종 수정 일시
* @param elapsedMinutes 현재 시각 기준 경과 시간()
*/
public record LastCollectionStatusResponse(
String apiKey,
String apiDesc,
LocalDateTime lastSuccessDate,
LocalDateTime updatedAt,
long elapsedMinutes
) {
}

파일 보기

@ -0,0 +1,46 @@
package com.snp.batch.global.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 스케줄 등록/수정 요청 DTO
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ScheduleRequest {
/**
* 배치 작업 이름
* : "jsonToPostgresJob", "shipDataImportJob"
*/
private String jobName;
/**
* Cron 표현식
* : "0 0 2 * * ?" (매일 새벽 2시)
* "0 0 * * * ?" ( 시간)
* "0 0/30 * * * ?" (30분마다)
*/
private String cronExpression;
/**
* 스케줄 설명 (선택)
*/
private String description;
/**
* 활성화 여부 (선택, 기본값 true)
*/
@Builder.Default
private Boolean active = true;
/**
* 생성자/수정자 정보 (선택)
*/
private String updatedBy;
}

파일 보기

@ -0,0 +1,80 @@
package com.snp.batch.global.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.Date;
/**
* 스케줄 조회 응답 DTO
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ScheduleResponse {
/**
* 스케줄 ID
*/
private Long id;
/**
* 배치 작업 이름
*/
private String jobName;
/**
* Cron 표현식
*/
private String cronExpression;
/**
* 스케줄 설명
*/
private String description;
/**
* 활성화 여부
*/
private Boolean active;
/**
* 다음 실행 예정 시간 (Quartz에서 계산)
*/
private Date nextFireTime;
/**
* 이전 실행 시간 (Quartz에서 조회)
*/
private Date previousFireTime;
/**
* Quartz Trigger 상태
* NORMAL, PAUSED, COMPLETE, ERROR, BLOCKED, NONE
*/
private String triggerState;
/**
* 생성 일시
*/
private LocalDateTime createdAt;
/**
* 수정 일시
*/
private LocalDateTime updatedAt;
/**
* 생성자
*/
private String createdBy;
/**
* 수정자
*/
private String updatedBy;
}

파일 보기

@ -0,0 +1,48 @@
package com.snp.batch.global.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
import java.util.Map;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class TimelineResponse {
private String periodLabel;
private List<PeriodInfo> periods;
private List<ScheduleTimeline> schedules;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class PeriodInfo {
private String key;
private String label;
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class ScheduleTimeline {
private String jobName;
private Map<String, ExecutionInfo> executions;
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class ExecutionInfo {
private Long executionId;
private String status;
private String startTime;
private String endTime;
}
}

파일 보기

@ -0,0 +1,46 @@
package com.snp.batch.global.model;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.CreationTimestamp;
import java.time.LocalDateTime;
@Entity
@Table(name = "batch_api_log", schema = "std_snp_data")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
public class BatchApiLog {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY) // PostgreSQL BIGSERIAL과 매핑
private Long logId;
@Column(name = "api_request_location") // job_name에서 변경
private String apiRequestLocation;
@Column(columnDefinition = "TEXT", nullable = false)
private String requestUri;
@Column(nullable = false, length = 10)
private String httpMethod;
private Integer statusCode;
private Long responseTimeMs;
@Column(name = "response_count")
private Long responseCount;
@Column(columnDefinition = "TEXT")
private String errorMessage;
@CreationTimestamp // 엔티티가 생성될 자동으로 시간 설정
@Column(updatable = false)
private LocalDateTime createdAt;
private Long jobExecutionId; // 추가
private Long stepExecutionId; // 추가
}

파일 보기

@ -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;
}
}

Some files were not shown because too many files have changed in this diff Show More