Merge pull request 'fix: ChnPrmShip 캐시 갱신 조건 완화 및 스케줄 이전 실행 시간 표시' (#3) from feature/bugfix-cache-schedule into develop
Reviewed-on: #3
This commit is contained in:
커밋
8755a92f34
@ -16,8 +16,8 @@ else
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 컴파일 검증 (테스트 제외, 오프라인 가능)
|
||||
$MVN compile -q -DskipTests 2>&1
|
||||
# 컴파일 검증 (테스트 제외, 프론트엔드 빌드 제외)
|
||||
$MVN compile -q -DskipTests -Dskip.npm -Dskip.installnodenpm 2>&1
|
||||
RESULT=$?
|
||||
|
||||
if [ $RESULT -ne 0 ]; then
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@ -95,6 +95,13 @@ application-local.yml
|
||||
logs/
|
||||
*.log.*
|
||||
|
||||
# Frontend (Vite + React)
|
||||
frontend/node_modules/
|
||||
frontend/node/
|
||||
src/main/resources/static/assets/
|
||||
src/main/resources/static/index.html
|
||||
src/main/resources/static/vite.svg
|
||||
|
||||
# Claude Code (개인 파일만 무시, 팀 파일은 추적)
|
||||
.claude/settings.local.json
|
||||
.claude/scripts/
|
||||
|
||||
105
README.md
Normal file
105
README.md
Normal file
@ -0,0 +1,105 @@
|
||||
# SNP-Batch (snp-batch-validation)
|
||||
|
||||
해양 데이터 통합 배치 시스템. Maritime API에서 선박/항만/사건 데이터를 수집하여 PostgreSQL에 저장하고, AIS 실시간 위치정보를 캐시 기반으로 서비스합니다.
|
||||
|
||||
## 기술 스택
|
||||
|
||||
- Java 17, Spring Boot 3.2.1, Spring Batch 5.1.0
|
||||
- PostgreSQL, Quartz Scheduler, Caffeine Cache
|
||||
- React 19 + Vite + Tailwind CSS 4 (관리 UI)
|
||||
- frontend-maven-plugin (프론트엔드 빌드 통합)
|
||||
|
||||
## 사전 요구사항
|
||||
|
||||
| 항목 | 버전 | 비고 |
|
||||
|------|------|------|
|
||||
| JDK | 17 | `.sdkmanrc` 참조 (`sdk env`) |
|
||||
| Maven | 3.9+ | |
|
||||
| Node.js | 20+ | 프론트엔드 빌드용 |
|
||||
| npm | 10+ | Node.js에 포함 |
|
||||
|
||||
## 빌드
|
||||
|
||||
> **주의**: frontend-maven-plugin의 Node 호환성 문제로, 프론트엔드와 백엔드를 분리하여 빌드합니다.
|
||||
|
||||
### 터미널
|
||||
|
||||
```bash
|
||||
# 1. 프론트엔드 빌드
|
||||
cd frontend && npm install && npm run build && cd ..
|
||||
|
||||
# 2. Maven 패키징 (프론트엔드 빌드 스킵)
|
||||
mvn clean package -DskipTests -Dskip.npm -Dskip.installnodenpm
|
||||
```
|
||||
|
||||
빌드 결과: `target/snp-batch-validation-1.0.0.jar`
|
||||
|
||||
### VSCode
|
||||
|
||||
`Cmd+Shift+B` (기본 빌드 태스크) → 프론트엔드 빌드 + Maven 패키징 순차 실행
|
||||
|
||||
개별 태스크: `Cmd+Shift+P` → "Tasks: Run Task" → 태스크 선택
|
||||
|
||||
> 태스크 설정: [.vscode/tasks.json](.vscode/tasks.json)
|
||||
|
||||
### IntelliJ IDEA
|
||||
|
||||
1. **프론트엔드 빌드**: Terminal 탭에서 `cd frontend && npm run build`
|
||||
2. **Maven 패키징**: Maven 패널 → Lifecycle → `package`
|
||||
- VM Options: `-DskipTests -Dskip.npm -Dskip.installnodenpm`
|
||||
- 또는 Run Configuration → Maven → Command line에 `clean package -DskipTests -Dskip.npm -Dskip.installnodenpm`
|
||||
|
||||
## 로컬 실행
|
||||
|
||||
### 터미널
|
||||
|
||||
```bash
|
||||
mvn spring-boot:run -Dspring-boot.run.profiles=local
|
||||
```
|
||||
|
||||
### VSCode
|
||||
|
||||
Run/Debug 패널(F5) → "SNP-Batch (local)" 선택
|
||||
|
||||
> 실행 설정: [.vscode/launch.json](.vscode/launch.json)
|
||||
|
||||
### IntelliJ IDEA
|
||||
|
||||
Run Configuration → Spring Boot:
|
||||
- Main class: `com.snp.batch.SnpBatchApplication`
|
||||
- Active profiles: `local`
|
||||
|
||||
## 서버 배포
|
||||
|
||||
```bash
|
||||
# 1. 빌드 (위 빌드 절차 수행)
|
||||
|
||||
# 2. JAR 전송
|
||||
scp target/snp-batch-validation-1.0.0.jar {서버}:{경로}/
|
||||
|
||||
# 3. 실행
|
||||
java -jar snp-batch-validation-1.0.0.jar --spring.profiles.active=dev
|
||||
```
|
||||
|
||||
## 접속 정보
|
||||
|
||||
| 항목 | URL |
|
||||
|------|-----|
|
||||
| 관리 UI | `http://localhost:8041/snp-api/` |
|
||||
| Swagger | `http://localhost:8041/snp-api/swagger-ui/index.html` |
|
||||
|
||||
## 프로파일
|
||||
|
||||
| 프로파일 | 용도 | DB |
|
||||
|----------|------|----|
|
||||
| `local` | 로컬 개발 | 개발 DB |
|
||||
| `dev` | 개발 서버 | 개발 DB |
|
||||
| `prod` | 운영 서버 | 운영 DB |
|
||||
|
||||
## Maven 빌드 플래그 요약
|
||||
|
||||
| 플래그 | 용도 |
|
||||
|--------|------|
|
||||
| `-DskipTests` | 테스트 스킵 |
|
||||
| `-Dskip.npm` | npm install/build 스킵 |
|
||||
| `-Dskip.installnodenpm` | Node/npm 자동 설치 스킵 |
|
||||
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
73
frontend/README.md
Normal file
73
frontend/README.md
Normal file
@ -0,0 +1,73 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
23
frontend/eslint.config.js
Normal file
23
frontend/eslint.config.js
Normal file
@ -0,0 +1,23 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>frontend</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
3935
frontend/package-lock.json
generated
Normal file
3935
frontend/package-lock.json
generated
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
33
frontend/package.json
Normal file
33
frontend/package.json
Normal file
@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-router-dom": "^7.13.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.48.0",
|
||||
"vite": "^7.3.1"
|
||||
}
|
||||
}
|
||||
49
frontend/src/App.tsx
Normal file
49
frontend/src/App.tsx
Normal file
@ -0,0 +1,49 @@
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||
import { ToastProvider, useToastContext } from './contexts/ToastContext';
|
||||
import { ThemeProvider } from './contexts/ThemeContext';
|
||||
import Navbar from './components/Navbar';
|
||||
import ToastContainer from './components/Toast';
|
||||
import LoadingSpinner from './components/LoadingSpinner';
|
||||
|
||||
const Dashboard = lazy(() => import('./pages/Dashboard'));
|
||||
const Jobs = lazy(() => import('./pages/Jobs'));
|
||||
const Executions = lazy(() => import('./pages/Executions'));
|
||||
const ExecutionDetail = lazy(() => import('./pages/ExecutionDetail'));
|
||||
const Schedules = lazy(() => import('./pages/Schedules'));
|
||||
const Timeline = lazy(() => import('./pages/Timeline'));
|
||||
|
||||
function AppLayout() {
|
||||
const { toasts, removeToast } = useToastContext();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-wing-bg text-wing-text">
|
||||
<div className="max-w-7xl mx-auto px-4 py-6">
|
||||
<Navbar />
|
||||
<Suspense fallback={<LoadingSpinner />}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/jobs" element={<Jobs />} />
|
||||
<Route path="/executions" element={<Executions />} />
|
||||
<Route path="/executions/:id" element={<ExecutionDetail />} />
|
||||
<Route path="/schedules" element={<Schedules />} />
|
||||
<Route path="/schedule-timeline" element={<Timeline />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</div>
|
||||
<ToastContainer toasts={toasts} onRemove={removeToast} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<BrowserRouter basename="/snp-api">
|
||||
<ToastProvider>
|
||||
<AppLayout />
|
||||
</ToastProvider>
|
||||
</BrowserRouter>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
308
frontend/src/api/batchApi.ts
Normal file
308
frontend/src/api/batchApi.ts
Normal file
@ -0,0 +1,308 @@
|
||||
const BASE = import.meta.env.DEV ? '/snp-api/api/batch' : '/snp-api/api/batch';
|
||||
|
||||
async function fetchJson<T>(url: string): Promise<T> {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`API Error: ${res.status} ${res.statusText}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function postJson<T>(url: string, body?: unknown): Promise<T> {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
if (!res.ok) throw new Error(`API Error: ${res.status} ${res.statusText}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ── Dashboard ────────────────────────────────────────────────
|
||||
|
||||
export interface DashboardStats {
|
||||
totalSchedules: number;
|
||||
activeSchedules: number;
|
||||
inactiveSchedules: number;
|
||||
totalJobs: number;
|
||||
}
|
||||
|
||||
export interface RunningJob {
|
||||
jobName: string;
|
||||
executionId: number;
|
||||
status: string;
|
||||
startTime: string;
|
||||
}
|
||||
|
||||
export interface RecentExecution {
|
||||
executionId: number;
|
||||
jobName: string;
|
||||
status: string;
|
||||
startTime: string;
|
||||
endTime: string | null;
|
||||
}
|
||||
|
||||
export interface RecentFailure {
|
||||
executionId: number;
|
||||
jobName: string;
|
||||
status: string;
|
||||
startTime: string;
|
||||
endTime: string | null;
|
||||
exitMessage: string | null;
|
||||
}
|
||||
|
||||
export interface FailureStats {
|
||||
last24h: number;
|
||||
last7d: number;
|
||||
}
|
||||
|
||||
export interface DashboardResponse {
|
||||
stats: DashboardStats;
|
||||
runningJobs: RunningJob[];
|
||||
recentExecutions: RecentExecution[];
|
||||
recentFailures: RecentFailure[];
|
||||
staleExecutionCount: number;
|
||||
failureStats: FailureStats;
|
||||
}
|
||||
|
||||
// ── Job Execution ────────────────────────────────────────────
|
||||
|
||||
export interface JobExecutionDto {
|
||||
executionId: number;
|
||||
jobName: string;
|
||||
status: string;
|
||||
startTime: string;
|
||||
endTime: string | null;
|
||||
exitCode: string | null;
|
||||
exitMessage: string | null;
|
||||
}
|
||||
|
||||
export interface ApiCallInfo {
|
||||
apiUrl: string;
|
||||
method: string;
|
||||
parameters: Record<string, unknown> | null;
|
||||
totalCalls: number;
|
||||
completedCalls: number;
|
||||
lastCallTime: string;
|
||||
}
|
||||
|
||||
export interface StepExecutionDto {
|
||||
stepExecutionId: number;
|
||||
stepName: string;
|
||||
status: string;
|
||||
startTime: string;
|
||||
endTime: string | null;
|
||||
readCount: number;
|
||||
writeCount: number;
|
||||
commitCount: number;
|
||||
rollbackCount: number;
|
||||
readSkipCount: number;
|
||||
processSkipCount: number;
|
||||
writeSkipCount: number;
|
||||
filterCount: number;
|
||||
exitCode: string;
|
||||
exitMessage: string | null;
|
||||
duration: number | null;
|
||||
apiCallInfo: ApiCallInfo | null;
|
||||
}
|
||||
|
||||
export interface JobExecutionDetailDto {
|
||||
executionId: number;
|
||||
jobName: string;
|
||||
status: string;
|
||||
startTime: string;
|
||||
endTime: string | null;
|
||||
exitCode: string;
|
||||
exitMessage: string | null;
|
||||
jobParameters: Record<string, string>;
|
||||
jobInstanceId: number;
|
||||
duration: number | null;
|
||||
readCount: number;
|
||||
writeCount: number;
|
||||
skipCount: number;
|
||||
filterCount: number;
|
||||
stepExecutions: StepExecutionDto[];
|
||||
}
|
||||
|
||||
// ── Schedule ─────────────────────────────────────────────────
|
||||
|
||||
export interface ScheduleResponse {
|
||||
id: number;
|
||||
jobName: string;
|
||||
cronExpression: string;
|
||||
description: string | null;
|
||||
active: boolean;
|
||||
nextFireTime: string | null;
|
||||
previousFireTime: string | null;
|
||||
triggerState: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ScheduleRequest {
|
||||
jobName: string;
|
||||
cronExpression: string;
|
||||
description?: string;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
// ── Timeline ─────────────────────────────────────────────────
|
||||
|
||||
export interface PeriodInfo {
|
||||
key: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface ExecutionInfo {
|
||||
executionId: number | null;
|
||||
status: string;
|
||||
startTime: string | null;
|
||||
endTime: string | null;
|
||||
}
|
||||
|
||||
export interface ScheduleTimeline {
|
||||
jobName: string;
|
||||
executions: Record<string, ExecutionInfo | null>;
|
||||
}
|
||||
|
||||
export interface TimelineResponse {
|
||||
periodLabel: string;
|
||||
periods: PeriodInfo[];
|
||||
schedules: ScheduleTimeline[];
|
||||
}
|
||||
|
||||
// ── F4: Execution Search ─────────────────────────────────────
|
||||
|
||||
export interface ExecutionSearchResponse {
|
||||
executions: JobExecutionDto[];
|
||||
totalCount: number;
|
||||
page: number;
|
||||
size: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
// ── F7: Job Detail ───────────────────────────────────────────
|
||||
|
||||
export interface LastExecution {
|
||||
executionId: number;
|
||||
status: string;
|
||||
startTime: string;
|
||||
endTime: string | null;
|
||||
}
|
||||
|
||||
export interface JobDetailDto {
|
||||
jobName: string;
|
||||
lastExecution: LastExecution | null;
|
||||
scheduleCron: string | null;
|
||||
}
|
||||
|
||||
// ── F8: Statistics ───────────────────────────────────────────
|
||||
|
||||
export interface DailyStat {
|
||||
date: string;
|
||||
successCount: number;
|
||||
failedCount: number;
|
||||
otherCount: number;
|
||||
avgDurationMs: number;
|
||||
}
|
||||
|
||||
export interface ExecutionStatisticsDto {
|
||||
dailyStats: DailyStat[];
|
||||
totalExecutions: number;
|
||||
totalSuccess: number;
|
||||
totalFailed: number;
|
||||
avgDurationMs: number;
|
||||
}
|
||||
|
||||
// ── 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>) =>
|
||||
postJson<{ success: boolean; message: string; executionId?: number }>(
|
||||
`${BASE}/jobs/${jobName}/execute`, params),
|
||||
|
||||
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}`),
|
||||
};
|
||||
74
frontend/src/components/BarChart.tsx
Normal file
74
frontend/src/components/BarChart.tsx
Normal file
@ -0,0 +1,74 @@
|
||||
interface BarValue {
|
||||
color: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
interface BarData {
|
||||
label: string;
|
||||
values: BarValue[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
data: BarData[];
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export default function BarChart({ data, height = 200 }: Props) {
|
||||
const maxTotal = Math.max(...data.map((d) => d.values.reduce((sum, v) => sum + v.value, 0)), 1);
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="flex items-end gap-1" style={{ height }}>
|
||||
{data.map((bar, i) => {
|
||||
const total = bar.values.reduce((sum, v) => sum + v.value, 0);
|
||||
const ratio = total / maxTotal;
|
||||
|
||||
return (
|
||||
<div key={i} className="flex-1 flex flex-col justify-end h-full min-w-0">
|
||||
<div
|
||||
className="w-full rounded-t overflow-hidden"
|
||||
style={{ height: `${ratio * 100}%` }}
|
||||
title={bar.values.map((v) => `${v.color}: ${v.value}`).join(', ')}
|
||||
>
|
||||
{bar.values
|
||||
.filter((v) => v.value > 0)
|
||||
.map((v, j) => {
|
||||
const segmentRatio = total > 0 ? (v.value / total) * 100 : 0;
|
||||
return (
|
||||
<div
|
||||
key={j}
|
||||
className={colorToClass(v.color)}
|
||||
style={{ height: `${segmentRatio}%` }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex gap-1 mt-1">
|
||||
{data.map((bar, i) => (
|
||||
<div key={i} className="flex-1 min-w-0">
|
||||
<p className="text-[10px] text-gray-500 text-center truncate" title={bar.label}>
|
||||
{bar.label}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function colorToClass(color: string): string {
|
||||
const map: Record<string, string> = {
|
||||
green: 'bg-green-500',
|
||||
red: 'bg-red-500',
|
||||
gray: 'bg-gray-400',
|
||||
blue: 'bg-blue-500',
|
||||
yellow: 'bg-yellow-500',
|
||||
orange: 'bg-orange-500',
|
||||
indigo: 'bg-indigo-500',
|
||||
};
|
||||
return map[color] ?? color;
|
||||
}
|
||||
49
frontend/src/components/ConfirmModal.tsx
Normal file
49
frontend/src/components/ConfirmModal.tsx
Normal file
@ -0,0 +1,49 @@
|
||||
interface Props {
|
||||
open: boolean;
|
||||
title?: string;
|
||||
message: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
confirmColor?: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export default function ConfirmModal({
|
||||
open,
|
||||
title = '확인',
|
||||
message,
|
||||
confirmLabel = '확인',
|
||||
cancelLabel = '취소',
|
||||
confirmColor = 'bg-wing-accent hover:bg-wing-accent/80',
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: Props) {
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-wing-overlay" onClick={onCancel}>
|
||||
<div
|
||||
className="bg-wing-surface rounded-xl shadow-2xl p-6 max-w-md w-full mx-4"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h3 className="text-lg font-semibold text-wing-text mb-2">{title}</h3>
|
||||
<p className="text-wing-muted text-sm mb-6 whitespace-pre-line">{message}</p>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 text-sm font-medium text-wing-text bg-wing-card rounded-lg hover:bg-wing-hover transition-colors"
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className={`px-4 py-2 text-sm font-medium text-white rounded-lg transition-colors ${confirmColor}`}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
15
frontend/src/components/EmptyState.tsx
Normal file
15
frontend/src/components/EmptyState.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
interface Props {
|
||||
icon?: string;
|
||||
message: string;
|
||||
sub?: string;
|
||||
}
|
||||
|
||||
export default function EmptyState({ icon = '📭', message, sub }: Props) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-wing-muted">
|
||||
<span className="text-4xl mb-3">{icon}</span>
|
||||
<p className="text-sm font-medium">{message}</p>
|
||||
{sub && <p className="text-xs mt-1">{sub}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
35
frontend/src/components/InfoModal.tsx
Normal file
35
frontend/src/components/InfoModal.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
interface Props {
|
||||
open: boolean;
|
||||
title?: string;
|
||||
children: React.ReactNode;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function InfoModal({
|
||||
open,
|
||||
title = '정보',
|
||||
children,
|
||||
onClose,
|
||||
}: Props) {
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-wing-overlay" onClick={onClose}>
|
||||
<div
|
||||
className="bg-wing-surface rounded-xl shadow-2xl p-6 max-w-lg w-full mx-4"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h3 className="text-lg font-semibold text-wing-text mb-4">{title}</h3>
|
||||
<div className="text-wing-muted text-sm mb-6">{children}</div>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium text-wing-text bg-wing-card rounded-lg hover:bg-wing-hover transition-colors"
|
||||
>
|
||||
닫기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
7
frontend/src/components/LoadingSpinner.tsx
Normal file
7
frontend/src/components/LoadingSpinner.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
export default function LoadingSpinner({ className = '' }: { className?: string }) {
|
||||
return (
|
||||
<div className={`flex items-center justify-center py-12 ${className}`}>
|
||||
<div className="w-8 h-8 border-4 border-wing-accent/30 border-t-wing-accent rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
54
frontend/src/components/Navbar.tsx
Normal file
54
frontend/src/components/Navbar.tsx
Normal file
@ -0,0 +1,54 @@
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { useThemeContext } from '../contexts/ThemeContext';
|
||||
|
||||
const navItems = [
|
||||
{ path: '/', label: '대시보드', icon: '📊' },
|
||||
{ path: '/jobs', label: '작업', icon: '⚙️' },
|
||||
{ path: '/executions', label: '실행 이력', icon: '📋' },
|
||||
{ path: '/schedules', label: '스케줄', icon: '🕐' },
|
||||
{ path: '/schedule-timeline', label: '타임라인', icon: '📅' },
|
||||
];
|
||||
|
||||
export default function Navbar() {
|
||||
const location = useLocation();
|
||||
const { theme, toggle } = useThemeContext();
|
||||
|
||||
const isActive = (path: string) => {
|
||||
if (path === '/') return location.pathname === '/';
|
||||
return location.pathname.startsWith(path);
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="bg-wing-glass-dense backdrop-blur-sm shadow-md rounded-xl mb-6 px-4 py-3 border border-wing-border">
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<Link to="/" className="text-lg font-bold text-wing-accent no-underline">
|
||||
S&P 배치 관리
|
||||
</Link>
|
||||
<div className="flex gap-1 flex-wrap items-center">
|
||||
{navItems.map((item) => (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium no-underline transition-colors
|
||||
${isActive(item.path)
|
||||
? 'bg-wing-accent text-white'
|
||||
: 'text-wing-muted hover:bg-wing-hover hover:text-wing-accent'
|
||||
}`}
|
||||
>
|
||||
<span className="mr-1">{item.icon}</span>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
<button
|
||||
onClick={toggle}
|
||||
className="ml-2 px-2.5 py-1.5 rounded-lg text-sm bg-wing-card text-wing-muted
|
||||
hover:text-wing-text border border-wing-border transition-colors"
|
||||
title={theme === 'dark' ? '라이트 모드' : '다크 모드'}
|
||||
>
|
||||
{theme === 'dark' ? '☀️' : '🌙'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
40
frontend/src/components/StatusBadge.tsx
Normal file
40
frontend/src/components/StatusBadge.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
const STATUS_CONFIG: Record<string, { bg: string; text: string; label: string }> = {
|
||||
COMPLETED: { bg: 'bg-emerald-100 text-emerald-700', text: '완료', label: '✓' },
|
||||
FAILED: { bg: 'bg-red-100 text-red-700', text: '실패', label: '✕' },
|
||||
STARTED: { bg: 'bg-blue-100 text-blue-700', text: '실행중', label: '↻' },
|
||||
STARTING: { bg: 'bg-cyan-100 text-cyan-700', text: '시작중', label: '⏳' },
|
||||
STOPPED: { bg: 'bg-amber-100 text-amber-700', text: '중지됨', label: '⏸' },
|
||||
STOPPING: { bg: 'bg-orange-100 text-orange-700', text: '중지중', label: '⏸' },
|
||||
ABANDONED: { bg: 'bg-gray-100 text-gray-700', text: '포기됨', label: '—' },
|
||||
SCHEDULED: { bg: 'bg-violet-100 text-violet-700', text: '예정', label: '🕐' },
|
||||
UNKNOWN: { bg: 'bg-gray-100 text-gray-500', text: '알수없음', label: '?' },
|
||||
};
|
||||
|
||||
interface Props {
|
||||
status: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function StatusBadge({ status, className = '' }: Props) {
|
||||
const config = STATUS_CONFIG[status] || STATUS_CONFIG.UNKNOWN;
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-semibold ${config.bg} ${className}`}>
|
||||
<span>{config.label}</span>
|
||||
{config.text}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export function getStatusColor(status: string): string {
|
||||
switch (status) {
|
||||
case 'COMPLETED': return '#10b981';
|
||||
case 'FAILED': return '#ef4444';
|
||||
case 'STARTED': return '#3b82f6';
|
||||
case 'STARTING': return '#06b6d4';
|
||||
case 'STOPPED': return '#f59e0b';
|
||||
case 'STOPPING': return '#f97316';
|
||||
case 'SCHEDULED': return '#8b5cf6';
|
||||
default: return '#6b7280';
|
||||
}
|
||||
}
|
||||
37
frontend/src/components/Toast.tsx
Normal file
37
frontend/src/components/Toast.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import type { Toast as ToastType } from '../hooks/useToast';
|
||||
|
||||
const TYPE_STYLES: Record<ToastType['type'], string> = {
|
||||
success: 'bg-emerald-500',
|
||||
error: 'bg-red-500',
|
||||
warning: 'bg-amber-500',
|
||||
info: 'bg-blue-500',
|
||||
};
|
||||
|
||||
interface Props {
|
||||
toasts: ToastType[];
|
||||
onRemove: (id: number) => void;
|
||||
}
|
||||
|
||||
export default function ToastContainer({ toasts, onRemove }: Props) {
|
||||
if (toasts.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed top-4 right-4 z-50 flex flex-col gap-2 max-w-sm">
|
||||
{toasts.map((toast) => (
|
||||
<div
|
||||
key={toast.id}
|
||||
className={`${TYPE_STYLES[toast.type]} text-white px-4 py-3 rounded-lg shadow-lg
|
||||
flex items-center justify-between gap-3 animate-slide-in`}
|
||||
>
|
||||
<span className="text-sm">{toast.message}</span>
|
||||
<button
|
||||
onClick={() => onRemove(toast.id)}
|
||||
className="text-white/80 hover:text-white text-lg leading-none"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
26
frontend/src/contexts/ThemeContext.tsx
Normal file
26
frontend/src/contexts/ThemeContext.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import { createContext, useContext, type ReactNode } from 'react';
|
||||
import { useTheme } from '../hooks/useTheme';
|
||||
|
||||
interface ThemeContextValue {
|
||||
theme: 'dark' | 'light';
|
||||
toggle: () => void;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue>({
|
||||
theme: 'dark',
|
||||
toggle: () => {},
|
||||
});
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const value = useTheme();
|
||||
return (
|
||||
<ThemeContext.Provider value={value}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export function useThemeContext() {
|
||||
return useContext(ThemeContext);
|
||||
}
|
||||
29
frontend/src/contexts/ToastContext.tsx
Normal file
29
frontend/src/contexts/ToastContext.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
import { createContext, useContext, type ReactNode } from 'react';
|
||||
import { useToast, type Toast } from '../hooks/useToast';
|
||||
|
||||
interface ToastContextValue {
|
||||
toasts: Toast[];
|
||||
showToast: (message: string, type?: Toast['type']) => void;
|
||||
removeToast: (id: number) => void;
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextValue | null>(null);
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const { toasts, showToast, removeToast } = useToast();
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ toasts, showToast, removeToast }}>
|
||||
{children}
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export function useToastContext(): ToastContextValue {
|
||||
const ctx = useContext(ToastContext);
|
||||
if (!ctx) {
|
||||
throw new Error('useToastContext must be used within a ToastProvider');
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
53
frontend/src/hooks/usePoller.ts
Normal file
53
frontend/src/hooks/usePoller.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
/**
|
||||
* 주기적 폴링 훅
|
||||
* - 마운트 시 즉시 1회 실행 후 intervalMs 주기로 반복
|
||||
* - 탭 비활성(document.hidden) 시 자동 중단, 활성화 시 즉시 재개
|
||||
* - deps 변경 시 타이머 재설정
|
||||
*/
|
||||
export function usePoller(
|
||||
fn: () => Promise<void> | void,
|
||||
intervalMs: number,
|
||||
deps: unknown[] = [],
|
||||
) {
|
||||
const fnRef = useRef(fn);
|
||||
fnRef.current = fn;
|
||||
|
||||
useEffect(() => {
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const run = () => {
|
||||
fnRef.current();
|
||||
};
|
||||
|
||||
const start = () => {
|
||||
run();
|
||||
timer = setInterval(run, intervalMs);
|
||||
};
|
||||
|
||||
const stop = () => {
|
||||
if (timer) {
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleVisibility = () => {
|
||||
if (document.hidden) {
|
||||
stop();
|
||||
} else {
|
||||
start();
|
||||
}
|
||||
};
|
||||
|
||||
start();
|
||||
document.addEventListener('visibilitychange', handleVisibility);
|
||||
|
||||
return () => {
|
||||
stop();
|
||||
document.removeEventListener('visibilitychange', handleVisibility);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [intervalMs, ...deps]);
|
||||
}
|
||||
27
frontend/src/hooks/useTheme.ts
Normal file
27
frontend/src/hooks/useTheme.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
type Theme = 'dark' | 'light';
|
||||
|
||||
const STORAGE_KEY = 'snp-batch-theme';
|
||||
|
||||
function getInitialTheme(): Theme {
|
||||
if (typeof window === 'undefined') return 'dark';
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored === 'light' || stored === 'dark') return stored;
|
||||
return 'dark';
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const [theme, setTheme] = useState<Theme>(getInitialTheme);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
localStorage.setItem(STORAGE_KEY, theme);
|
||||
}, [theme]);
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
setTheme((prev) => (prev === 'dark' ? 'light' : 'dark'));
|
||||
}, []);
|
||||
|
||||
return { theme, toggle } as const;
|
||||
}
|
||||
27
frontend/src/hooks/useToast.ts
Normal file
27
frontend/src/hooks/useToast.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
export interface Toast {
|
||||
id: number;
|
||||
message: string;
|
||||
type: 'success' | 'error' | 'warning' | 'info';
|
||||
}
|
||||
|
||||
let nextId = 0;
|
||||
|
||||
export function useToast() {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
|
||||
const showToast = useCallback((message: string, type: Toast['type'] = 'info') => {
|
||||
const id = nextId++;
|
||||
setToasts((prev) => [...prev, { id, message, type }]);
|
||||
setTimeout(() => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
}, 5000);
|
||||
}, []);
|
||||
|
||||
const removeToast = useCallback((id: number) => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
}, []);
|
||||
|
||||
return { toasts, showToast, removeToast };
|
||||
}
|
||||
3
frontend/src/index.css
Normal file
3
frontend/src/index.css
Normal file
@ -0,0 +1,3 @@
|
||||
@import "tailwindcss";
|
||||
@import "./theme/tokens.css";
|
||||
@import "./theme/base.css";
|
||||
10
frontend/src/main.tsx
Normal file
10
frontend/src/main.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
507
frontend/src/pages/Dashboard.tsx
Normal file
507
frontend/src/pages/Dashboard.tsx
Normal file
@ -0,0 +1,507 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
batchApi,
|
||||
type DashboardResponse,
|
||||
type DashboardStats,
|
||||
type ExecutionStatisticsDto,
|
||||
} from '../api/batchApi';
|
||||
import { usePoller } from '../hooks/usePoller';
|
||||
import { useToastContext } from '../contexts/ToastContext';
|
||||
import StatusBadge from '../components/StatusBadge';
|
||||
import EmptyState from '../components/EmptyState';
|
||||
import LoadingSpinner from '../components/LoadingSpinner';
|
||||
import BarChart from '../components/BarChart';
|
||||
import { formatDateTime, calculateDuration } from '../utils/formatters';
|
||||
|
||||
const POLLING_INTERVAL = 5000;
|
||||
|
||||
interface StatCardProps {
|
||||
label: string;
|
||||
value: number;
|
||||
gradient: string;
|
||||
to?: string;
|
||||
}
|
||||
|
||||
function StatCard({ label, value, gradient, to }: StatCardProps) {
|
||||
const content = (
|
||||
<div
|
||||
className={`${gradient} rounded-xl shadow-md p-6 text-white
|
||||
hover:shadow-lg hover:-translate-y-0.5 transition-all cursor-pointer`}
|
||||
>
|
||||
<p className="text-3xl font-bold">{value}</p>
|
||||
<p className="text-sm mt-1 opacity-90">{label}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (to) {
|
||||
return <Link to={to} className="no-underline">{content}</Link>;
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const { showToast } = useToastContext();
|
||||
const [dashboard, setDashboard] = useState<DashboardResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Execute Job modal
|
||||
const [showExecuteModal, setShowExecuteModal] = useState(false);
|
||||
const [jobs, setJobs] = useState<string[]>([]);
|
||||
const [selectedJob, setSelectedJob] = useState('');
|
||||
const [executing, setExecuting] = useState(false);
|
||||
const [startDate, setStartDate] = useState('');
|
||||
const [stopDate, setStopDate] = useState('');
|
||||
const [abandoning, setAbandoning] = useState(false);
|
||||
const [statistics, setStatistics] = useState<ExecutionStatisticsDto | null>(null);
|
||||
|
||||
const loadStatistics = useCallback(async () => {
|
||||
try {
|
||||
const data = await batchApi.getStatistics(30);
|
||||
setStatistics(data);
|
||||
} catch {
|
||||
/* 통계 로드 실패는 무시 */
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadStatistics();
|
||||
}, [loadStatistics]);
|
||||
|
||||
const loadDashboard = useCallback(async () => {
|
||||
try {
|
||||
const data = await batchApi.getDashboard();
|
||||
setDashboard(data);
|
||||
} catch (err) {
|
||||
console.error('Dashboard load failed:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
usePoller(loadDashboard, POLLING_INTERVAL);
|
||||
|
||||
const handleOpenExecuteModal = async () => {
|
||||
try {
|
||||
const jobList = await batchApi.getJobs();
|
||||
setJobs(jobList);
|
||||
setSelectedJob(jobList[0] ?? '');
|
||||
setShowExecuteModal(true);
|
||||
} catch (err) {
|
||||
showToast('작업 목록을 불러올 수 없습니다.', 'error');
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExecuteJob = async () => {
|
||||
if (!selectedJob) return;
|
||||
setExecuting(true);
|
||||
try {
|
||||
const params: Record<string, string> = {};
|
||||
if (startDate) params.startDate = startDate;
|
||||
if (stopDate) params.stopDate = stopDate;
|
||||
const result = await batchApi.executeJob(
|
||||
selectedJob,
|
||||
Object.keys(params).length > 0 ? params : undefined,
|
||||
);
|
||||
showToast(
|
||||
result.message || `${selectedJob} 실행 요청 완료`,
|
||||
'success',
|
||||
);
|
||||
setShowExecuteModal(false);
|
||||
setStartDate('');
|
||||
setStopDate('');
|
||||
await loadDashboard();
|
||||
} catch (err) {
|
||||
showToast(`실행 실패: ${err instanceof Error ? err.message : '알 수 없는 오류'}`, 'error');
|
||||
} finally {
|
||||
setExecuting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAbandonAllStale = async () => {
|
||||
setAbandoning(true);
|
||||
try {
|
||||
const result = await batchApi.abandonAllStale();
|
||||
showToast(
|
||||
result.message || `${result.abandonedCount ?? 0}건 강제 종료 완료`,
|
||||
'success',
|
||||
);
|
||||
await loadDashboard();
|
||||
} catch (err) {
|
||||
showToast(
|
||||
`강제 종료 실패: ${err instanceof Error ? err.message : '알 수 없는 오류'}`,
|
||||
'error',
|
||||
);
|
||||
} finally {
|
||||
setAbandoning(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <LoadingSpinner />;
|
||||
|
||||
const stats: DashboardStats = dashboard?.stats ?? {
|
||||
totalSchedules: 0,
|
||||
activeSchedules: 0,
|
||||
inactiveSchedules: 0,
|
||||
totalJobs: 0,
|
||||
};
|
||||
|
||||
const runningJobs = dashboard?.runningJobs ?? [];
|
||||
const recentExecutions = dashboard?.recentExecutions ?? [];
|
||||
const recentFailures = dashboard?.recentFailures ?? [];
|
||||
const staleExecutionCount = dashboard?.staleExecutionCount ?? 0;
|
||||
const failureStats = dashboard?.failureStats ?? { last24h: 0, last7d: 0 };
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<h1 className="text-2xl font-bold text-wing-text">대시보드</h1>
|
||||
<button
|
||||
onClick={handleOpenExecuteModal}
|
||||
className="px-4 py-2 bg-wing-accent text-white font-semibold rounded-lg shadow
|
||||
hover:bg-wing-accent/80 hover:shadow-lg transition-all text-sm"
|
||||
>
|
||||
작업 즉시 실행
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* F1: Stale Execution Warning Banner */}
|
||||
{staleExecutionCount > 0 && (
|
||||
<div className="flex items-center justify-between bg-amber-100 border border-amber-300 rounded-xl px-5 py-3">
|
||||
<span className="text-amber-800 font-medium text-sm">
|
||||
{staleExecutionCount}건의 오래된 실행 중 작업이 있습니다
|
||||
</span>
|
||||
<button
|
||||
onClick={handleAbandonAllStale}
|
||||
disabled={abandoning}
|
||||
className="px-4 py-1.5 text-sm font-medium text-white bg-amber-600 rounded-lg
|
||||
hover:bg-amber-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{abandoning ? '처리 중...' : '전체 강제 종료'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4">
|
||||
<StatCard
|
||||
label="전체 스케줄"
|
||||
value={stats.totalSchedules}
|
||||
gradient="bg-gradient-to-br from-blue-500 to-blue-600"
|
||||
to="/schedules"
|
||||
/>
|
||||
<StatCard
|
||||
label="활성 스케줄"
|
||||
value={stats.activeSchedules}
|
||||
gradient="bg-gradient-to-br from-emerald-500 to-emerald-600"
|
||||
/>
|
||||
<StatCard
|
||||
label="비활성 스케줄"
|
||||
value={stats.inactiveSchedules}
|
||||
gradient="bg-gradient-to-br from-amber-500 to-amber-600"
|
||||
/>
|
||||
<StatCard
|
||||
label="전체 작업"
|
||||
value={stats.totalJobs}
|
||||
gradient="bg-gradient-to-br from-violet-500 to-violet-600"
|
||||
to="/jobs"
|
||||
/>
|
||||
<StatCard
|
||||
label="실패 (24h)"
|
||||
value={failureStats.last24h}
|
||||
gradient="bg-gradient-to-br from-red-500 to-red-600"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Quick Navigation */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link
|
||||
to="/jobs"
|
||||
className="px-3 py-1.5 bg-wing-card-alpha text-wing-text rounded-lg text-sm hover:bg-wing-hover transition-colors no-underline"
|
||||
>
|
||||
작업 관리
|
||||
</Link>
|
||||
<Link
|
||||
to="/executions"
|
||||
className="px-3 py-1.5 bg-wing-card-alpha text-wing-text rounded-lg text-sm hover:bg-wing-hover transition-colors no-underline"
|
||||
>
|
||||
실행 이력
|
||||
</Link>
|
||||
<Link
|
||||
to="/schedules"
|
||||
className="px-3 py-1.5 bg-wing-card-alpha text-wing-text rounded-lg text-sm hover:bg-wing-hover transition-colors no-underline"
|
||||
>
|
||||
스케줄 관리
|
||||
</Link>
|
||||
<Link
|
||||
to="/schedule-timeline"
|
||||
className="px-3 py-1.5 bg-wing-card-alpha text-wing-text rounded-lg text-sm hover:bg-wing-hover transition-colors no-underline"
|
||||
>
|
||||
타임라인
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Running Jobs */}
|
||||
<section className="bg-wing-surface rounded-xl shadow-md p-6">
|
||||
<h2 className="text-lg font-semibold text-wing-text mb-4">
|
||||
실행 중인 작업
|
||||
{runningJobs.length > 0 && (
|
||||
<span className="ml-2 text-sm font-normal text-wing-accent">
|
||||
({runningJobs.length}건)
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
{runningJobs.length === 0 ? (
|
||||
<EmptyState
|
||||
icon="💤"
|
||||
message="현재 실행 중인 작업이 없습니다."
|
||||
/>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-wing-border text-left text-wing-muted">
|
||||
<th className="pb-2 font-medium">작업명</th>
|
||||
<th className="pb-2 font-medium">실행 ID</th>
|
||||
<th className="pb-2 font-medium">시작 시간</th>
|
||||
<th className="pb-2 font-medium">상태</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{runningJobs.map((job) => (
|
||||
<tr key={job.executionId} className="border-b border-wing-border/50">
|
||||
<td className="py-3 font-medium text-wing-text">{job.jobName}</td>
|
||||
<td className="py-3 text-wing-muted">#{job.executionId}</td>
|
||||
<td className="py-3 text-wing-muted">{formatDateTime(job.startTime)}</td>
|
||||
<td className="py-3">
|
||||
<StatusBadge status={job.status} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Recent Executions */}
|
||||
<section className="bg-wing-surface rounded-xl shadow-md p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-wing-text">최근 실행 이력</h2>
|
||||
<Link
|
||||
to="/executions"
|
||||
className="text-sm text-wing-accent hover:text-wing-accent no-underline"
|
||||
>
|
||||
전체 보기 →
|
||||
</Link>
|
||||
</div>
|
||||
{recentExecutions.length === 0 ? (
|
||||
<EmptyState
|
||||
icon="📋"
|
||||
message="실행 이력이 없습니다."
|
||||
sub="작업을 실행하면 여기에 표시됩니다."
|
||||
/>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-wing-border text-left text-wing-muted">
|
||||
<th className="pb-2 font-medium">실행 ID</th>
|
||||
<th className="pb-2 font-medium">작업명</th>
|
||||
<th className="pb-2 font-medium">시작 시간</th>
|
||||
<th className="pb-2 font-medium">종료 시간</th>
|
||||
<th className="pb-2 font-medium">소요 시간</th>
|
||||
<th className="pb-2 font-medium">상태</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{recentExecutions.slice(0, 5).map((exec) => (
|
||||
<tr key={exec.executionId} className="border-b border-wing-border/50">
|
||||
<td className="py-3">
|
||||
<Link
|
||||
to={`/executions/${exec.executionId}`}
|
||||
className="text-wing-accent hover:text-wing-accent no-underline font-medium"
|
||||
>
|
||||
#{exec.executionId}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="py-3 text-wing-text">{exec.jobName}</td>
|
||||
<td className="py-3 text-wing-muted">{formatDateTime(exec.startTime)}</td>
|
||||
<td className="py-3 text-wing-muted">{formatDateTime(exec.endTime)}</td>
|
||||
<td className="py-3 text-wing-muted">
|
||||
{calculateDuration(exec.startTime, exec.endTime)}
|
||||
</td>
|
||||
<td className="py-3">
|
||||
<StatusBadge status={exec.status} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* F6: Recent Failures */}
|
||||
{recentFailures.length > 0 && (
|
||||
<section className="bg-wing-surface rounded-xl shadow-md p-6 border border-red-200">
|
||||
<h2 className="text-lg font-semibold text-red-700 mb-4">
|
||||
최근 실패 이력
|
||||
<span className="ml-2 text-sm font-normal text-red-500">
|
||||
({recentFailures.length}건)
|
||||
</span>
|
||||
</h2>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-red-200 text-left text-red-500">
|
||||
<th className="pb-2 font-medium">실행 ID</th>
|
||||
<th className="pb-2 font-medium">작업명</th>
|
||||
<th className="pb-2 font-medium">시작 시간</th>
|
||||
<th className="pb-2 font-medium">오류 메시지</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{recentFailures.map((fail) => (
|
||||
<tr key={fail.executionId} className="border-b border-red-100">
|
||||
<td className="py-3">
|
||||
<Link
|
||||
to={`/executions/${fail.executionId}`}
|
||||
className="text-red-600 hover:text-red-800 no-underline font-medium"
|
||||
>
|
||||
#{fail.executionId}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="py-3 text-wing-text">{fail.jobName}</td>
|
||||
<td className="py-3 text-wing-muted">{formatDateTime(fail.startTime)}</td>
|
||||
<td className="py-3 text-wing-muted" title={fail.exitMessage ?? ''}>
|
||||
{fail.exitMessage
|
||||
? fail.exitMessage.length > 50
|
||||
? `${fail.exitMessage.slice(0, 50)}...`
|
||||
: fail.exitMessage
|
||||
: '-'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* F8: Execution Statistics Chart */}
|
||||
{statistics && statistics.dailyStats.length > 0 && (
|
||||
<section className="bg-wing-surface rounded-xl shadow-md p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-wing-text">
|
||||
실행 통계 (최근 30일)
|
||||
</h2>
|
||||
<div className="flex gap-4 text-xs text-wing-muted">
|
||||
<span>
|
||||
전체 <strong className="text-wing-text">{statistics.totalExecutions}</strong>
|
||||
</span>
|
||||
<span>
|
||||
성공 <strong className="text-emerald-600">{statistics.totalSuccess}</strong>
|
||||
</span>
|
||||
<span>
|
||||
실패 <strong className="text-red-600">{statistics.totalFailed}</strong>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<BarChart
|
||||
data={statistics.dailyStats.map((d) => ({
|
||||
label: d.date.slice(5),
|
||||
values: [
|
||||
{ color: 'green', value: d.successCount },
|
||||
{ color: 'red', value: d.failedCount },
|
||||
{ color: 'gray', value: d.otherCount },
|
||||
],
|
||||
}))}
|
||||
height={180}
|
||||
/>
|
||||
<div className="flex gap-4 mt-3 text-xs text-wing-muted justify-end">
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-3 h-3 rounded-sm bg-emerald-500" /> 성공
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-3 h-3 rounded-sm bg-red-500" /> 실패
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-3 h-3 rounded-sm bg-gray-400" /> 기타
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Execute Job Modal */}
|
||||
{showExecuteModal && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-wing-overlay"
|
||||
onClick={() => setShowExecuteModal(false)}
|
||||
>
|
||||
<div
|
||||
className="bg-wing-surface rounded-xl shadow-2xl p-6 max-w-md w-full mx-4"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h3 className="text-lg font-semibold text-wing-text mb-4">작업 즉시 실행</h3>
|
||||
<label className="block text-sm font-medium text-wing-text mb-2">
|
||||
실행할 작업 선택
|
||||
</label>
|
||||
<select
|
||||
value={selectedJob}
|
||||
onChange={(e) => setSelectedJob(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-wing-border rounded-lg text-sm
|
||||
focus:ring-2 focus:ring-wing-accent focus:border-wing-accent mb-4"
|
||||
>
|
||||
{jobs.map((job) => (
|
||||
<option key={job} value={job}>{job}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<label className="block text-sm font-medium text-wing-text mb-1">
|
||||
시작일시 <span className="text-wing-muted font-normal">(선택)</span>
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-wing-border rounded-lg text-sm
|
||||
focus:ring-2 focus:ring-wing-accent focus:border-wing-accent mb-3"
|
||||
/>
|
||||
|
||||
<label className="block text-sm font-medium text-wing-text mb-1">
|
||||
종료일시 <span className="text-wing-muted font-normal">(선택)</span>
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={stopDate}
|
||||
onChange={(e) => setStopDate(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-wing-border rounded-lg text-sm
|
||||
focus:ring-2 focus:ring-wing-accent focus:border-wing-accent mb-4"
|
||||
/>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => setShowExecuteModal(false)}
|
||||
className="px-4 py-2 text-sm font-medium text-wing-text bg-wing-card rounded-lg
|
||||
hover:bg-wing-hover transition-colors"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExecuteJob}
|
||||
disabled={executing || !selectedJob}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-wing-accent rounded-lg
|
||||
hover:bg-wing-accent/80 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{executing ? '실행 중...' : '실행'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
335
frontend/src/pages/ExecutionDetail.tsx
Normal file
335
frontend/src/pages/ExecutionDetail.tsx
Normal file
@ -0,0 +1,335 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useParams, useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import { batchApi, type JobExecutionDetailDto, type StepExecutionDto } from '../api/batchApi';
|
||||
import { formatDateTime, formatDuration, calculateDuration } from '../utils/formatters';
|
||||
import { usePoller } from '../hooks/usePoller';
|
||||
import StatusBadge from '../components/StatusBadge';
|
||||
import EmptyState from '../components/EmptyState';
|
||||
import LoadingSpinner from '../components/LoadingSpinner';
|
||||
|
||||
const POLLING_INTERVAL_MS = 5000;
|
||||
|
||||
interface StatCardProps {
|
||||
label: string;
|
||||
value: number;
|
||||
gradient: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
function StatCard({ label, value, gradient, icon }: StatCardProps) {
|
||||
return (
|
||||
<div
|
||||
className={`rounded-xl p-5 text-white shadow-md ${gradient}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white/80">{label}</p>
|
||||
<p className="mt-1 text-3xl font-bold">
|
||||
{value.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-3xl opacity-80">{icon}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface StepCardProps {
|
||||
step: StepExecutionDto;
|
||||
}
|
||||
|
||||
function StepCard({ step }: StepCardProps) {
|
||||
const stats = [
|
||||
{ label: '읽기', value: step.readCount },
|
||||
{ label: '쓰기', value: step.writeCount },
|
||||
{ label: '커밋', value: step.commitCount },
|
||||
{ label: '롤백', value: step.rollbackCount },
|
||||
{ label: '읽기 건너뜀', value: step.readSkipCount },
|
||||
{ label: '처리 건너뜀', value: step.processSkipCount },
|
||||
{ label: '쓰기 건너뜀', value: step.writeSkipCount },
|
||||
{ label: '필터', value: step.filterCount },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="bg-wing-surface rounded-xl shadow-md p-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="text-base font-semibold text-wing-text">
|
||||
{step.stepName}
|
||||
</h3>
|
||||
<StatusBadge status={step.status} />
|
||||
</div>
|
||||
<span className="text-sm text-wing-muted">
|
||||
{step.duration != null
|
||||
? formatDuration(step.duration)
|
||||
: calculateDuration(step.startTime, step.endTime)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 text-sm mb-3">
|
||||
<div className="text-wing-muted">
|
||||
시작: <span className="text-wing-text">{formatDateTime(step.startTime)}</span>
|
||||
</div>
|
||||
<div className="text-wing-muted">
|
||||
종료: <span className="text-wing-text">{formatDateTime(step.endTime)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
{stats.map(({ label, value }) => (
|
||||
<div
|
||||
key={label}
|
||||
className="rounded-lg bg-wing-card px-3 py-2 text-center"
|
||||
>
|
||||
<p className="text-lg font-bold text-wing-text">
|
||||
{value.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-xs text-wing-muted">{label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* API 호출 정보 */}
|
||||
{step.apiCallInfo && (
|
||||
<div className="mt-4 rounded-lg bg-blue-50 border border-blue-200 p-3">
|
||||
<p className="text-xs font-medium text-blue-700 mb-2">API 호출 정보</p>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 text-xs">
|
||||
<div>
|
||||
<span className="text-blue-500">URL:</span>{' '}
|
||||
<span className="text-blue-900 font-mono break-all">{step.apiCallInfo.apiUrl}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-blue-500">Method:</span>{' '}
|
||||
<span className="text-blue-900 font-semibold">{step.apiCallInfo.method}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-blue-500">호출:</span>{' '}
|
||||
<span className="text-blue-900">{step.apiCallInfo.completedCalls} / {step.apiCallInfo.totalCalls}</span>
|
||||
</div>
|
||||
{step.apiCallInfo.lastCallTime && (
|
||||
<div>
|
||||
<span className="text-blue-500">최종:</span>{' '}
|
||||
<span className="text-blue-900">{step.apiCallInfo.lastCallTime}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step.exitMessage && (
|
||||
<div className="mt-4 rounded-lg bg-red-50 border border-red-200 p-3">
|
||||
<p className="text-xs font-medium text-red-700 mb-1">Exit Message</p>
|
||||
<p className="text-xs text-red-600 whitespace-pre-wrap break-words">
|
||||
{step.exitMessage}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ExecutionDetail() {
|
||||
const { id: paramId } = useParams<{ id: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const executionId = paramId
|
||||
? Number(paramId)
|
||||
: Number(searchParams.get('id'));
|
||||
|
||||
const [detail, setDetail] = useState<JobExecutionDetailDto | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const isRunning = detail
|
||||
? detail.status === 'STARTED' || detail.status === 'STARTING'
|
||||
: false;
|
||||
|
||||
const loadDetail = useCallback(async () => {
|
||||
if (!executionId || isNaN(executionId)) {
|
||||
setError('유효하지 않은 실행 ID입니다.');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const data = await batchApi.getExecutionDetail(executionId);
|
||||
setDetail(data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: '실행 상세 정보를 불러오지 못했습니다.',
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [executionId]);
|
||||
|
||||
/* 실행중인 경우 5초 폴링, 완료 후에는 1회 로드로 충분하지만 폴링 유지 */
|
||||
usePoller(loadDetail, isRunning ? POLLING_INTERVAL_MS : 30_000, [
|
||||
executionId,
|
||||
]);
|
||||
|
||||
if (loading) return <LoadingSpinner />;
|
||||
|
||||
if (error || !detail) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<button
|
||||
onClick={() => navigate('/executions')}
|
||||
className="inline-flex items-center gap-1 text-sm text-wing-muted hover:text-wing-text transition-colors"
|
||||
>
|
||||
<span>←</span> 목록으로
|
||||
</button>
|
||||
<EmptyState
|
||||
icon="⚠"
|
||||
message={error || '실행 정보를 찾을 수 없습니다.'}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const jobParams = Object.entries(detail.jobParameters);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 상단 내비게이션 */}
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="inline-flex items-center gap-1 text-sm text-wing-muted hover:text-wing-text transition-colors"
|
||||
>
|
||||
<span>←</span> 목록으로
|
||||
</button>
|
||||
|
||||
{/* Job 기본 정보 */}
|
||||
<div className="bg-wing-surface rounded-xl shadow-md p-6">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-wing-text">
|
||||
실행 #{detail.executionId}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-wing-muted">
|
||||
{detail.jobName}
|
||||
</p>
|
||||
</div>
|
||||
<StatusBadge status={detail.status} className="text-sm" />
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 text-sm">
|
||||
<InfoItem label="시작시간" value={formatDateTime(detail.startTime)} />
|
||||
<InfoItem label="종료시간" value={formatDateTime(detail.endTime)} />
|
||||
<InfoItem
|
||||
label="소요시간"
|
||||
value={
|
||||
detail.duration != null
|
||||
? formatDuration(detail.duration)
|
||||
: calculateDuration(detail.startTime, detail.endTime)
|
||||
}
|
||||
/>
|
||||
<InfoItem label="Exit Code" value={detail.exitCode} />
|
||||
{detail.exitMessage && (
|
||||
<div className="sm:col-span-2 lg:col-span-3">
|
||||
<InfoItem label="Exit Message" value={detail.exitMessage} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 실행 통계 카드 4개 */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
label="읽기 (Read)"
|
||||
value={detail.readCount}
|
||||
gradient="bg-gradient-to-br from-blue-500 to-blue-600"
|
||||
icon="📥"
|
||||
/>
|
||||
<StatCard
|
||||
label="쓰기 (Write)"
|
||||
value={detail.writeCount}
|
||||
gradient="bg-gradient-to-br from-emerald-500 to-emerald-600"
|
||||
icon="📤"
|
||||
/>
|
||||
<StatCard
|
||||
label="건너뜀 (Skip)"
|
||||
value={detail.skipCount}
|
||||
gradient="bg-gradient-to-br from-amber-500 to-amber-600"
|
||||
icon="⏭"
|
||||
/>
|
||||
<StatCard
|
||||
label="필터 (Filter)"
|
||||
value={detail.filterCount}
|
||||
gradient="bg-gradient-to-br from-purple-500 to-purple-600"
|
||||
icon="🔍"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Job Parameters */}
|
||||
{jobParams.length > 0 && (
|
||||
<div className="bg-wing-surface rounded-xl shadow-md p-6">
|
||||
<h2 className="text-lg font-semibold text-wing-text mb-4">
|
||||
Job Parameters
|
||||
</h2>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm text-left">
|
||||
<thead className="bg-wing-card text-xs uppercase text-wing-muted">
|
||||
<tr>
|
||||
<th className="px-6 py-3 font-medium">Key</th>
|
||||
<th className="px-6 py-3 font-medium">Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-wing-border/50">
|
||||
{jobParams.map(([key, value]) => (
|
||||
<tr
|
||||
key={key}
|
||||
className="hover:bg-wing-hover transition-colors"
|
||||
>
|
||||
<td className="px-6 py-3 font-mono text-wing-text">
|
||||
{key}
|
||||
</td>
|
||||
<td className="px-6 py-3 text-wing-muted break-all">
|
||||
{value}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 실행 정보 */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-wing-text mb-4">
|
||||
Step 실행 정보
|
||||
<span className="ml-2 text-sm font-normal text-wing-muted">
|
||||
({detail.stepExecutions.length}개)
|
||||
</span>
|
||||
</h2>
|
||||
{detail.stepExecutions.length === 0 ? (
|
||||
<EmptyState message="Step 실행 정보가 없습니다." />
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{detail.stepExecutions.map((step) => (
|
||||
<StepCard
|
||||
key={step.stepExecutionId}
|
||||
step={step}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoItem({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<dt className="text-xs font-medium text-wing-muted uppercase tracking-wide">
|
||||
{label}
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm text-wing-text break-words">{value || '-'}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
587
frontend/src/pages/Executions.tsx
Normal file
587
frontend/src/pages/Executions.tsx
Normal file
@ -0,0 +1,587 @@
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { batchApi, type JobExecutionDto, type ExecutionSearchResponse } from '../api/batchApi';
|
||||
import { formatDateTime, calculateDuration } from '../utils/formatters';
|
||||
import { usePoller } from '../hooks/usePoller';
|
||||
import { useToastContext } from '../contexts/ToastContext';
|
||||
import StatusBadge from '../components/StatusBadge';
|
||||
import ConfirmModal from '../components/ConfirmModal';
|
||||
import InfoModal from '../components/InfoModal';
|
||||
import EmptyState from '../components/EmptyState';
|
||||
import LoadingSpinner from '../components/LoadingSpinner';
|
||||
|
||||
type StatusFilter = 'ALL' | 'COMPLETED' | 'FAILED' | 'STARTED' | 'STOPPED';
|
||||
|
||||
const STATUS_FILTERS: { value: StatusFilter; label: string }[] = [
|
||||
{ value: 'ALL', label: '전체' },
|
||||
{ value: 'COMPLETED', label: '완료' },
|
||||
{ value: 'FAILED', label: '실패' },
|
||||
{ value: 'STARTED', label: '실행중' },
|
||||
{ value: 'STOPPED', label: '중지됨' },
|
||||
];
|
||||
|
||||
const POLLING_INTERVAL_MS = 5000;
|
||||
const RECENT_LIMIT = 50;
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
export default function Executions() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const jobFromQuery = searchParams.get('job') || '';
|
||||
|
||||
const [jobs, setJobs] = useState<string[]>([]);
|
||||
const [executions, setExecutions] = useState<JobExecutionDto[]>([]);
|
||||
const [selectedJobs, setSelectedJobs] = useState<string[]>(jobFromQuery ? [jobFromQuery] : []);
|
||||
const [jobDropdownOpen, setJobDropdownOpen] = useState(false);
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>('ALL');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [stopTarget, setStopTarget] = useState<JobExecutionDto | null>(null);
|
||||
|
||||
// F1: 강제 종료
|
||||
const [abandonTarget, setAbandonTarget] = useState<JobExecutionDto | null>(null);
|
||||
|
||||
// F4: 날짜 범위 필터 + 페이지네이션
|
||||
const [startDate, setStartDate] = useState('');
|
||||
const [endDate, setEndDate] = useState('');
|
||||
const [page, setPage] = useState(0);
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [useSearch, setUseSearch] = useState(false);
|
||||
|
||||
// F9: 실패 로그 뷰어
|
||||
const [failLogTarget, setFailLogTarget] = useState<JobExecutionDto | null>(null);
|
||||
|
||||
const { showToast } = useToastContext();
|
||||
|
||||
const loadJobs = useCallback(async () => {
|
||||
try {
|
||||
const data = await batchApi.getJobs();
|
||||
setJobs(data);
|
||||
} catch {
|
||||
/* Job 목록 로드 실패는 무시 */
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadSearchExecutions = useCallback(async (targetPage: number) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const params: {
|
||||
jobNames?: string[];
|
||||
status?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
page?: number;
|
||||
size?: number;
|
||||
} = {
|
||||
page: targetPage,
|
||||
size: PAGE_SIZE,
|
||||
};
|
||||
if (selectedJobs.length > 0) params.jobNames = selectedJobs;
|
||||
if (statusFilter !== 'ALL') params.status = statusFilter;
|
||||
if (startDate) params.startDate = `${startDate}T00:00:00`;
|
||||
if (endDate) params.endDate = `${endDate}T23:59:59`;
|
||||
|
||||
const data: ExecutionSearchResponse = await batchApi.searchExecutions(params);
|
||||
setExecutions(data.executions);
|
||||
setTotalPages(data.totalPages);
|
||||
setTotalCount(data.totalCount);
|
||||
setPage(data.page);
|
||||
} catch {
|
||||
setExecutions([]);
|
||||
setTotalPages(0);
|
||||
setTotalCount(0);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [selectedJobs, statusFilter, startDate, endDate]);
|
||||
|
||||
const loadExecutions = useCallback(async () => {
|
||||
// 검색 모드에서는 폴링하지 않음 (검색 버튼 클릭 시에만 1회 조회)
|
||||
if (useSearch) return;
|
||||
try {
|
||||
let data: JobExecutionDto[];
|
||||
if (selectedJobs.length === 1) {
|
||||
data = await batchApi.getJobExecutions(selectedJobs[0]);
|
||||
} else if (selectedJobs.length > 1) {
|
||||
// 복수 Job 선택 시 search API 사용
|
||||
const result = await batchApi.searchExecutions({
|
||||
jobNames: selectedJobs, size: RECENT_LIMIT,
|
||||
});
|
||||
data = result.executions;
|
||||
} else {
|
||||
try {
|
||||
data = await batchApi.getRecentExecutions(RECENT_LIMIT);
|
||||
} catch {
|
||||
data = [];
|
||||
}
|
||||
}
|
||||
setExecutions(data);
|
||||
} catch {
|
||||
setExecutions([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [selectedJobs, useSearch, page, loadSearchExecutions]);
|
||||
|
||||
/* 마운트 시 Job 목록 1회 로드 */
|
||||
usePoller(loadJobs, 60_000, []);
|
||||
|
||||
/* 실행 이력 5초 폴링 */
|
||||
usePoller(loadExecutions, POLLING_INTERVAL_MS, [selectedJobs, useSearch, page]);
|
||||
|
||||
const filteredExecutions = useMemo(() => {
|
||||
// 검색 모드에서는 서버 필터링 사용
|
||||
if (useSearch) return executions;
|
||||
if (statusFilter === 'ALL') return executions;
|
||||
return executions.filter((e) => e.status === statusFilter);
|
||||
}, [executions, statusFilter, useSearch]);
|
||||
|
||||
const toggleJob = (jobName: string) => {
|
||||
setSelectedJobs((prev) => {
|
||||
const next = prev.includes(jobName)
|
||||
? prev.filter((j) => j !== jobName)
|
||||
: [...prev, jobName];
|
||||
if (next.length === 1) {
|
||||
setSearchParams({ job: next[0] });
|
||||
} else {
|
||||
setSearchParams({});
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setLoading(true);
|
||||
if (useSearch) {
|
||||
setPage(0);
|
||||
}
|
||||
};
|
||||
|
||||
const clearSelectedJobs = () => {
|
||||
setSelectedJobs([]);
|
||||
setSearchParams({});
|
||||
setLoading(true);
|
||||
if (useSearch) {
|
||||
setPage(0);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStop = async () => {
|
||||
if (!stopTarget) return;
|
||||
try {
|
||||
const result = await batchApi.stopExecution(stopTarget.executionId);
|
||||
showToast(result.message || '실행이 중지되었습니다.', 'success');
|
||||
} catch (err) {
|
||||
showToast(
|
||||
err instanceof Error ? err.message : '중지 요청에 실패했습니다.',
|
||||
'error',
|
||||
);
|
||||
} finally {
|
||||
setStopTarget(null);
|
||||
}
|
||||
};
|
||||
|
||||
// F1: 강제 종료 핸들러
|
||||
const handleAbandon = async () => {
|
||||
if (!abandonTarget) return;
|
||||
try {
|
||||
const result = await batchApi.abandonExecution(abandonTarget.executionId);
|
||||
showToast(result.message || '실행이 강제 종료되었습니다.', 'success');
|
||||
} catch (err) {
|
||||
showToast(
|
||||
err instanceof Error ? err.message : '강제 종료 요청에 실패했습니다.',
|
||||
'error',
|
||||
);
|
||||
} finally {
|
||||
setAbandonTarget(null);
|
||||
}
|
||||
};
|
||||
|
||||
// F4: 검색 핸들러
|
||||
const handleSearch = async () => {
|
||||
setUseSearch(true);
|
||||
setPage(0);
|
||||
await loadSearchExecutions(0);
|
||||
};
|
||||
|
||||
// F4: 초기화 핸들러
|
||||
const handleResetSearch = () => {
|
||||
setUseSearch(false);
|
||||
setStartDate('');
|
||||
setEndDate('');
|
||||
setPage(0);
|
||||
setTotalPages(0);
|
||||
setTotalCount(0);
|
||||
setLoading(true);
|
||||
};
|
||||
|
||||
// F4: 페이지 이동 핸들러
|
||||
const handlePageChange = (newPage: number) => {
|
||||
if (newPage < 0 || newPage >= totalPages) return;
|
||||
setPage(newPage);
|
||||
loadSearchExecutions(newPage);
|
||||
};
|
||||
|
||||
const isRunning = (status: string) =>
|
||||
status === 'STARTED' || status === 'STARTING';
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 헤더 */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-wing-text">실행 이력</h1>
|
||||
<p className="mt-1 text-sm text-wing-muted">
|
||||
배치 작업 실행 이력을 조회하고 관리합니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 필터 영역 */}
|
||||
<div className="bg-wing-surface rounded-xl shadow-md p-6">
|
||||
<div className="space-y-3">
|
||||
{/* Job 멀티 선택 */}
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<label className="text-sm font-medium text-wing-text shrink-0">
|
||||
작업 선택
|
||||
</label>
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setJobDropdownOpen((v) => !v)}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg border border-wing-border bg-wing-surface hover:bg-wing-hover transition-colors"
|
||||
>
|
||||
{selectedJobs.length === 0
|
||||
? `전체 (최근 ${RECENT_LIMIT}건)`
|
||||
: `${selectedJobs.length}개 선택됨`}
|
||||
<svg className={`w-4 h-4 text-wing-muted transition-transform ${jobDropdownOpen ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>
|
||||
</button>
|
||||
{jobDropdownOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={() => setJobDropdownOpen(false)} />
|
||||
<div className="absolute z-20 mt-1 w-72 max-h-60 overflow-y-auto bg-wing-surface border border-wing-border rounded-lg shadow-lg">
|
||||
{jobs.map((job) => (
|
||||
<label
|
||||
key={job}
|
||||
className="flex items-center gap-2 px-3 py-2 text-sm cursor-pointer hover:bg-wing-hover transition-colors"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedJobs.includes(job)}
|
||||
onChange={() => toggleJob(job)}
|
||||
className="rounded border-wing-border text-wing-accent focus:ring-wing-accent"
|
||||
/>
|
||||
<span className="text-wing-text truncate">{job}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{selectedJobs.length > 0 && (
|
||||
<button
|
||||
onClick={clearSelectedJobs}
|
||||
className="text-xs text-wing-muted hover:text-wing-accent transition-colors"
|
||||
>
|
||||
초기화
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* 선택된 Job 칩 */}
|
||||
{selectedJobs.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{selectedJobs.map((job) => (
|
||||
<span
|
||||
key={job}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-medium bg-wing-accent/15 text-wing-accent rounded-full"
|
||||
>
|
||||
{job}
|
||||
<button
|
||||
onClick={() => toggleJob(job)}
|
||||
className="hover:text-wing-text transition-colors"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 상태 필터 버튼 그룹 */}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{STATUS_FILTERS.map(({ value, label }) => (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => {
|
||||
setStatusFilter(value);
|
||||
if (useSearch) {
|
||||
setPage(0);
|
||||
}
|
||||
}}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${
|
||||
statusFilter === value
|
||||
? 'bg-wing-accent text-white shadow-sm'
|
||||
: 'bg-wing-card text-wing-muted hover:bg-wing-hover'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* F4: 날짜 범위 필터 */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center mt-4 pt-4 border-t border-wing-border/50">
|
||||
<label className="text-sm font-medium text-wing-text shrink-0">
|
||||
기간 검색
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
className="block rounded-lg border border-wing-border bg-wing-surface px-3 py-2 text-sm shadow-sm focus:border-wing-accent focus:ring-1 focus:ring-wing-accent"
|
||||
/>
|
||||
<span className="text-wing-muted text-sm">~</span>
|
||||
<input
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
className="block rounded-lg border border-wing-border bg-wing-surface px-3 py-2 text-sm shadow-sm focus:border-wing-accent focus:ring-1 focus:ring-wing-accent"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-wing-accent rounded-lg hover:bg-wing-accent/80 transition-colors shadow-sm"
|
||||
>
|
||||
검색
|
||||
</button>
|
||||
{useSearch && (
|
||||
<button
|
||||
onClick={handleResetSearch}
|
||||
className="px-4 py-2 text-sm font-medium text-wing-text bg-wing-card rounded-lg hover:bg-wing-hover transition-colors"
|
||||
>
|
||||
초기화
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 실행 이력 테이블 */}
|
||||
<div className="bg-wing-surface rounded-xl shadow-md overflow-hidden">
|
||||
{loading ? (
|
||||
<LoadingSpinner />
|
||||
) : filteredExecutions.length === 0 ? (
|
||||
<EmptyState
|
||||
message="실행 이력이 없습니다."
|
||||
sub={
|
||||
statusFilter !== 'ALL'
|
||||
? '다른 상태 필터를 선택해 보세요.'
|
||||
: selectedJobs.length > 0
|
||||
? '선택한 작업의 실행 이력이 없습니다.'
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm text-left">
|
||||
<thead className="bg-wing-card text-xs uppercase text-wing-muted">
|
||||
<tr>
|
||||
<th className="px-6 py-3 font-medium">실행 ID</th>
|
||||
<th className="px-6 py-3 font-medium">작업명</th>
|
||||
<th className="px-6 py-3 font-medium">상태</th>
|
||||
<th className="px-6 py-3 font-medium">시작시간</th>
|
||||
<th className="px-6 py-3 font-medium">종료시간</th>
|
||||
<th className="px-6 py-3 font-medium">소요시간</th>
|
||||
<th className="px-6 py-3 font-medium text-right">
|
||||
액션
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-wing-border/50">
|
||||
{filteredExecutions.map((exec) => (
|
||||
<tr
|
||||
key={exec.executionId}
|
||||
className="hover:bg-wing-hover transition-colors"
|
||||
>
|
||||
<td className="px-6 py-4 font-mono text-wing-text">
|
||||
#{exec.executionId}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-wing-text">
|
||||
{exec.jobName}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{/* F9: FAILED 상태 클릭 시 실패 로그 모달 */}
|
||||
{exec.status === 'FAILED' ? (
|
||||
<button
|
||||
onClick={() => setFailLogTarget(exec)}
|
||||
className="cursor-pointer"
|
||||
title="클릭하여 실패 로그 확인"
|
||||
>
|
||||
<StatusBadge status={exec.status} />
|
||||
</button>
|
||||
) : (
|
||||
<StatusBadge status={exec.status} />
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-wing-muted whitespace-nowrap">
|
||||
{formatDateTime(exec.startTime)}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-wing-muted whitespace-nowrap">
|
||||
{formatDateTime(exec.endTime)}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-wing-muted whitespace-nowrap">
|
||||
{calculateDuration(
|
||||
exec.startTime,
|
||||
exec.endTime,
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{isRunning(exec.status) ? (
|
||||
<>
|
||||
<button
|
||||
onClick={() =>
|
||||
setStopTarget(exec)
|
||||
}
|
||||
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-red-700 bg-red-50 rounded-lg hover:bg-red-100 transition-colors"
|
||||
>
|
||||
중지
|
||||
</button>
|
||||
{/* F1: 강제 종료 버튼 */}
|
||||
<button
|
||||
onClick={() =>
|
||||
setAbandonTarget(exec)
|
||||
}
|
||||
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-amber-700 bg-amber-50 rounded-lg hover:bg-amber-100 transition-colors"
|
||||
>
|
||||
강제 종료
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/executions/${exec.executionId}`,
|
||||
)
|
||||
}
|
||||
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-wing-accent bg-wing-accent/10 rounded-lg hover:bg-wing-accent/15 transition-colors"
|
||||
>
|
||||
상세
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 결과 건수 표시 + F4: 페이지네이션 */}
|
||||
{!loading && filteredExecutions.length > 0 && (
|
||||
<div className="px-6 py-3 bg-wing-card border-t border-wing-border/50 flex items-center justify-between">
|
||||
<div className="text-xs text-wing-muted">
|
||||
{useSearch ? (
|
||||
<>총 {totalCount}건</>
|
||||
) : (
|
||||
<>
|
||||
총 {filteredExecutions.length}건
|
||||
{statusFilter !== 'ALL' && (
|
||||
<span className="ml-1">
|
||||
(전체 {executions.length}건 중)
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{/* F4: 페이지네이션 UI */}
|
||||
{useSearch && totalPages > 1 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handlePageChange(page - 1)}
|
||||
disabled={page === 0}
|
||||
className="px-3 py-1.5 text-xs font-medium rounded-lg transition-colors disabled:opacity-40 disabled:cursor-not-allowed bg-wing-card text-wing-muted hover:bg-wing-hover"
|
||||
>
|
||||
이전
|
||||
</button>
|
||||
<span className="text-xs text-wing-muted">
|
||||
{page + 1} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handlePageChange(page + 1)}
|
||||
disabled={page >= totalPages - 1}
|
||||
className="px-3 py-1.5 text-xs font-medium rounded-lg transition-colors disabled:opacity-40 disabled:cursor-not-allowed bg-wing-card text-wing-muted hover:bg-wing-hover"
|
||||
>
|
||||
다음
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 중지 확인 모달 */}
|
||||
<ConfirmModal
|
||||
open={stopTarget !== null}
|
||||
title="실행 중지"
|
||||
message={
|
||||
stopTarget
|
||||
? `실행 #${stopTarget.executionId} (${stopTarget.jobName})을 중지하시겠습니까?`
|
||||
: ''
|
||||
}
|
||||
confirmLabel="중지"
|
||||
confirmColor="bg-red-600 hover:bg-red-700"
|
||||
onConfirm={handleStop}
|
||||
onCancel={() => setStopTarget(null)}
|
||||
/>
|
||||
|
||||
{/* F1: 강제 종료 확인 모달 */}
|
||||
<ConfirmModal
|
||||
open={abandonTarget !== null}
|
||||
title="강제 종료"
|
||||
message={
|
||||
abandonTarget
|
||||
? `실행 #${abandonTarget.executionId} (${abandonTarget.jobName})을 강제 종료하시겠습니까?\n\n강제 종료는 실행 상태를 ABANDONED로 변경합니다.`
|
||||
: ''
|
||||
}
|
||||
confirmLabel="강제 종료"
|
||||
confirmColor="bg-amber-600 hover:bg-amber-700"
|
||||
onConfirm={handleAbandon}
|
||||
onCancel={() => setAbandonTarget(null)}
|
||||
/>
|
||||
|
||||
{/* F9: 실패 로그 뷰어 모달 */}
|
||||
<InfoModal
|
||||
open={failLogTarget !== null}
|
||||
title={
|
||||
failLogTarget
|
||||
? `실패 로그 - #${failLogTarget.executionId} (${failLogTarget.jobName})`
|
||||
: '실패 로그'
|
||||
}
|
||||
onClose={() => setFailLogTarget(null)}
|
||||
>
|
||||
{failLogTarget && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-wing-muted uppercase mb-1">
|
||||
Exit Code
|
||||
</h4>
|
||||
<p className="text-sm text-wing-text font-mono bg-wing-card px-3 py-2 rounded-lg">
|
||||
{failLogTarget.exitCode || '-'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-wing-muted uppercase mb-1">
|
||||
Exit Message
|
||||
</h4>
|
||||
<pre className="text-sm text-wing-text font-mono bg-wing-card px-3 py-2 rounded-lg whitespace-pre-wrap break-words max-h-64 overflow-y-auto">
|
||||
{failLogTarget.exitMessage || '메시지 없음'}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</InfoModal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
273
frontend/src/pages/Jobs.tsx
Normal file
273
frontend/src/pages/Jobs.tsx
Normal file
@ -0,0 +1,273 @@
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { batchApi } from '../api/batchApi';
|
||||
import type { JobDetailDto } from '../api/batchApi';
|
||||
import { usePoller } from '../hooks/usePoller';
|
||||
import { useToastContext } from '../contexts/ToastContext';
|
||||
import StatusBadge from '../components/StatusBadge';
|
||||
import EmptyState from '../components/EmptyState';
|
||||
import LoadingSpinner from '../components/LoadingSpinner';
|
||||
import { formatDateTime } from '../utils/formatters';
|
||||
|
||||
const POLLING_INTERVAL = 30000;
|
||||
|
||||
export default function Jobs() {
|
||||
const navigate = useNavigate();
|
||||
const { showToast } = useToastContext();
|
||||
|
||||
const [jobs, setJobs] = useState<JobDetailDto[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
// Execute modal
|
||||
const [executeModalOpen, setExecuteModalOpen] = useState(false);
|
||||
const [targetJob, setTargetJob] = useState('');
|
||||
const [executing, setExecuting] = useState(false);
|
||||
const [startDate, setStartDate] = useState('');
|
||||
const [stopDate, setStopDate] = useState('');
|
||||
|
||||
const loadJobs = useCallback(async () => {
|
||||
try {
|
||||
const data = await batchApi.getJobsDetail();
|
||||
setJobs(data);
|
||||
} catch (err) {
|
||||
console.error('Jobs load failed:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
usePoller(loadJobs, POLLING_INTERVAL);
|
||||
|
||||
const filteredJobs = useMemo(() => {
|
||||
if (!searchTerm.trim()) return jobs;
|
||||
const term = searchTerm.toLowerCase();
|
||||
return jobs.filter((job) => job.jobName.toLowerCase().includes(term));
|
||||
}, [jobs, searchTerm]);
|
||||
|
||||
const handleExecuteClick = (jobName: string) => {
|
||||
setTargetJob(jobName);
|
||||
setStartDate('');
|
||||
setStopDate('');
|
||||
setExecuteModalOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirmExecute = async () => {
|
||||
if (!targetJob) return;
|
||||
setExecuting(true);
|
||||
try {
|
||||
const params: Record<string, string> = {};
|
||||
if (startDate) params.startDate = startDate;
|
||||
if (stopDate) params.stopDate = stopDate;
|
||||
|
||||
const result = await batchApi.executeJob(
|
||||
targetJob,
|
||||
Object.keys(params).length > 0 ? params : undefined,
|
||||
);
|
||||
showToast(
|
||||
result.message || `${targetJob} 실행 요청 완료`,
|
||||
'success',
|
||||
);
|
||||
setExecuteModalOpen(false);
|
||||
} catch (err) {
|
||||
showToast(
|
||||
`실행 실패: ${err instanceof Error ? err.message : '알 수 없는 오류'}`,
|
||||
'error',
|
||||
);
|
||||
} finally {
|
||||
setExecuting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewHistory = (jobName: string) => {
|
||||
navigate(`/executions?job=${encodeURIComponent(jobName)}`);
|
||||
};
|
||||
|
||||
if (loading) return <LoadingSpinner />;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<h1 className="text-2xl font-bold text-wing-text">배치 작업 목록</h1>
|
||||
<span className="text-sm text-wing-muted">
|
||||
총 {jobs.length}개 작업
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Search Filter */}
|
||||
<div className="bg-wing-surface rounded-xl shadow-md p-4">
|
||||
<div className="relative">
|
||||
<span className="absolute inset-y-0 left-3 flex items-center text-wing-muted">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="작업명 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 border border-wing-border rounded-lg text-sm
|
||||
focus:ring-2 focus:ring-wing-accent focus:border-wing-accent outline-none"
|
||||
/>
|
||||
{searchTerm && (
|
||||
<button
|
||||
onClick={() => setSearchTerm('')}
|
||||
className="absolute inset-y-0 right-3 flex items-center text-wing-muted hover:text-wing-text"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{searchTerm && (
|
||||
<p className="mt-2 text-xs text-wing-muted">
|
||||
{filteredJobs.length}개 작업 검색됨
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Job Cards Grid */}
|
||||
{filteredJobs.length === 0 ? (
|
||||
<div className="bg-wing-surface rounded-xl shadow-md p-6">
|
||||
<EmptyState
|
||||
icon="🔍"
|
||||
message={searchTerm ? '검색 결과가 없습니다.' : '등록된 작업이 없습니다.'}
|
||||
sub={searchTerm ? '다른 검색어를 입력해 보세요.' : undefined}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filteredJobs.map((job) => (
|
||||
<div
|
||||
key={job.jobName}
|
||||
className="bg-wing-surface rounded-xl shadow-md p-6
|
||||
hover:shadow-lg hover:-translate-y-0.5 transition-all"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-wing-text break-all leading-tight">
|
||||
{job.jobName}
|
||||
</h3>
|
||||
{job.lastExecution && (
|
||||
<StatusBadge status={job.lastExecution.status} className="ml-2 shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Job detail info */}
|
||||
<div className="mb-4 space-y-1">
|
||||
{job.lastExecution ? (
|
||||
<p className="text-xs text-wing-muted">
|
||||
마지막 실행: {formatDateTime(job.lastExecution.startTime)}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-wing-muted text-xs">실행 이력 없음</p>
|
||||
)}
|
||||
{job.scheduleCron && (
|
||||
<p className="text-xs text-wing-muted">
|
||||
스케줄:{' '}
|
||||
<span className="font-mono text-xs bg-wing-card px-2 py-0.5 rounded">
|
||||
{job.scheduleCron}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleExecuteClick(job.jobName)}
|
||||
className="flex-1 px-3 py-2 text-xs font-medium text-white bg-wing-accent rounded-lg
|
||||
hover:bg-wing-accent/80 transition-colors"
|
||||
>
|
||||
실행
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleViewHistory(job.jobName)}
|
||||
className="flex-1 px-3 py-2 text-xs font-medium text-wing-accent bg-wing-accent/10 rounded-lg
|
||||
hover:bg-wing-accent/15 transition-colors"
|
||||
>
|
||||
이력 보기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Execute Modal (custom with date params) */}
|
||||
{executeModalOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-wing-overlay"
|
||||
onClick={() => setExecuteModalOpen(false)}
|
||||
>
|
||||
<div
|
||||
className="bg-wing-surface rounded-xl shadow-2xl p-6 max-w-md w-full mx-4"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h3 className="text-lg font-semibold text-wing-text mb-2">작업 실행 확인</h3>
|
||||
<p className="text-wing-muted text-sm mb-4">
|
||||
"{targetJob}" 작업을 실행하시겠습니까?
|
||||
</p>
|
||||
|
||||
{/* Date parameters */}
|
||||
<div className="space-y-3 mb-6">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-wing-text mb-1">
|
||||
시작일시 (선택)
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-wing-border rounded-lg text-sm
|
||||
focus:ring-2 focus:ring-wing-accent focus:border-wing-accent outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-wing-text mb-1">
|
||||
종료일시 (선택)
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={stopDate}
|
||||
onChange={(e) => setStopDate(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-wing-border rounded-lg text-sm
|
||||
focus:ring-2 focus:ring-wing-accent focus:border-wing-accent outline-none"
|
||||
/>
|
||||
</div>
|
||||
{(startDate || stopDate) && (
|
||||
<p className="text-xs text-wing-muted">
|
||||
날짜를 지정하면 해당 범위의 데이터를 수집합니다.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => setExecuteModalOpen(false)}
|
||||
className="px-4 py-2 text-sm font-medium text-wing-text bg-wing-card rounded-lg
|
||||
hover:bg-wing-hover transition-colors"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirmExecute}
|
||||
disabled={executing}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-wing-accent rounded-lg
|
||||
hover:bg-wing-accent/80 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{executing ? '실행 중...' : '실행'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
490
frontend/src/pages/Schedules.tsx
Normal file
490
frontend/src/pages/Schedules.tsx
Normal file
@ -0,0 +1,490 @@
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import { batchApi, type ScheduleResponse } from '../api/batchApi';
|
||||
import { formatDateTime } from '../utils/formatters';
|
||||
import { useToastContext } from '../contexts/ToastContext';
|
||||
import ConfirmModal from '../components/ConfirmModal';
|
||||
import EmptyState from '../components/EmptyState';
|
||||
import LoadingSpinner from '../components/LoadingSpinner';
|
||||
import { getNextExecutions } from '../utils/cronPreview';
|
||||
|
||||
type ScheduleMode = 'new' | 'existing';
|
||||
|
||||
interface ConfirmAction {
|
||||
type: 'toggle' | 'delete';
|
||||
schedule: ScheduleResponse;
|
||||
}
|
||||
|
||||
const CRON_PRESETS = [
|
||||
{ label: '매 분', cron: '0 * * * * ?' },
|
||||
{ label: '매시 정각', cron: '0 0 * * * ?' },
|
||||
{ label: '매 15분', cron: '0 0/15 * * * ?' },
|
||||
{ label: '매일 00:00', cron: '0 0 0 * * ?' },
|
||||
{ label: '매일 12:00', cron: '0 0 12 * * ?' },
|
||||
{ label: '매주 월 00:00', cron: '0 0 0 ? * MON' },
|
||||
];
|
||||
|
||||
function CronPreview({ cron }: { cron: string }) {
|
||||
const nextDates = useMemo(() => getNextExecutions(cron, 5), [cron]);
|
||||
|
||||
if (nextDates.length === 0) {
|
||||
return (
|
||||
<div className="md:col-span-2">
|
||||
<p className="text-xs text-wing-muted">미리보기 불가 (복잡한 표현식)</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const fmt = new Intl.DateTimeFormat('ko-KR', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
weekday: 'short',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-wing-text mb-1">
|
||||
다음 5회 실행 예정
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{nextDates.map((d, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="inline-block bg-wing-accent/10 text-wing-accent text-xs font-mono px-2 py-1 rounded"
|
||||
>
|
||||
{fmt.format(d)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getTriggerStateStyle(state: string | null): string {
|
||||
switch (state) {
|
||||
case 'NORMAL':
|
||||
return 'bg-emerald-100 text-emerald-700';
|
||||
case 'PAUSED':
|
||||
return 'bg-amber-100 text-amber-700';
|
||||
case 'BLOCKED':
|
||||
return 'bg-red-100 text-red-700';
|
||||
case 'ERROR':
|
||||
return 'bg-red-100 text-red-700';
|
||||
default:
|
||||
return 'bg-wing-card text-wing-muted';
|
||||
}
|
||||
}
|
||||
|
||||
export default function Schedules() {
|
||||
const { showToast } = useToastContext();
|
||||
|
||||
// Form state
|
||||
const [jobs, setJobs] = useState<string[]>([]);
|
||||
const [selectedJob, setSelectedJob] = useState('');
|
||||
const [cronExpression, setCronExpression] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [scheduleMode, setScheduleMode] = useState<ScheduleMode>('new');
|
||||
const [formLoading, setFormLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Schedule list state
|
||||
const [schedules, setSchedules] = useState<ScheduleResponse[]>([]);
|
||||
const [listLoading, setListLoading] = useState(true);
|
||||
|
||||
// Confirm modal state
|
||||
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
|
||||
|
||||
// 폼 영역 ref (편집 버튼 클릭 시 스크롤)
|
||||
const formRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const loadSchedules = useCallback(async () => {
|
||||
try {
|
||||
const result = await batchApi.getSchedules();
|
||||
setSchedules(result.schedules);
|
||||
} catch (err) {
|
||||
showToast('스케줄 목록 조회 실패', 'error');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setListLoading(false);
|
||||
}
|
||||
}, [showToast]);
|
||||
|
||||
const loadJobs = useCallback(async () => {
|
||||
try {
|
||||
const result = await batchApi.getJobs();
|
||||
setJobs(result);
|
||||
} catch (err) {
|
||||
showToast('작업 목록 조회 실패', 'error');
|
||||
console.error(err);
|
||||
}
|
||||
}, [showToast]);
|
||||
|
||||
useEffect(() => {
|
||||
loadJobs();
|
||||
loadSchedules();
|
||||
}, [loadJobs, loadSchedules]);
|
||||
|
||||
const handleJobSelect = async (jobName: string) => {
|
||||
setSelectedJob(jobName);
|
||||
setCronExpression('');
|
||||
setDescription('');
|
||||
setScheduleMode('new');
|
||||
|
||||
if (!jobName) return;
|
||||
|
||||
setFormLoading(true);
|
||||
try {
|
||||
const schedule = await batchApi.getSchedule(jobName);
|
||||
setCronExpression(schedule.cronExpression);
|
||||
setDescription(schedule.description ?? '');
|
||||
setScheduleMode('existing');
|
||||
} catch {
|
||||
// 404 = new schedule
|
||||
setScheduleMode('new');
|
||||
} finally {
|
||||
setFormLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!selectedJob) {
|
||||
showToast('작업을 선택해주세요', 'error');
|
||||
return;
|
||||
}
|
||||
if (!cronExpression.trim()) {
|
||||
showToast('Cron 표현식을 입력해주세요', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
if (scheduleMode === 'existing') {
|
||||
await batchApi.updateSchedule(selectedJob, {
|
||||
cronExpression: cronExpression.trim(),
|
||||
description: description.trim() || undefined,
|
||||
});
|
||||
showToast('스케줄이 수정되었습니다', 'success');
|
||||
} else {
|
||||
await batchApi.createSchedule({
|
||||
jobName: selectedJob,
|
||||
cronExpression: cronExpression.trim(),
|
||||
description: description.trim() || undefined,
|
||||
});
|
||||
showToast('스케줄이 등록되었습니다', 'success');
|
||||
}
|
||||
await loadSchedules();
|
||||
// Reload schedule info for current job
|
||||
await handleJobSelect(selectedJob);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : '저장 실패';
|
||||
showToast(message, 'error');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggle = async (schedule: ScheduleResponse) => {
|
||||
try {
|
||||
await batchApi.toggleSchedule(schedule.jobName, !schedule.active);
|
||||
showToast(
|
||||
`${schedule.jobName} 스케줄이 ${schedule.active ? '비활성화' : '활성화'}되었습니다`,
|
||||
'success',
|
||||
);
|
||||
await loadSchedules();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : '토글 실패';
|
||||
showToast(message, 'error');
|
||||
}
|
||||
setConfirmAction(null);
|
||||
};
|
||||
|
||||
const handleDelete = async (schedule: ScheduleResponse) => {
|
||||
try {
|
||||
await batchApi.deleteSchedule(schedule.jobName);
|
||||
showToast(`${schedule.jobName} 스케줄이 삭제되었습니다`, 'success');
|
||||
await loadSchedules();
|
||||
// Clear form if deleted schedule was selected
|
||||
if (selectedJob === schedule.jobName) {
|
||||
setSelectedJob('');
|
||||
setCronExpression('');
|
||||
setDescription('');
|
||||
setScheduleMode('new');
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : '삭제 실패';
|
||||
showToast(message, 'error');
|
||||
}
|
||||
setConfirmAction(null);
|
||||
};
|
||||
|
||||
const handleEditFromCard = (schedule: ScheduleResponse) => {
|
||||
setSelectedJob(schedule.jobName);
|
||||
setCronExpression(schedule.cronExpression);
|
||||
setDescription(schedule.description ?? '');
|
||||
setScheduleMode('existing');
|
||||
formRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Form Section */}
|
||||
<div ref={formRef} className="bg-wing-surface rounded-xl shadow-lg p-6">
|
||||
<h2 className="text-lg font-bold text-wing-text mb-4">스케줄 등록 / 수정</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Job Select */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-wing-text mb-1">
|
||||
작업 선택
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={selectedJob}
|
||||
onChange={(e) => handleJobSelect(e.target.value)}
|
||||
className="flex-1 rounded-lg border border-wing-border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-wing-accent focus:border-wing-accent"
|
||||
disabled={formLoading}
|
||||
>
|
||||
<option value="">-- 작업을 선택하세요 --</option>
|
||||
{jobs.map((job) => (
|
||||
<option key={job} value={job}>
|
||||
{job}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{selectedJob && (
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold whitespace-nowrap ${
|
||||
scheduleMode === 'existing'
|
||||
? 'bg-blue-100 text-blue-700'
|
||||
: 'bg-green-100 text-green-700'
|
||||
}`}
|
||||
>
|
||||
{scheduleMode === 'existing' ? '기존 스케줄' : '새 스케줄'}
|
||||
</span>
|
||||
)}
|
||||
{formLoading && (
|
||||
<div className="w-5 h-5 border-2 border-wing-accent/30 border-t-wing-accent rounded-full animate-spin" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cron Expression */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-wing-text mb-1">
|
||||
Cron 표현식
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={cronExpression}
|
||||
onChange={(e) => setCronExpression(e.target.value)}
|
||||
placeholder="0 0/15 * * * ?"
|
||||
className="w-full rounded-lg border border-wing-border px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-wing-accent focus:border-wing-accent"
|
||||
disabled={!selectedJob || formLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Cron Presets */}
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-wing-text mb-1">
|
||||
프리셋
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{CRON_PRESETS.map(({ label, cron }) => (
|
||||
<button
|
||||
key={cron}
|
||||
type="button"
|
||||
onClick={() => setCronExpression(cron)}
|
||||
disabled={!selectedJob || formLoading}
|
||||
className="px-3 py-1 text-xs font-medium bg-wing-card text-wing-text rounded-lg hover:bg-wing-accent/15 hover:text-wing-accent transition-colors disabled:opacity-50"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cron Preview */}
|
||||
{cronExpression.trim() && (
|
||||
<CronPreview cron={cronExpression.trim()} />
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-wing-text mb-1">
|
||||
설명
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="스케줄 설명 (선택)"
|
||||
className="w-full rounded-lg border border-wing-border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-wing-accent focus:border-wing-accent"
|
||||
disabled={!selectedJob || formLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="mt-4 flex justify-end">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!selectedJob || !cronExpression.trim() || saving || formLoading}
|
||||
className="px-6 py-2 text-sm font-medium text-white bg-wing-accent rounded-lg hover:bg-wing-accent/80 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{saving ? '저장 중...' : '저장'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Schedule List */}
|
||||
<div className="bg-wing-surface rounded-xl shadow-lg p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-bold text-wing-text">
|
||||
등록된 스케줄
|
||||
{schedules.length > 0 && (
|
||||
<span className="ml-2 text-sm font-normal text-wing-muted">
|
||||
({schedules.length}개)
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
<button
|
||||
onClick={loadSchedules}
|
||||
className="px-3 py-1.5 text-sm text-wing-muted bg-wing-card rounded-lg hover:bg-wing-hover transition-colors"
|
||||
>
|
||||
새로고침
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{listLoading ? (
|
||||
<LoadingSpinner />
|
||||
) : schedules.length === 0 ? (
|
||||
<EmptyState message="등록된 스케줄이 없습니다" sub="위 폼에서 새 스케줄을 등록하세요" />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{schedules.map((schedule) => (
|
||||
<div
|
||||
key={schedule.id}
|
||||
className="border border-wing-border rounded-xl p-4 hover:shadow-md transition-shadow"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<h3 className="text-sm font-bold text-wing-text truncate">
|
||||
{schedule.jobName}
|
||||
</h3>
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-semibold ${
|
||||
schedule.active
|
||||
? 'bg-emerald-100 text-emerald-700'
|
||||
: 'bg-wing-card text-wing-muted'
|
||||
}`}
|
||||
>
|
||||
{schedule.active ? '활성' : '비활성'}
|
||||
</span>
|
||||
{schedule.triggerState && (
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-semibold ${getTriggerStateStyle(schedule.triggerState)}`}
|
||||
>
|
||||
{schedule.triggerState}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cron Expression */}
|
||||
<div className="mb-2">
|
||||
<span className="inline-block bg-wing-card text-wing-text font-mono text-xs px-2 py-1 rounded">
|
||||
{schedule.cronExpression}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{schedule.description && (
|
||||
<p className="text-sm text-wing-muted mb-3">{schedule.description}</p>
|
||||
)}
|
||||
|
||||
{/* Time Info */}
|
||||
<div className="grid grid-cols-2 gap-2 text-xs text-wing-muted mb-3">
|
||||
<div>
|
||||
<span className="font-medium text-wing-muted">다음 실행:</span>{' '}
|
||||
{formatDateTime(schedule.nextFireTime)}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-wing-muted">이전 실행:</span>{' '}
|
||||
{formatDateTime(schedule.previousFireTime)}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-wing-muted">등록일:</span>{' '}
|
||||
{formatDateTime(schedule.createdAt)}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-wing-muted">수정일:</span>{' '}
|
||||
{formatDateTime(schedule.updatedAt)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-2 pt-2 border-t border-wing-border/50">
|
||||
<button
|
||||
onClick={() => handleEditFromCard(schedule)}
|
||||
className="flex-1 px-3 py-1.5 text-xs font-medium text-wing-accent bg-wing-accent/10 rounded-lg hover:bg-wing-accent/20 transition-colors"
|
||||
>
|
||||
편집
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
setConfirmAction({ type: 'toggle', schedule })
|
||||
}
|
||||
className={`flex-1 px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${
|
||||
schedule.active
|
||||
? 'text-amber-700 bg-amber-50 hover:bg-amber-100'
|
||||
: 'text-emerald-700 bg-emerald-50 hover:bg-emerald-100'
|
||||
}`}
|
||||
>
|
||||
{schedule.active ? '비활성화' : '활성화'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
setConfirmAction({ type: 'delete', schedule })
|
||||
}
|
||||
className="flex-1 px-3 py-1.5 text-xs font-medium text-red-700 bg-red-50 rounded-lg hover:bg-red-100 transition-colors"
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Confirm Modal */}
|
||||
{confirmAction?.type === 'toggle' && (
|
||||
<ConfirmModal
|
||||
open
|
||||
title="스케줄 상태 변경"
|
||||
message={`${confirmAction.schedule.jobName} 스케줄을 ${
|
||||
confirmAction.schedule.active ? '비활성화' : '활성화'
|
||||
}하시겠습니까?`}
|
||||
confirmLabel={confirmAction.schedule.active ? '비활성화' : '활성화'}
|
||||
onConfirm={() => handleToggle(confirmAction.schedule)}
|
||||
onCancel={() => setConfirmAction(null)}
|
||||
/>
|
||||
)}
|
||||
{confirmAction?.type === 'delete' && (
|
||||
<ConfirmModal
|
||||
open
|
||||
title="스케줄 삭제"
|
||||
message={`${confirmAction.schedule.jobName} 스케줄을 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.`}
|
||||
confirmLabel="삭제"
|
||||
confirmColor="bg-red-600 hover:bg-red-700"
|
||||
onConfirm={() => handleDelete(confirmAction.schedule)}
|
||||
onCancel={() => setConfirmAction(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
466
frontend/src/pages/Timeline.tsx
Normal file
466
frontend/src/pages/Timeline.tsx
Normal file
@ -0,0 +1,466 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { batchApi, type ExecutionInfo, type JobExecutionDto, type PeriodInfo, type ScheduleTimeline } from '../api/batchApi';
|
||||
import { formatDateTime, calculateDuration } from '../utils/formatters';
|
||||
import { usePoller } from '../hooks/usePoller';
|
||||
import { useToastContext } from '../contexts/ToastContext';
|
||||
import { getStatusColor } from '../components/StatusBadge';
|
||||
import StatusBadge from '../components/StatusBadge';
|
||||
import LoadingSpinner from '../components/LoadingSpinner';
|
||||
import EmptyState from '../components/EmptyState';
|
||||
|
||||
type ViewType = 'day' | 'week' | 'month';
|
||||
|
||||
interface TooltipData {
|
||||
jobName: string;
|
||||
period: PeriodInfo;
|
||||
execution: ExecutionInfo;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface SelectedCell {
|
||||
jobName: string;
|
||||
periodKey: string;
|
||||
periodLabel: string;
|
||||
}
|
||||
|
||||
const VIEW_OPTIONS: { value: ViewType; label: string }[] = [
|
||||
{ value: 'day', label: 'Day' },
|
||||
{ value: 'week', label: 'Week' },
|
||||
{ value: 'month', label: 'Month' },
|
||||
];
|
||||
|
||||
const LEGEND_ITEMS = [
|
||||
{ status: 'COMPLETED', color: '#10b981', label: '완료' },
|
||||
{ status: 'FAILED', color: '#ef4444', label: '실패' },
|
||||
{ status: 'STARTED', color: '#3b82f6', label: '실행중' },
|
||||
{ status: 'SCHEDULED', color: '#8b5cf6', label: '예정' },
|
||||
{ status: 'NONE', color: '#e5e7eb', label: '없음' },
|
||||
];
|
||||
|
||||
const JOB_COL_WIDTH = 200;
|
||||
const CELL_MIN_WIDTH = 80;
|
||||
const POLLING_INTERVAL = 30000;
|
||||
|
||||
function formatDateStr(date: Date): string {
|
||||
const y = date.getFullYear();
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(date.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
|
||||
function shiftDate(date: Date, view: ViewType, delta: number): Date {
|
||||
const next = new Date(date);
|
||||
switch (view) {
|
||||
case 'day':
|
||||
next.setDate(next.getDate() + delta);
|
||||
break;
|
||||
case 'week':
|
||||
next.setDate(next.getDate() + delta * 7);
|
||||
break;
|
||||
case 'month':
|
||||
next.setMonth(next.getMonth() + delta);
|
||||
break;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function isRunning(status: string): boolean {
|
||||
return status === 'STARTED' || status === 'STARTING';
|
||||
}
|
||||
|
||||
export default function Timeline() {
|
||||
const { showToast } = useToastContext();
|
||||
|
||||
const [view, setView] = useState<ViewType>('day');
|
||||
const [currentDate, setCurrentDate] = useState(() => new Date());
|
||||
const [periodLabel, setPeriodLabel] = useState('');
|
||||
const [periods, setPeriods] = useState<PeriodInfo[]>([]);
|
||||
const [schedules, setSchedules] = useState<ScheduleTimeline[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Tooltip
|
||||
const [tooltip, setTooltip] = useState<TooltipData | null>(null);
|
||||
const tooltipTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Selected cell & detail panel
|
||||
const [selectedCell, setSelectedCell] = useState<SelectedCell | null>(null);
|
||||
const [detailExecutions, setDetailExecutions] = useState<JobExecutionDto[]>([]);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
|
||||
const loadTimeline = useCallback(async () => {
|
||||
try {
|
||||
const dateStr = formatDateStr(currentDate);
|
||||
const result = await batchApi.getTimeline(view, dateStr);
|
||||
setPeriodLabel(result.periodLabel);
|
||||
setPeriods(result.periods);
|
||||
setSchedules(result.schedules);
|
||||
} catch (err) {
|
||||
showToast('타임라인 조회 실패', 'error');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [view, currentDate, showToast]);
|
||||
|
||||
usePoller(loadTimeline, POLLING_INTERVAL, [view, currentDate]);
|
||||
|
||||
const handlePrev = () => setCurrentDate((d) => shiftDate(d, view, -1));
|
||||
const handleNext = () => setCurrentDate((d) => shiftDate(d, view, 1));
|
||||
const handleToday = () => setCurrentDate(new Date());
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setLoading(true);
|
||||
await loadTimeline();
|
||||
};
|
||||
|
||||
// Tooltip handlers
|
||||
const handleCellMouseEnter = (
|
||||
e: React.MouseEvent,
|
||||
jobName: string,
|
||||
period: PeriodInfo,
|
||||
execution: ExecutionInfo,
|
||||
) => {
|
||||
if (tooltipTimeoutRef.current) {
|
||||
clearTimeout(tooltipTimeoutRef.current);
|
||||
}
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
setTooltip({
|
||||
jobName,
|
||||
period,
|
||||
execution,
|
||||
x: rect.left + rect.width / 2,
|
||||
y: rect.top,
|
||||
});
|
||||
};
|
||||
|
||||
const handleCellMouseLeave = () => {
|
||||
tooltipTimeoutRef.current = setTimeout(() => {
|
||||
setTooltip(null);
|
||||
}, 100);
|
||||
};
|
||||
|
||||
// Clean up tooltip timeout
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (tooltipTimeoutRef.current) {
|
||||
clearTimeout(tooltipTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Cell click -> detail panel
|
||||
const handleCellClick = async (jobName: string, periodKey: string, periodLabel: string) => {
|
||||
// Toggle off if clicking same cell
|
||||
if (selectedCell?.jobName === jobName && selectedCell?.periodKey === periodKey) {
|
||||
setSelectedCell(null);
|
||||
setDetailExecutions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedCell({ jobName, periodKey, periodLabel });
|
||||
setDetailLoading(true);
|
||||
setDetailExecutions([]);
|
||||
|
||||
try {
|
||||
const executions = await batchApi.getPeriodExecutions(jobName, view, periodKey);
|
||||
setDetailExecutions(executions);
|
||||
} catch (err) {
|
||||
showToast('구간 실행 이력 조회 실패', 'error');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const closeDetail = () => {
|
||||
setSelectedCell(null);
|
||||
setDetailExecutions([]);
|
||||
};
|
||||
|
||||
const gridTemplateColumns = `${JOB_COL_WIDTH}px repeat(${periods.length}, minmax(${CELL_MIN_WIDTH}px, 1fr))`;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Controls */}
|
||||
<div className="bg-wing-surface rounded-xl shadow-lg p-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{/* View Toggle */}
|
||||
<div className="flex rounded-lg border border-wing-border overflow-hidden">
|
||||
{VIEW_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => {
|
||||
setView(opt.value);
|
||||
setLoading(true);
|
||||
}}
|
||||
className={`px-4 py-1.5 text-sm font-medium transition-colors ${
|
||||
view === opt.value
|
||||
? 'bg-wing-accent text-white'
|
||||
: 'bg-wing-surface text-wing-muted hover:bg-wing-accent/10'
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={handlePrev}
|
||||
className="px-3 py-1.5 text-sm text-wing-muted bg-wing-card rounded-lg hover:bg-wing-hover transition-colors"
|
||||
>
|
||||
← 이전
|
||||
</button>
|
||||
<button
|
||||
onClick={handleToday}
|
||||
className="px-3 py-1.5 text-sm font-medium text-wing-accent bg-wing-accent/10 rounded-lg hover:bg-wing-accent/15 transition-colors"
|
||||
>
|
||||
오늘
|
||||
</button>
|
||||
<button
|
||||
onClick={handleNext}
|
||||
className="px-3 py-1.5 text-sm text-wing-muted bg-wing-card rounded-lg hover:bg-wing-hover transition-colors"
|
||||
>
|
||||
다음 →
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Period Label */}
|
||||
<span className="text-sm font-semibold text-wing-text">
|
||||
{periodLabel}
|
||||
</span>
|
||||
|
||||
{/* Refresh */}
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
className="ml-auto px-3 py-1.5 text-sm text-wing-muted bg-wing-card rounded-lg hover:bg-wing-hover transition-colors"
|
||||
>
|
||||
새로고침
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex flex-wrap items-center gap-4 px-2">
|
||||
{LEGEND_ITEMS.map((item) => (
|
||||
<div key={item.status} className="flex items-center gap-1.5">
|
||||
<div
|
||||
className="w-4 h-4 rounded"
|
||||
style={{ backgroundColor: item.color }}
|
||||
/>
|
||||
<span className="text-xs text-wing-muted">{item.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Timeline Grid */}
|
||||
<div className="bg-wing-surface rounded-xl shadow-lg overflow-hidden">
|
||||
{loading ? (
|
||||
<LoadingSpinner />
|
||||
) : schedules.length === 0 ? (
|
||||
<EmptyState message="타임라인 데이터가 없습니다" sub="등록된 스케줄이 없거나 해당 기간에 실행 이력이 없습니다" />
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<div
|
||||
className="grid min-w-max"
|
||||
style={{ gridTemplateColumns }}
|
||||
>
|
||||
{/* Header Row */}
|
||||
<div className="sticky left-0 z-20 bg-wing-card border-b border-r border-wing-border px-3 py-2 text-xs font-semibold text-wing-muted">
|
||||
작업명
|
||||
</div>
|
||||
{periods.map((period) => (
|
||||
<div
|
||||
key={period.key}
|
||||
className="bg-wing-card border-b border-r border-wing-border px-2 py-2 text-xs font-medium text-wing-muted text-center whitespace-nowrap"
|
||||
>
|
||||
{period.label}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Data Rows */}
|
||||
{schedules.map((schedule) => (
|
||||
<>
|
||||
{/* Job Name (sticky) */}
|
||||
<div
|
||||
key={`name-${schedule.jobName}`}
|
||||
className="sticky left-0 z-10 bg-wing-surface border-b border-r border-wing-border px-3 py-2 text-xs font-medium text-wing-text truncate flex items-center"
|
||||
title={schedule.jobName}
|
||||
>
|
||||
{schedule.jobName}
|
||||
</div>
|
||||
|
||||
{/* Execution Cells */}
|
||||
{periods.map((period) => {
|
||||
const exec = schedule.executions[period.key];
|
||||
const hasExec = exec !== null && exec !== undefined;
|
||||
const isSelected =
|
||||
selectedCell?.jobName === schedule.jobName &&
|
||||
selectedCell?.periodKey === period.key;
|
||||
const running = hasExec && isRunning(exec.status);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`cell-${schedule.jobName}-${period.key}`}
|
||||
className={`border-b border-r border-wing-border/50 p-1 cursor-pointer transition-all hover:opacity-80 ${
|
||||
isSelected ? 'ring-2 ring-yellow-400 ring-inset' : ''
|
||||
}`}
|
||||
onClick={() =>
|
||||
handleCellClick(schedule.jobName, period.key, period.label)
|
||||
}
|
||||
onMouseEnter={
|
||||
hasExec
|
||||
? (e) => handleCellMouseEnter(e, schedule.jobName, period, exec)
|
||||
: undefined
|
||||
}
|
||||
onMouseLeave={hasExec ? handleCellMouseLeave : undefined}
|
||||
>
|
||||
{hasExec && (
|
||||
<div
|
||||
className={`w-full h-6 rounded ${running ? 'animate-pulse' : ''}`}
|
||||
style={{ backgroundColor: getStatusColor(exec.status) }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tooltip */}
|
||||
{tooltip && (
|
||||
<div
|
||||
className="fixed z-50 pointer-events-none"
|
||||
style={{
|
||||
left: tooltip.x,
|
||||
top: tooltip.y - 8,
|
||||
transform: 'translate(-50%, -100%)',
|
||||
}}
|
||||
>
|
||||
<div className="bg-gray-900 text-white text-xs rounded-lg px-3 py-2 shadow-lg max-w-xs">
|
||||
<div className="font-semibold mb-1">{tooltip.jobName}</div>
|
||||
<div className="space-y-0.5 text-gray-300">
|
||||
<div>기간: {tooltip.period.label}</div>
|
||||
<div>
|
||||
상태:{' '}
|
||||
<span
|
||||
className="font-medium"
|
||||
style={{ color: getStatusColor(tooltip.execution.status) }}
|
||||
>
|
||||
{tooltip.execution.status}
|
||||
</span>
|
||||
</div>
|
||||
{tooltip.execution.startTime && (
|
||||
<div>시작: {formatDateTime(tooltip.execution.startTime)}</div>
|
||||
)}
|
||||
{tooltip.execution.endTime && (
|
||||
<div>종료: {formatDateTime(tooltip.execution.endTime)}</div>
|
||||
)}
|
||||
{tooltip.execution.executionId && (
|
||||
<div>실행 ID: {tooltip.execution.executionId}</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Arrow */}
|
||||
<div className="absolute left-1/2 -translate-x-1/2 top-full w-0 h-0 border-l-[6px] border-l-transparent border-r-[6px] border-r-transparent border-t-[6px] border-t-gray-900" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Detail Panel */}
|
||||
{selectedCell && (
|
||||
<div className="bg-wing-surface rounded-xl shadow-lg p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-wing-text">
|
||||
{selectedCell.jobName}
|
||||
</h3>
|
||||
<p className="text-xs text-wing-muted mt-0.5">
|
||||
구간: {selectedCell.periodLabel}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={closeDetail}
|
||||
className="px-3 py-1.5 text-xs text-wing-muted bg-wing-card rounded-lg hover:bg-wing-hover transition-colors"
|
||||
>
|
||||
닫기
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{detailLoading ? (
|
||||
<LoadingSpinner className="py-6" />
|
||||
) : detailExecutions.length === 0 ? (
|
||||
<EmptyState
|
||||
message="해당 구간에 실행 이력이 없습니다"
|
||||
icon="📭"
|
||||
/>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-wing-border">
|
||||
<th className="text-left py-2 px-3 text-xs font-semibold text-wing-muted">
|
||||
실행 ID
|
||||
</th>
|
||||
<th className="text-left py-2 px-3 text-xs font-semibold text-wing-muted">
|
||||
상태
|
||||
</th>
|
||||
<th className="text-left py-2 px-3 text-xs font-semibold text-wing-muted">
|
||||
시작 시간
|
||||
</th>
|
||||
<th className="text-left py-2 px-3 text-xs font-semibold text-wing-muted">
|
||||
종료 시간
|
||||
</th>
|
||||
<th className="text-left py-2 px-3 text-xs font-semibold text-wing-muted">
|
||||
소요 시간
|
||||
</th>
|
||||
<th className="text-right py-2 px-3 text-xs font-semibold text-wing-muted">
|
||||
상세
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{detailExecutions.map((exec) => (
|
||||
<tr
|
||||
key={exec.executionId}
|
||||
className="border-b border-wing-border/50 hover:bg-wing-hover"
|
||||
>
|
||||
<td className="py-2 px-3 text-xs font-mono text-wing-text">
|
||||
#{exec.executionId}
|
||||
</td>
|
||||
<td className="py-2 px-3">
|
||||
<StatusBadge status={exec.status} />
|
||||
</td>
|
||||
<td className="py-2 px-3 text-xs text-wing-muted">
|
||||
{formatDateTime(exec.startTime)}
|
||||
</td>
|
||||
<td className="py-2 px-3 text-xs text-wing-muted">
|
||||
{formatDateTime(exec.endTime)}
|
||||
</td>
|
||||
<td className="py-2 px-3 text-xs text-wing-muted">
|
||||
{calculateDuration(exec.startTime, exec.endTime)}
|
||||
</td>
|
||||
<td className="py-2 px-3 text-right">
|
||||
<a
|
||||
href={`/executions/${exec.executionId}`}
|
||||
className="text-xs text-wing-accent hover:text-wing-accent font-medium"
|
||||
>
|
||||
상세
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
25
frontend/src/theme/base.css
Normal file
25
frontend/src/theme/base.css
Normal file
@ -0,0 +1,25 @@
|
||||
body {
|
||||
font-family: 'Noto Sans KR', sans-serif;
|
||||
background: var(--wing-bg);
|
||||
color: var(--wing-text);
|
||||
transition: background-color 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
|
||||
/* Scrollbar styling for dark mode */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--wing-surface);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--wing-muted);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--wing-accent);
|
||||
}
|
||||
66
frontend/src/theme/tokens.css
Normal file
66
frontend/src/theme/tokens.css
Normal file
@ -0,0 +1,66 @@
|
||||
/* Dark theme (default) */
|
||||
:root,
|
||||
[data-theme='dark'] {
|
||||
--wing-bg: #020617;
|
||||
--wing-surface: #0f172a;
|
||||
--wing-card: #1e293b;
|
||||
--wing-border: #1e3a5f;
|
||||
--wing-text: #e2e8f0;
|
||||
--wing-muted: #64748b;
|
||||
--wing-accent: #3b82f6;
|
||||
--wing-danger: #ef4444;
|
||||
--wing-warning: #f59e0b;
|
||||
--wing-success: #22c55e;
|
||||
--wing-glass: rgba(15, 23, 42, 0.92);
|
||||
--wing-glass-dense: rgba(15, 23, 42, 0.95);
|
||||
--wing-overlay: rgba(2, 6, 23, 0.42);
|
||||
--wing-card-alpha: rgba(30, 41, 59, 0.55);
|
||||
--wing-subtle: rgba(255, 255, 255, 0.03);
|
||||
--wing-hover: rgba(255, 255, 255, 0.05);
|
||||
--wing-input-bg: #0f172a;
|
||||
--wing-input-border: #334155;
|
||||
}
|
||||
|
||||
/* Light theme */
|
||||
[data-theme='light'] {
|
||||
--wing-bg: #e2e8f0;
|
||||
--wing-surface: #ffffff;
|
||||
--wing-card: #f1f5f9;
|
||||
--wing-border: #94a3b8;
|
||||
--wing-text: #0f172a;
|
||||
--wing-muted: #64748b;
|
||||
--wing-accent: #2563eb;
|
||||
--wing-danger: #dc2626;
|
||||
--wing-warning: #d97706;
|
||||
--wing-success: #16a34a;
|
||||
--wing-glass: rgba(255, 255, 255, 0.92);
|
||||
--wing-glass-dense: rgba(255, 255, 255, 0.95);
|
||||
--wing-overlay: rgba(0, 0, 0, 0.25);
|
||||
--wing-card-alpha: rgba(226, 232, 240, 0.6);
|
||||
--wing-subtle: rgba(0, 0, 0, 0.03);
|
||||
--wing-hover: rgba(0, 0, 0, 0.04);
|
||||
--wing-input-bg: #ffffff;
|
||||
--wing-input-border: #cbd5e1;
|
||||
}
|
||||
|
||||
@theme {
|
||||
--color-wing-bg: var(--wing-bg);
|
||||
--color-wing-surface: var(--wing-surface);
|
||||
--color-wing-card: var(--wing-card);
|
||||
--color-wing-border: var(--wing-border);
|
||||
--color-wing-text: var(--wing-text);
|
||||
--color-wing-muted: var(--wing-muted);
|
||||
--color-wing-accent: var(--wing-accent);
|
||||
--color-wing-danger: var(--wing-danger);
|
||||
--color-wing-warning: var(--wing-warning);
|
||||
--color-wing-success: var(--wing-success);
|
||||
--color-wing-glass: var(--wing-glass);
|
||||
--color-wing-glass-dense: var(--wing-glass-dense);
|
||||
--color-wing-overlay: var(--wing-overlay);
|
||||
--color-wing-card-alpha: var(--wing-card-alpha);
|
||||
--color-wing-subtle: var(--wing-subtle);
|
||||
--color-wing-hover: var(--wing-hover);
|
||||
--color-wing-input-bg: var(--wing-input-bg);
|
||||
--color-wing-input-border: var(--wing-input-border);
|
||||
--font-sans: 'Noto Sans KR', sans-serif;
|
||||
}
|
||||
153
frontend/src/utils/cronPreview.ts
Normal file
153
frontend/src/utils/cronPreview.ts
Normal file
@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Quartz 형식 Cron 표현식의 다음 실행 시간을 계산한다.
|
||||
* 형식: 초 분 시 일 월 요일
|
||||
*/
|
||||
export function getNextExecutions(cron: string, count: number): Date[] {
|
||||
const parts = cron.trim().split(/\s+/);
|
||||
if (parts.length < 6) return [];
|
||||
|
||||
const [secField, minField, hourField, dayField, monthField, dowField] = parts;
|
||||
|
||||
if (hasUnsupportedToken(dayField) || hasUnsupportedToken(dowField)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const seconds = parseField(secField, 0, 59);
|
||||
const minutes = parseField(minField, 0, 59);
|
||||
const hours = parseField(hourField, 0, 23);
|
||||
const daysOfMonth = parseField(dayField, 1, 31);
|
||||
const months = parseField(monthField, 1, 12);
|
||||
const daysOfWeek = parseDowField(dowField);
|
||||
|
||||
if (!seconds || !minutes || !hours || !months) return [];
|
||||
|
||||
const results: Date[] = [];
|
||||
const now = new Date();
|
||||
const cursor = new Date(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours(), now.getMinutes(), now.getSeconds() + 1);
|
||||
cursor.setMilliseconds(0);
|
||||
|
||||
const limit = new Date(now.getTime() + 365 * 24 * 60 * 60 * 1000);
|
||||
|
||||
while (results.length < count && cursor.getTime() <= limit.getTime()) {
|
||||
const month = cursor.getMonth() + 1;
|
||||
if (!months.includes(month)) {
|
||||
cursor.setMonth(cursor.getMonth() + 1, 1);
|
||||
cursor.setHours(0, 0, 0, 0);
|
||||
continue;
|
||||
}
|
||||
|
||||
const day = cursor.getDate();
|
||||
const dayMatches = daysOfMonth ? daysOfMonth.includes(day) : true;
|
||||
const dowMatches = daysOfWeek ? daysOfWeek.includes(cursor.getDay()) : true;
|
||||
|
||||
const needDayCheck = dayField !== '?' && dowField !== '?';
|
||||
const dayOk = needDayCheck ? dayMatches && dowMatches : dayMatches && dowMatches;
|
||||
|
||||
if (!dayOk) {
|
||||
cursor.setDate(cursor.getDate() + 1);
|
||||
cursor.setHours(0, 0, 0, 0);
|
||||
continue;
|
||||
}
|
||||
|
||||
const hour = cursor.getHours();
|
||||
if (!hours.includes(hour)) {
|
||||
cursor.setHours(cursor.getHours() + 1, 0, 0, 0);
|
||||
continue;
|
||||
}
|
||||
|
||||
const minute = cursor.getMinutes();
|
||||
if (!minutes.includes(minute)) {
|
||||
cursor.setMinutes(cursor.getMinutes() + 1, 0, 0);
|
||||
continue;
|
||||
}
|
||||
|
||||
const second = cursor.getSeconds();
|
||||
if (!seconds.includes(second)) {
|
||||
cursor.setSeconds(cursor.getSeconds() + 1, 0);
|
||||
continue;
|
||||
}
|
||||
|
||||
results.push(new Date(cursor));
|
||||
cursor.setSeconds(cursor.getSeconds() + 1);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function hasUnsupportedToken(field: string): boolean {
|
||||
return /[LW#]/.test(field);
|
||||
}
|
||||
|
||||
function parseField(field: string, min: number, max: number): number[] | null {
|
||||
if (field === '?') return null;
|
||||
if (field === '*') return range(min, max);
|
||||
|
||||
const values = new Set<number>();
|
||||
|
||||
for (const part of field.split(',')) {
|
||||
const stepMatch = part.match(/^(.+)\/(\d+)$/);
|
||||
if (stepMatch) {
|
||||
const [, base, stepStr] = stepMatch;
|
||||
const step = parseInt(stepStr, 10);
|
||||
let start = min;
|
||||
let end = max;
|
||||
|
||||
if (base === '*') {
|
||||
start = min;
|
||||
} else if (base.includes('-')) {
|
||||
const [lo, hi] = base.split('-').map(Number);
|
||||
start = lo;
|
||||
end = hi;
|
||||
} else {
|
||||
start = parseInt(base, 10);
|
||||
}
|
||||
|
||||
for (let v = start; v <= end; v += step) {
|
||||
if (v >= min && v <= max) values.add(v);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const rangeMatch = part.match(/^(\d+)-(\d+)$/);
|
||||
if (rangeMatch) {
|
||||
const lo = parseInt(rangeMatch[1], 10);
|
||||
const hi = parseInt(rangeMatch[2], 10);
|
||||
for (let v = lo; v <= hi; v++) {
|
||||
if (v >= min && v <= max) values.add(v);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const num = parseInt(part, 10);
|
||||
if (!isNaN(num) && num >= min && num <= max) {
|
||||
values.add(num);
|
||||
}
|
||||
}
|
||||
|
||||
return values.size > 0 ? Array.from(values).sort((a, b) => a - b) : range(min, max);
|
||||
}
|
||||
|
||||
function parseDowField(field: string): number[] | null {
|
||||
if (field === '?' || field === '*') return null;
|
||||
|
||||
const dayMap: Record<string, string> = {
|
||||
SUN: '0', MON: '1', TUE: '2', WED: '3', THU: '4', FRI: '5', SAT: '6',
|
||||
};
|
||||
|
||||
let normalized = field.toUpperCase();
|
||||
for (const [name, num] of Object.entries(dayMap)) {
|
||||
normalized = normalized.replace(new RegExp(name, 'g'), num);
|
||||
}
|
||||
|
||||
// Quartz uses 1=SUN..7=SAT, convert to JS 0=SUN..6=SAT
|
||||
const parsed = parseField(normalized, 1, 7);
|
||||
if (!parsed) return null;
|
||||
|
||||
return parsed.map((v) => v - 1);
|
||||
}
|
||||
|
||||
function range(min: number, max: number): number[] {
|
||||
const result: number[] = [];
|
||||
for (let i = min; i <= max; i++) result.push(i);
|
||||
return result;
|
||||
}
|
||||
58
frontend/src/utils/formatters.ts
Normal file
58
frontend/src/utils/formatters.ts
Normal file
@ -0,0 +1,58 @@
|
||||
export function formatDateTime(dateTimeStr: string | null | undefined): string {
|
||||
if (!dateTimeStr) return '-';
|
||||
try {
|
||||
const date = new Date(dateTimeStr);
|
||||
if (isNaN(date.getTime())) return '-';
|
||||
const y = date.getFullYear();
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(date.getDate()).padStart(2, '0');
|
||||
const h = String(date.getHours()).padStart(2, '0');
|
||||
const min = String(date.getMinutes()).padStart(2, '0');
|
||||
const s = String(date.getSeconds()).padStart(2, '0');
|
||||
return `${y}-${m}-${d} ${h}:${min}:${s}`;
|
||||
} catch {
|
||||
return '-';
|
||||
}
|
||||
}
|
||||
|
||||
export function formatDateTimeShort(dateTimeStr: string | null | undefined): string {
|
||||
if (!dateTimeStr) return '-';
|
||||
try {
|
||||
const date = new Date(dateTimeStr);
|
||||
if (isNaN(date.getTime())) return '-';
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(date.getDate()).padStart(2, '0');
|
||||
const h = String(date.getHours()).padStart(2, '0');
|
||||
const min = String(date.getMinutes()).padStart(2, '0');
|
||||
return `${m}/${d} ${h}:${min}`;
|
||||
} catch {
|
||||
return '-';
|
||||
}
|
||||
}
|
||||
|
||||
export function formatDuration(ms: number | null | undefined): string {
|
||||
if (ms == null || ms < 0) return '-';
|
||||
const totalSeconds = Math.floor(ms / 1000);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
if (hours > 0) return `${hours}시간 ${minutes}분 ${seconds}초`;
|
||||
if (minutes > 0) return `${minutes}분 ${seconds}초`;
|
||||
return `${seconds}초`;
|
||||
}
|
||||
|
||||
export function calculateDuration(
|
||||
startTime: string | null | undefined,
|
||||
endTime: string | null | undefined,
|
||||
): string {
|
||||
if (!startTime) return '-';
|
||||
const start = new Date(startTime).getTime();
|
||||
if (isNaN(start)) return '-';
|
||||
|
||||
if (!endTime) return '실행 중...';
|
||||
const end = new Date(endTime).getTime();
|
||||
if (isNaN(end)) return '-';
|
||||
|
||||
return formatDuration(end - start);
|
||||
}
|
||||
28
frontend/tsconfig.app.json
Normal file
28
frontend/tsconfig.app.json
Normal file
@ -0,0 +1,28 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
frontend/tsconfig.json
Normal file
7
frontend/tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
frontend/tsconfig.node.json
Normal file
26
frontend/tsconfig.node.json
Normal file
@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
21
frontend/vite.config.ts
Normal file
21
frontend/vite.config.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/snp-api/api': {
|
||||
target: 'http://localhost:8041',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
base: '/snp-api/',
|
||||
build: {
|
||||
outDir: '../src/main/resources/static',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
})
|
||||
29
pom.xml
29
pom.xml
@ -160,6 +160,35 @@
|
||||
</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>
|
||||
|
||||
149
sql/chnprmship-cache-diag.sql
Normal file
149
sql/chnprmship-cache-diag.sql
Normal file
@ -0,0 +1,149 @@
|
||||
-- ============================================================
|
||||
-- ChnPrmShip 캐시 검증 진단 쿼리
|
||||
-- 대상: t_std_snp_data.ais_target (일별 파티션)
|
||||
-- 목적: 최근 2일 내 대상 MMSI별 최종위치 캐싱 검증
|
||||
-- ============================================================
|
||||
|
||||
-- ============================================================
|
||||
-- 0. 대상 MMSI 임시 테이블 생성
|
||||
-- ============================================================
|
||||
CREATE TEMP TABLE tmp_chn_mmsi (mmsi BIGINT PRIMARY KEY);
|
||||
|
||||
-- psql에서 실행:
|
||||
-- \copy tmp_chn_mmsi(mmsi) FROM 'chnprmship-mmsi.txt'
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 1. 기본 현황: 대상 MMSI 중 최근 2일 내 데이터 존재 여부
|
||||
-- ============================================================
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM tmp_chn_mmsi) AS total_target_mmsi,
|
||||
COUNT(DISTINCT a.mmsi) AS mmsi_with_data_2d,
|
||||
(SELECT COUNT(*) FROM tmp_chn_mmsi) - COUNT(DISTINCT a.mmsi) AS mmsi_without_data_2d,
|
||||
ROUND(COUNT(DISTINCT a.mmsi) * 100.0
|
||||
/ NULLIF((SELECT COUNT(*) FROM tmp_chn_mmsi), 0), 1) AS hit_rate_pct
|
||||
FROM t_std_snp_data.ais_target a
|
||||
JOIN tmp_chn_mmsi t ON a.mmsi = t.mmsi
|
||||
WHERE a.message_timestamp >= NOW() - INTERVAL '2 days';
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 2. 워밍업 시뮬레이션: 최근 2일 내 MMSI별 최종위치
|
||||
-- (수정 후 findLatestByMmsiIn 쿼리와 동일하게 동작)
|
||||
-- ============================================================
|
||||
SELECT COUNT(*) AS cached_count,
|
||||
MIN(message_timestamp) AS oldest_cached,
|
||||
MAX(message_timestamp) AS newest_cached,
|
||||
NOW() - MAX(message_timestamp) AS newest_age
|
||||
FROM (
|
||||
SELECT DISTINCT ON (a.mmsi) a.mmsi, a.message_timestamp
|
||||
FROM t_std_snp_data.ais_target a
|
||||
JOIN tmp_chn_mmsi t ON a.mmsi = t.mmsi
|
||||
WHERE a.message_timestamp >= NOW() - INTERVAL '2 days'
|
||||
ORDER BY a.mmsi, a.message_timestamp DESC
|
||||
) latest;
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 3. MMSI별 최종위치 상세 (최근 2일 내, 최신순 상위 30건)
|
||||
-- ============================================================
|
||||
SELECT DISTINCT ON (a.mmsi)
|
||||
a.mmsi,
|
||||
a.message_timestamp,
|
||||
a.name,
|
||||
a.vessel_type,
|
||||
a.lat,
|
||||
a.lon,
|
||||
a.sog,
|
||||
a.cog,
|
||||
a.heading,
|
||||
NOW() - a.message_timestamp AS data_age
|
||||
FROM t_std_snp_data.ais_target a
|
||||
JOIN tmp_chn_mmsi t ON a.mmsi = t.mmsi
|
||||
WHERE a.message_timestamp >= NOW() - INTERVAL '2 days'
|
||||
ORDER BY a.mmsi, a.message_timestamp DESC
|
||||
LIMIT 30;
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 4. 데이터 없는 대상 MMSI (최근 2일 내 DB에 없는 선박)
|
||||
-- ============================================================
|
||||
SELECT t.mmsi AS missing_mmsi
|
||||
FROM tmp_chn_mmsi t
|
||||
LEFT JOIN (
|
||||
SELECT DISTINCT mmsi
|
||||
FROM t_std_snp_data.ais_target
|
||||
WHERE mmsi IN (SELECT mmsi FROM tmp_chn_mmsi)
|
||||
AND message_timestamp >= NOW() - INTERVAL '2 days'
|
||||
) a ON t.mmsi = a.mmsi
|
||||
WHERE a.mmsi IS NULL
|
||||
ORDER BY t.mmsi;
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 5. 시간대별 분포 (2일 기준 세부 확인)
|
||||
-- ============================================================
|
||||
SELECT
|
||||
'6시간 이내' AS time_range,
|
||||
COUNT(DISTINCT mmsi) AS distinct_mmsi
|
||||
FROM t_std_snp_data.ais_target a
|
||||
JOIN tmp_chn_mmsi t ON a.mmsi = t.mmsi
|
||||
WHERE a.message_timestamp >= NOW() - INTERVAL '6 hours'
|
||||
|
||||
UNION ALL
|
||||
SELECT '12시간 이내', COUNT(DISTINCT mmsi)
|
||||
FROM t_std_snp_data.ais_target a
|
||||
JOIN tmp_chn_mmsi t ON a.mmsi = t.mmsi
|
||||
WHERE a.message_timestamp >= NOW() - INTERVAL '12 hours'
|
||||
|
||||
UNION ALL
|
||||
SELECT '1일 이내', COUNT(DISTINCT mmsi)
|
||||
FROM t_std_snp_data.ais_target a
|
||||
JOIN tmp_chn_mmsi t ON a.mmsi = t.mmsi
|
||||
WHERE a.message_timestamp >= NOW() - INTERVAL '1 day'
|
||||
|
||||
UNION ALL
|
||||
SELECT '2일 이내', COUNT(DISTINCT mmsi)
|
||||
FROM t_std_snp_data.ais_target a
|
||||
JOIN tmp_chn_mmsi t ON a.mmsi = t.mmsi
|
||||
WHERE a.message_timestamp >= NOW() - INTERVAL '2 days'
|
||||
|
||||
UNION ALL
|
||||
SELECT '전체(무제한)', COUNT(DISTINCT mmsi)
|
||||
FROM t_std_snp_data.ais_target a
|
||||
JOIN tmp_chn_mmsi t ON a.mmsi = t.mmsi;
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 6. 파티션별 대상 데이터 분포
|
||||
-- ============================================================
|
||||
SELECT
|
||||
tableoid::regclass AS partition_name,
|
||||
COUNT(*) AS row_count,
|
||||
COUNT(DISTINCT mmsi) AS distinct_mmsi,
|
||||
MIN(message_timestamp) AS min_ts,
|
||||
MAX(message_timestamp) AS max_ts
|
||||
FROM t_std_snp_data.ais_target a
|
||||
JOIN tmp_chn_mmsi t ON a.mmsi = t.mmsi
|
||||
GROUP BY tableoid::regclass
|
||||
ORDER BY max_ts DESC;
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 7. 전체 ais_target 파티션 현황
|
||||
-- ============================================================
|
||||
SELECT
|
||||
c.relname AS partition_name,
|
||||
pg_size_pretty(pg_relation_size(c.oid)) AS table_size,
|
||||
s.n_live_tup AS estimated_rows
|
||||
FROM pg_inherits i
|
||||
JOIN pg_class c ON c.oid = i.inhrelid
|
||||
JOIN pg_stat_user_tables s ON s.relid = c.oid
|
||||
WHERE i.inhparent = 't_std_snp_data.ais_target'::regclass
|
||||
ORDER BY c.relname DESC;
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 정리
|
||||
-- ============================================================
|
||||
DROP TABLE IF EXISTS tmp_chn_mmsi;
|
||||
@ -2,10 +2,11 @@ package com.snp.batch;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.autoconfigure.kafka.KafkaAutoConfiguration;
|
||||
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
@SpringBootApplication
|
||||
@SpringBootApplication(exclude = KafkaAutoConfiguration.class)
|
||||
@EnableScheduling
|
||||
@ConfigurationPropertiesScan
|
||||
public class SnpBatchApplication {
|
||||
|
||||
@ -1,233 +0,0 @@
|
||||
package com.snp.batch.common.util;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import java.security.MessageDigest;
|
||||
import java.util.*;
|
||||
|
||||
public class JsonChangeDetector {
|
||||
|
||||
// Map으로 변환 시 사용할 ObjectMapper (표준 Mapper 사용)
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
// 해시 비교에서 제외할 필드 목록 (DataSetVersion 등)
|
||||
// 이 목록은 모든 JSON 계층에 걸쳐 적용됩니다.
|
||||
private static final java.util.Set<String> EXCLUDE_KEYS =
|
||||
java.util.Set.of("DataSetVersion", "APSStatus", "LastUpdateDateTime");
|
||||
|
||||
// =========================================================================
|
||||
// ✅ LIST_SORT_KEYS: 정적 초기화 블록을 사용한 Map 정의
|
||||
// =========================================================================
|
||||
private static final Map<String, String> LIST_SORT_KEYS;
|
||||
static {
|
||||
// TreeMap을 사용하여 키를 알파벳 순으로 정렬할 수도 있지만, 여기서는 HashMap을 사용하고 final로 만듭니다.
|
||||
Map<String, String> map = new HashMap<>();
|
||||
// List 필드명 // 정렬 기준 복합 키 (JSON 필드명, 쉼표로 구분)
|
||||
map.put("OwnerHistory", "OwnerCode,EffectiveDate,Sequence");
|
||||
map.put("CrewList", "LRNO,Shipname,Nationality");
|
||||
map.put("StowageCommodity", "Sequence,CommodityCode,StowageCode");
|
||||
map.put("GroupBeneficialOwnerHistory", "EffectiveDate,GroupBeneficialOwnerCode,Sequence");
|
||||
map.put("ShipManagerHistory", "EffectiveDate,ShipManagerCode,Sequence");
|
||||
map.put("OperatorHistory", "EffectiveDate,OperatorCode,Sequence");
|
||||
map.put("TechnicalManagerHistory", "EffectiveDate,Sequence,TechnicalManagerCode");
|
||||
map.put("BareBoatCharterHistory", "Sequence,EffectiveDate,BBChartererCode");
|
||||
map.put("NameHistory", "Sequence,EffectiveDate");
|
||||
map.put("FlagHistory", "FlagCode,EffectiveDate,Sequence");
|
||||
map.put("PandIHistory", "PandIClubCode,EffectiveDate");
|
||||
map.put("CallSignAndMmsiHistory", "EffectiveDate,SeqNo");
|
||||
map.put("IceClass", "IceClassCode");
|
||||
map.put("SafetyManagementCertificateHistory", "Sequence");
|
||||
map.put("ClassHistory", "ClassCode,EffectiveDate,Sequence");
|
||||
map.put("SurveyDatesHistory", "ClassSocietyCode");
|
||||
map.put("SurveyDatesHistoryUnique", "ClassSocietyCode,SurveyDate,SurveyType");
|
||||
map.put("SisterShipLinks", "LinkedLRNO");
|
||||
map.put("StatusHistory", "Sequence,StatusCode,StatusDate");
|
||||
map.put("SpecialFeature", "Sequence,SpecialFeatureCode");
|
||||
map.put("Thrusters", "Sequence");
|
||||
map.put("DarkActivityConfirmed", "Lrno,Mmsi,Dark_Time,Dark_Status");
|
||||
map.put("CompanyComplianceDetails", "OwCode");
|
||||
map.put("CompanyVesselRelationships", "LRNO");
|
||||
map.put("CompanyDetailsComplexWithCodesAndParent", "OWCODE,LastChangeDate");
|
||||
|
||||
LIST_SORT_KEYS = Collections.unmodifiableMap(map);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 1. JSON 문자열을 정렬 및 필터링된 Map으로 변환하는 핵심 로직
|
||||
// =========================================================================
|
||||
/**
|
||||
* JSON 문자열을 Map으로 변환하고, 특정 키를 제거하며, 키 순서가 정렬된 상태로 만듭니다.
|
||||
* @param jsonString API 응답 또는 DB에서 읽은 JSON 문자열
|
||||
* @return 필터링되고 정렬된 Map 객체
|
||||
*/
|
||||
public static Map<String, Object> jsonToSortedFilteredMap(String jsonString) {
|
||||
if (jsonString == null || jsonString.trim().isEmpty()) {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Map<String, Object>으로 1차 변환합니다. (순서 보장 안됨)
|
||||
Map<String, Object> rawMap = MAPPER.readValue(jsonString,
|
||||
new com.fasterxml.jackson.core.type.TypeReference<Map<String, Object>>() {});
|
||||
|
||||
// 2. 재귀 함수를 호출하여 키를 제거하고 TreeMap(키 순서 정렬)으로 깊은 복사합니다.
|
||||
return deepFilterAndSort(rawMap);
|
||||
|
||||
} catch (Exception e) {
|
||||
System.err.println("Error converting JSON to filtered Map: " + e.getMessage());
|
||||
// 예외 발생 시 빈 Map 반환
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map을 재귀적으로 탐색하며 제외 키를 제거하고 TreeMap(알파벳 순서)으로 변환합니다.
|
||||
*/
|
||||
private static Map<String, Object> deepFilterAndSort(Map<String, Object> rawMap) {
|
||||
// Map을 TreeMap으로 생성하여 키 순서를 알파벳 순으로 강제 정렬합니다.
|
||||
Map<String, Object> sortedMap = new TreeMap<>();
|
||||
|
||||
for (Map.Entry<String, Object> entry : rawMap.entrySet()) {
|
||||
String key = entry.getKey();
|
||||
Object value = entry.getValue();
|
||||
|
||||
// 🔑 1. 제외할 키 값인지 확인
|
||||
if (EXCLUDE_KEYS.contains(key)) {
|
||||
continue; // 제외
|
||||
}
|
||||
|
||||
// 2. 값의 타입에 따라 재귀 처리
|
||||
if (value instanceof Map) {
|
||||
// 재귀 호출: 하위 Map을 필터링하고 정렬
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> subMap = (Map<String, Object>) value;
|
||||
sortedMap.put(key, deepFilterAndSort(subMap));
|
||||
} else if (value instanceof List) {
|
||||
// List 처리: List 내부의 Map 요소만 재귀 호출
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Object> rawList = (List<Object>) value;
|
||||
List<Object> filteredList = new ArrayList<>();
|
||||
|
||||
// 1. List 내부의 Map 요소들을 재귀적으로 필터링/정렬하여 filteredList에 추가
|
||||
for (Object item : rawList) {
|
||||
if (item instanceof Map) {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> itemMap = (Map<String, Object>) item;
|
||||
// List의 요소인 Map도 필터링하고 정렬 (Map의 필드 순서 정렬)
|
||||
filteredList.add(deepFilterAndSort(itemMap));
|
||||
} else {
|
||||
filteredList.add(item);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 🔑 List 필드명에 따른 복합 순서 정렬 로직 (수정된 핵심 로직)
|
||||
String listFieldName = entry.getKey();
|
||||
String sortKeysString = LIST_SORT_KEYS.get(listFieldName); // 쉼표로 구분된 복합 키 문자열
|
||||
|
||||
if (sortKeysString != null && !filteredList.isEmpty() && filteredList.get(0) instanceof Map) {
|
||||
// 복합 키 문자열을 개별 키 배열로 분리
|
||||
final String[] sortKeys = sortKeysString.split(",");
|
||||
|
||||
// Map 요소를 가진 리스트인 경우에만 정렬 실행
|
||||
try {
|
||||
Collections.sort(filteredList, new Comparator<Object>() {
|
||||
@Override
|
||||
@SuppressWarnings("unchecked")
|
||||
public int compare(Object o1, Object o2) {
|
||||
Map<String, Object> map1 = (Map<String, Object>) o1;
|
||||
Map<String, Object> map2 = (Map<String, Object>) o2;
|
||||
|
||||
// 복합 키(sortKeys)를 순서대로 순회하며 비교
|
||||
for (String rawSortKey : sortKeys) {
|
||||
// 키의 공백 제거
|
||||
String sortKey = rawSortKey.trim();
|
||||
|
||||
Object key1 = map1.get(sortKey);
|
||||
Object key2 = map2.get(sortKey);
|
||||
|
||||
// null 값 처리 로직
|
||||
if (key1 == null && key2 == null) {
|
||||
continue; // 두 값이 동일하므로 다음 키로 이동
|
||||
}
|
||||
if (key1 == null) {
|
||||
// key1이 null이고 key2는 null이 아니면, key2가 더 크다고 (뒤 순서) 간주하고 1 반환
|
||||
return 1;
|
||||
}
|
||||
if (key2 == null) {
|
||||
// key2가 null이고 key1은 null이 아니면, key1이 더 크다고 (뒤 순서) 간주하고 -1 반환
|
||||
return -1;
|
||||
}
|
||||
|
||||
// 값을 문자열로 변환하여 비교 (String, Number, Date 타입 모두 처리 가능)
|
||||
int comparisonResult = key1.toString().compareTo(key2.toString());
|
||||
|
||||
// 현재 키에서 순서가 결정되면 즉시 반환
|
||||
if (comparisonResult != 0) {
|
||||
return comparisonResult;
|
||||
}
|
||||
// comparisonResult == 0 이면 다음 키로 이동하여 비교를 계속함
|
||||
}
|
||||
|
||||
// 모든 키를 비교해도 동일한 경우
|
||||
// 이 경우 두 Map은 해시값 측면에서 동일한 것으로 간주되어야 합니다.
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
System.err.println("List sort failed for key " + listFieldName + ": " + e.getMessage());
|
||||
// 정렬 실패 시 원래 순서 유지 (filteredList 상태 유지)
|
||||
}
|
||||
}
|
||||
sortedMap.put(key, filteredList);
|
||||
} else {
|
||||
// String, Number 등 기본 타입은 그대로 추가
|
||||
sortedMap.put(key, value);
|
||||
}
|
||||
}
|
||||
return sortedMap;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 2. 해시 생성 로직
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 필터링되고 정렬된 Map의 문자열 표현을 기반으로 SHA-256 해시를 생성합니다.
|
||||
*/
|
||||
public static String getSha256HashFromMap(Map<String, Object> sortedMap) {
|
||||
// 1. Map을 String으로 변환: TreeMap 덕분에 toString() 결과가 항상 동일한 순서를 가집니다.
|
||||
String mapString = sortedMap.toString();
|
||||
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
byte[] hash = digest.digest(mapString.getBytes("UTF-8"));
|
||||
|
||||
// 바이트 배열을 16진수 문자열로 변환
|
||||
StringBuilder hexString = new StringBuilder();
|
||||
for (byte b : hash) {
|
||||
String hex = Integer.toHexString(0xff & b);
|
||||
if (hex.length() == 1) hexString.append('0');
|
||||
hexString.append(hex);
|
||||
}
|
||||
return hexString.toString();
|
||||
} catch (Exception e) {
|
||||
System.err.println("Error generating hash: " + e.getMessage());
|
||||
return "HASH_ERROR";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// =========================================================================
|
||||
// 3. 해시값 비교 로직
|
||||
// =========================================================================
|
||||
public static boolean isChanged(String previousHash, String currentHash) {
|
||||
// DB 해시가 null인 경우 (첫 Insert)는 변경된 것으로 간주
|
||||
if (previousHash == null || previousHash.isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 해시값이 다르면 변경된 것으로 간주
|
||||
return !Objects.equals(previousHash, currentHash);
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,33 +0,0 @@
|
||||
package com.snp.batch.common.util;
|
||||
|
||||
public class SafeGetDataUtil {
|
||||
private String safeGetString(String value) {
|
||||
if (value == null || value.trim().isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
private Double safeGetDouble(String value) {
|
||||
if (value == null || value.trim().isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return Double.parseDouble(value);
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private Long safeGetLong(String value) {
|
||||
if (value == null || value.trim().isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return Long.parseLong(value.trim());
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
package com.snp.batch.common.web;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
@ -14,26 +15,19 @@ import lombok.NoArgsConstructor;
|
||||
@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;
|
||||
|
||||
/**
|
||||
|
||||
@ -1,300 +0,0 @@
|
||||
package com.snp.batch.common.web.controller;
|
||||
|
||||
import com.snp.batch.common.web.ApiResponse;
|
||||
import com.snp.batch.common.web.service.BaseService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 모든 REST Controller의 공통 베이스 클래스
|
||||
* CRUD API의 일관된 구조 제공
|
||||
*
|
||||
* 이 클래스는 추상 클래스이므로 @Tag를 붙이지 않습니다.
|
||||
* 하위 클래스에서 @Tag를 정의하면 모든 엔드포인트가 해당 태그로 그룹화됩니다.
|
||||
*
|
||||
* @param <D> DTO 타입
|
||||
* @param <ID> ID 타입
|
||||
*/
|
||||
@Slf4j
|
||||
public abstract class BaseController<D, ID> {
|
||||
|
||||
/**
|
||||
* Service 반환 (하위 클래스에서 구현)
|
||||
*/
|
||||
protected abstract BaseService<?, D, ID> getService();
|
||||
|
||||
/**
|
||||
* 리소스 이름 반환 (로깅용)
|
||||
*/
|
||||
protected abstract String getResourceName();
|
||||
|
||||
/**
|
||||
* 단건 생성
|
||||
*/
|
||||
@Operation(
|
||||
summary = "리소스 생성",
|
||||
description = "새로운 리소스를 생성합니다",
|
||||
responses = {
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "생성 성공"
|
||||
),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||
responseCode = "500",
|
||||
description = "서버 오류"
|
||||
)
|
||||
}
|
||||
)
|
||||
@PostMapping
|
||||
public ResponseEntity<ApiResponse<D>> create(
|
||||
@Parameter(description = "생성할 리소스 데이터", required = true)
|
||||
@RequestBody D dto) {
|
||||
log.info("{} 생성 요청", getResourceName());
|
||||
try {
|
||||
D created = getService().create(dto);
|
||||
return ResponseEntity.ok(
|
||||
ApiResponse.success(getResourceName() + " created successfully", created)
|
||||
);
|
||||
} catch (Exception e) {
|
||||
log.error("{} 생성 실패", getResourceName(), e);
|
||||
return ResponseEntity.internalServerError().body(
|
||||
ApiResponse.error("Failed to create " + getResourceName() + ": " + e.getMessage())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 단건 조회
|
||||
*/
|
||||
@Operation(
|
||||
summary = "리소스 조회",
|
||||
description = "ID로 특정 리소스를 조회합니다",
|
||||
responses = {
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "조회 성공"
|
||||
),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||
responseCode = "404",
|
||||
description = "리소스 없음"
|
||||
),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||
responseCode = "500",
|
||||
description = "서버 오류"
|
||||
)
|
||||
}
|
||||
)
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<ApiResponse<D>> getById(
|
||||
@Parameter(description = "리소스 ID", required = true)
|
||||
@PathVariable ID id) {
|
||||
log.info("{} 조회 요청: ID={}", getResourceName(), id);
|
||||
try {
|
||||
return getService().findById(id)
|
||||
.map(dto -> ResponseEntity.ok(ApiResponse.success(dto)))
|
||||
.orElse(ResponseEntity.notFound().build());
|
||||
} catch (Exception e) {
|
||||
log.error("{} 조회 실패: ID={}", getResourceName(), id, e);
|
||||
return ResponseEntity.internalServerError().body(
|
||||
ApiResponse.error("Failed to get " + getResourceName() + ": " + e.getMessage())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체 조회
|
||||
*/
|
||||
@Operation(
|
||||
summary = "전체 리소스 조회",
|
||||
description = "모든 리소스를 조회합니다",
|
||||
responses = {
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "조회 성공"
|
||||
),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||
responseCode = "500",
|
||||
description = "서버 오류"
|
||||
)
|
||||
}
|
||||
)
|
||||
@GetMapping
|
||||
public ResponseEntity<ApiResponse<List<D>>> getAll() {
|
||||
log.info("{} 전체 조회 요청", getResourceName());
|
||||
try {
|
||||
List<D> list = getService().findAll();
|
||||
return ResponseEntity.ok(ApiResponse.success(list));
|
||||
} catch (Exception e) {
|
||||
log.error("{} 전체 조회 실패", getResourceName(), e);
|
||||
return ResponseEntity.internalServerError().body(
|
||||
ApiResponse.error("Failed to get all " + getResourceName() + ": " + e.getMessage())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이징 조회 (JDBC 기반)
|
||||
*
|
||||
* @param offset 시작 위치 (기본값: 0)
|
||||
* @param limit 조회 개수 (기본값: 20)
|
||||
*/
|
||||
@Operation(
|
||||
summary = "페이징 조회",
|
||||
description = "페이지 단위로 리소스를 조회합니다",
|
||||
responses = {
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "조회 성공"
|
||||
),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||
responseCode = "500",
|
||||
description = "서버 오류"
|
||||
)
|
||||
}
|
||||
)
|
||||
@GetMapping("/page")
|
||||
public ResponseEntity<ApiResponse<List<D>>> getPage(
|
||||
@Parameter(description = "시작 위치 (0부터 시작)", example = "0")
|
||||
@RequestParam(defaultValue = "0") int offset,
|
||||
@Parameter(description = "조회 개수", example = "20")
|
||||
@RequestParam(defaultValue = "20") int limit) {
|
||||
log.info("{} 페이징 조회 요청: offset={}, limit={}",
|
||||
getResourceName(), offset, limit);
|
||||
try {
|
||||
List<D> list = getService().findAll(offset, limit);
|
||||
long total = getService().count();
|
||||
|
||||
// 페이징 정보를 포함한 응답
|
||||
return ResponseEntity.ok(
|
||||
ApiResponse.success("Retrieved " + list.size() + " items (total: " + total + ")", list)
|
||||
);
|
||||
} catch (Exception e) {
|
||||
log.error("{} 페이징 조회 실패", getResourceName(), e);
|
||||
return ResponseEntity.internalServerError().body(
|
||||
ApiResponse.error("Failed to get page of " + getResourceName() + ": " + e.getMessage())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 단건 수정
|
||||
*/
|
||||
@Operation(
|
||||
summary = "리소스 수정",
|
||||
description = "기존 리소스를 수정합니다",
|
||||
responses = {
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "수정 성공"
|
||||
),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||
responseCode = "404",
|
||||
description = "리소스 없음"
|
||||
),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||
responseCode = "500",
|
||||
description = "서버 오류"
|
||||
)
|
||||
}
|
||||
)
|
||||
@PutMapping("/{id}")
|
||||
public ResponseEntity<ApiResponse<D>> update(
|
||||
@Parameter(description = "리소스 ID", required = true)
|
||||
@PathVariable ID id,
|
||||
@Parameter(description = "수정할 리소스 데이터", required = true)
|
||||
@RequestBody D dto) {
|
||||
log.info("{} 수정 요청: ID={}", getResourceName(), id);
|
||||
try {
|
||||
D updated = getService().update(id, dto);
|
||||
return ResponseEntity.ok(
|
||||
ApiResponse.success(getResourceName() + " updated successfully", updated)
|
||||
);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.notFound().build();
|
||||
} catch (Exception e) {
|
||||
log.error("{} 수정 실패: ID={}", getResourceName(), id, e);
|
||||
return ResponseEntity.internalServerError().body(
|
||||
ApiResponse.error("Failed to update " + getResourceName() + ": " + e.getMessage())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 단건 삭제
|
||||
*/
|
||||
@Operation(
|
||||
summary = "리소스 삭제",
|
||||
description = "기존 리소스를 삭제합니다",
|
||||
responses = {
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "삭제 성공"
|
||||
),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||
responseCode = "404",
|
||||
description = "리소스 없음"
|
||||
),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||
responseCode = "500",
|
||||
description = "서버 오류"
|
||||
)
|
||||
}
|
||||
)
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<ApiResponse<Void>> delete(
|
||||
@Parameter(description = "리소스 ID", required = true)
|
||||
@PathVariable ID id) {
|
||||
log.info("{} 삭제 요청: ID={}", getResourceName(), id);
|
||||
try {
|
||||
getService().deleteById(id);
|
||||
return ResponseEntity.ok(
|
||||
ApiResponse.success(getResourceName() + " deleted successfully", null)
|
||||
);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.notFound().build();
|
||||
} catch (Exception e) {
|
||||
log.error("{} 삭제 실패: ID={}", getResourceName(), id, e);
|
||||
return ResponseEntity.internalServerError().body(
|
||||
ApiResponse.error("Failed to delete " + getResourceName() + ": " + e.getMessage())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 존재 여부 확인
|
||||
*/
|
||||
@Operation(
|
||||
summary = "리소스 존재 확인",
|
||||
description = "특정 ID의 리소스가 존재하는지 확인합니다",
|
||||
responses = {
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "확인 성공"
|
||||
),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||
responseCode = "500",
|
||||
description = "서버 오류"
|
||||
)
|
||||
}
|
||||
)
|
||||
@GetMapping("/{id}/exists")
|
||||
public ResponseEntity<ApiResponse<Boolean>> exists(
|
||||
@Parameter(description = "리소스 ID", required = true)
|
||||
@PathVariable ID id) {
|
||||
log.debug("{} 존재 여부 확인: ID={}", getResourceName(), id);
|
||||
try {
|
||||
boolean exists = getService().existsById(id);
|
||||
return ResponseEntity.ok(ApiResponse.success(exists));
|
||||
} catch (Exception e) {
|
||||
log.error("{} 존재 여부 확인 실패: ID={}", getResourceName(), id, e);
|
||||
return ResponseEntity.internalServerError().body(
|
||||
ApiResponse.error("Failed to check existence: " + e.getMessage())
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,33 +0,0 @@
|
||||
package com.snp.batch.common.web.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 모든 DTO의 공통 베이스 클래스
|
||||
* 생성/수정 정보 등 공통 필드
|
||||
*/
|
||||
@Data
|
||||
public abstract class BaseDto {
|
||||
|
||||
/**
|
||||
* 생성 일시
|
||||
*/
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
/**
|
||||
* 수정 일시
|
||||
*/
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
/**
|
||||
* 생성자
|
||||
*/
|
||||
private String createdBy;
|
||||
|
||||
/**
|
||||
* 수정자
|
||||
*/
|
||||
private String updatedBy;
|
||||
}
|
||||
@ -1,202 +0,0 @@
|
||||
package com.snp.batch.common.web.service;
|
||||
|
||||
import com.snp.batch.common.batch.repository.BaseJdbcRepository;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 하이브리드 서비스 Base 클래스 (DB 캐시 + 외부 API 프록시)
|
||||
*
|
||||
* 사용 시나리오:
|
||||
* 1. 클라이언트 요청 → DB 조회 (캐시 Hit)
|
||||
* - 캐시 데이터 유효 시 즉시 반환
|
||||
* 2. 캐시 Miss 또는 만료 시
|
||||
* - 외부 서비스 API 호출
|
||||
* - DB에 저장 (캐시 갱신)
|
||||
* - 클라이언트에게 반환
|
||||
*
|
||||
* 장점:
|
||||
* - 빠른 응답 (DB 캐시)
|
||||
* - 외부 서비스 장애 시에도 캐시 데이터 제공 가능
|
||||
* - 외부 API 호출 횟수 감소 (비용 절감)
|
||||
*
|
||||
* @param <T> Entity 타입
|
||||
* @param <D> DTO 타입
|
||||
* @param <ID> ID 타입
|
||||
*/
|
||||
@Slf4j
|
||||
public abstract class BaseHybridService<T, D, ID> extends BaseServiceImpl<T, D, ID> {
|
||||
|
||||
/**
|
||||
* WebClient 반환 (하위 클래스에서 구현)
|
||||
*/
|
||||
protected abstract WebClient getWebClient();
|
||||
|
||||
/**
|
||||
* 외부 서비스 이름 반환
|
||||
*/
|
||||
protected abstract String getExternalServiceName();
|
||||
|
||||
/**
|
||||
* 캐시 유효 시간 (초)
|
||||
* 기본값: 300초 (5분)
|
||||
*/
|
||||
protected long getCacheTtlSeconds() {
|
||||
return 300;
|
||||
}
|
||||
|
||||
/**
|
||||
* 요청 타임아웃
|
||||
*/
|
||||
protected Duration getTimeout() {
|
||||
return Duration.ofSeconds(30);
|
||||
}
|
||||
|
||||
/**
|
||||
* 하이브리드 조회: DB 캐시 우선, 없으면 외부 API 호출
|
||||
*
|
||||
* @param id 조회 키
|
||||
* @return DTO
|
||||
*/
|
||||
@Transactional
|
||||
public D findByIdHybrid(ID id) {
|
||||
log.info("[하이브리드] ID로 조회: {}", id);
|
||||
|
||||
// 1. DB 캐시 조회
|
||||
Optional<D> cached = findById(id);
|
||||
|
||||
if (cached.isPresent()) {
|
||||
// 캐시 유효성 검증
|
||||
if (isCacheValid(cached.get())) {
|
||||
log.info("[하이브리드] 캐시 Hit - DB에서 반환");
|
||||
return cached.get();
|
||||
} else {
|
||||
log.info("[하이브리드] 캐시 만료 - 외부 API 호출");
|
||||
}
|
||||
} else {
|
||||
log.info("[하이브리드] 캐시 Miss - 외부 API 호출");
|
||||
}
|
||||
|
||||
// 2. 외부 API 호출
|
||||
try {
|
||||
D externalData = fetchFromExternalApi(id);
|
||||
|
||||
// 3. DB 저장 (캐시 갱신)
|
||||
T entity = toEntity(externalData);
|
||||
T saved = getRepository().save(entity);
|
||||
|
||||
log.info("[하이브리드] 외부 데이터 DB 저장 완료");
|
||||
return toDto(saved);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("[하이브리드] 외부 API 호출 실패: {}", e.getMessage());
|
||||
|
||||
// 4. 외부 API 실패 시 만료된 캐시라도 반환 (Fallback)
|
||||
if (cached.isPresent()) {
|
||||
log.warn("[하이브리드] Fallback - 만료된 캐시 반환");
|
||||
return cached.get();
|
||||
}
|
||||
|
||||
throw new RuntimeException("데이터 조회 실패: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 외부 API에서 데이터 조회 (하위 클래스에서 구현)
|
||||
*
|
||||
* @param id 조회 키
|
||||
* @return DTO
|
||||
*/
|
||||
protected abstract D fetchFromExternalApi(ID id) throws Exception;
|
||||
|
||||
/**
|
||||
* 캐시 유효성 검증
|
||||
* 기본 구현: updated_at 기준으로 TTL 체크
|
||||
*
|
||||
* @param dto 캐시 데이터
|
||||
* @return 유효 여부
|
||||
*/
|
||||
protected boolean isCacheValid(D dto) {
|
||||
// BaseDto를 상속한 경우 updatedAt 체크
|
||||
try {
|
||||
LocalDateTime updatedAt = extractUpdatedAt(dto);
|
||||
if (updatedAt == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
long elapsedSeconds = Duration.between(updatedAt, now).getSeconds();
|
||||
|
||||
return elapsedSeconds < getCacheTtlSeconds();
|
||||
|
||||
} catch (Exception e) {
|
||||
log.warn("캐시 유효성 검증 실패 - 항상 최신 데이터 조회: {}", e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO에서 updatedAt 추출 (하위 클래스에서 오버라이드 가능)
|
||||
*/
|
||||
protected LocalDateTime extractUpdatedAt(D dto) {
|
||||
// 기본 구현: 항상 캐시 무효 (외부 API 호출)
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 강제 캐시 갱신 (외부 API 호출 강제)
|
||||
*/
|
||||
@Transactional
|
||||
public D refreshCache(ID id) throws Exception {
|
||||
log.info("[하이브리드] 캐시 강제 갱신: {}", id);
|
||||
|
||||
D externalData = fetchFromExternalApi(id);
|
||||
T entity = toEntity(externalData);
|
||||
T saved = getRepository().save(entity);
|
||||
|
||||
return toDto(saved);
|
||||
}
|
||||
|
||||
/**
|
||||
* 외부 API GET 요청
|
||||
*/
|
||||
protected <RES> RES callExternalGet(String endpoint, Map<String, String> params, Class<RES> responseType) {
|
||||
log.info("[{}] GET 요청: endpoint={}", getExternalServiceName(), endpoint);
|
||||
|
||||
return getWebClient()
|
||||
.get()
|
||||
.uri(uriBuilder -> {
|
||||
uriBuilder.path(endpoint);
|
||||
if (params != null) {
|
||||
params.forEach(uriBuilder::queryParam);
|
||||
}
|
||||
return uriBuilder.build();
|
||||
})
|
||||
.retrieve()
|
||||
.bodyToMono(responseType)
|
||||
.timeout(getTimeout())
|
||||
.block();
|
||||
}
|
||||
|
||||
/**
|
||||
* 외부 API POST 요청
|
||||
*/
|
||||
protected <REQ, RES> RES callExternalPost(String endpoint, REQ requestBody, Class<RES> responseType) {
|
||||
log.info("[{}] POST 요청: endpoint={}", getExternalServiceName(), endpoint);
|
||||
|
||||
return getWebClient()
|
||||
.post()
|
||||
.uri(endpoint)
|
||||
.bodyValue(requestBody)
|
||||
.retrieve()
|
||||
.bodyToMono(responseType)
|
||||
.timeout(getTimeout())
|
||||
.block();
|
||||
}
|
||||
}
|
||||
@ -1,176 +0,0 @@
|
||||
package com.snp.batch.common.web.service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 외부 API 프록시 서비스 Base 클래스
|
||||
*
|
||||
* 목적: 해외 외부 서비스를 국내에서 우회 접근할 수 있도록 프록시 역할 수행
|
||||
*
|
||||
* 사용 시나리오:
|
||||
* - 외부 서비스가 해외에 있고 국내 IP에서만 접근 가능
|
||||
* - 클라이언트 A → 우리 서버 (국내) → 외부 서비스 (해외) → 응답 전달
|
||||
*
|
||||
* 장점:
|
||||
* - 실시간 데이터 제공 (DB 캐시 없이)
|
||||
* - 외부 서비스의 최신 데이터 보장
|
||||
* - DB 저장 부담 없음
|
||||
*
|
||||
* @param <REQ> 요청 DTO 타입
|
||||
* @param <RES> 응답 DTO 타입
|
||||
*/
|
||||
@Slf4j
|
||||
public abstract class BaseProxyService<REQ, RES> {
|
||||
|
||||
/**
|
||||
* WebClient 반환 (하위 클래스에서 구현)
|
||||
* 외부 서비스별로 인증, Base URL 등 설정
|
||||
*/
|
||||
protected abstract WebClient getWebClient();
|
||||
|
||||
/**
|
||||
* 외부 서비스 이름 반환 (로깅용)
|
||||
*/
|
||||
protected abstract String getServiceName();
|
||||
|
||||
/**
|
||||
* 요청 타임아웃 (밀리초)
|
||||
* 기본값: 30초
|
||||
*/
|
||||
protected Duration getTimeout() {
|
||||
return Duration.ofSeconds(30);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET 요청 프록시
|
||||
*
|
||||
* @param endpoint 엔드포인트 경로 (예: "/api/ships")
|
||||
* @param params 쿼리 파라미터
|
||||
* @param responseType 응답 클래스 타입
|
||||
* @return 외부 서비스 응답
|
||||
*/
|
||||
public RES proxyGet(String endpoint, Map<String, String> params, Class<RES> responseType) {
|
||||
log.info("[{}] GET 요청 프록시: endpoint={}, params={}", getServiceName(), endpoint, params);
|
||||
|
||||
try {
|
||||
WebClient.RequestHeadersSpec<?> spec = getWebClient()
|
||||
.get()
|
||||
.uri(uriBuilder -> {
|
||||
uriBuilder.path(endpoint);
|
||||
if (params != null) {
|
||||
params.forEach(uriBuilder::queryParam);
|
||||
}
|
||||
return uriBuilder.build();
|
||||
});
|
||||
|
||||
RES response = spec.retrieve()
|
||||
.bodyToMono(responseType)
|
||||
.timeout(getTimeout())
|
||||
.block();
|
||||
|
||||
log.info("[{}] 응답 성공", getServiceName());
|
||||
return response;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("[{}] 프록시 요청 실패: {}", getServiceName(), e.getMessage(), e);
|
||||
throw new RuntimeException("외부 서비스 호출 실패: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST 요청 프록시
|
||||
*
|
||||
* @param endpoint 엔드포인트 경로
|
||||
* @param requestBody 요청 본문
|
||||
* @param responseType 응답 클래스 타입
|
||||
* @return 외부 서비스 응답
|
||||
*/
|
||||
public RES proxyPost(String endpoint, REQ requestBody, Class<RES> responseType) {
|
||||
log.info("[{}] POST 요청 프록시: endpoint={}", getServiceName(), endpoint);
|
||||
|
||||
try {
|
||||
RES response = getWebClient()
|
||||
.post()
|
||||
.uri(endpoint)
|
||||
.bodyValue(requestBody)
|
||||
.retrieve()
|
||||
.bodyToMono(responseType)
|
||||
.timeout(getTimeout())
|
||||
.block();
|
||||
|
||||
log.info("[{}] 응답 성공", getServiceName());
|
||||
return response;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("[{}] 프록시 요청 실패: {}", getServiceName(), e.getMessage(), e);
|
||||
throw new RuntimeException("외부 서비스 호출 실패: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT 요청 프록시
|
||||
*/
|
||||
public RES proxyPut(String endpoint, REQ requestBody, Class<RES> responseType) {
|
||||
log.info("[{}] PUT 요청 프록시: endpoint={}", getServiceName(), endpoint);
|
||||
|
||||
try {
|
||||
RES response = getWebClient()
|
||||
.put()
|
||||
.uri(endpoint)
|
||||
.bodyValue(requestBody)
|
||||
.retrieve()
|
||||
.bodyToMono(responseType)
|
||||
.timeout(getTimeout())
|
||||
.block();
|
||||
|
||||
log.info("[{}] 응답 성공", getServiceName());
|
||||
return response;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("[{}] 프록시 요청 실패: {}", getServiceName(), e.getMessage(), e);
|
||||
throw new RuntimeException("외부 서비스 호출 실패: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE 요청 프록시
|
||||
*/
|
||||
public void proxyDelete(String endpoint, Map<String, String> params) {
|
||||
log.info("[{}] DELETE 요청 프록시: endpoint={}, params={}", getServiceName(), endpoint, params);
|
||||
|
||||
try {
|
||||
getWebClient()
|
||||
.delete()
|
||||
.uri(uriBuilder -> {
|
||||
uriBuilder.path(endpoint);
|
||||
if (params != null) {
|
||||
params.forEach(uriBuilder::queryParam);
|
||||
}
|
||||
return uriBuilder.build();
|
||||
})
|
||||
.retrieve()
|
||||
.bodyToMono(Void.class)
|
||||
.timeout(getTimeout())
|
||||
.block();
|
||||
|
||||
log.info("[{}] DELETE 성공", getServiceName());
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("[{}] 프록시 DELETE 실패: {}", getServiceName(), e.getMessage(), e);
|
||||
throw new RuntimeException("외부 서비스 호출 실패: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 커스텀 요청 처리 (하위 클래스에서 오버라이드)
|
||||
* 복잡한 로직이 필요한 경우 사용
|
||||
*/
|
||||
protected RES customRequest(REQ request) {
|
||||
throw new UnsupportedOperationException("커스텀 요청이 구현되지 않았습니다");
|
||||
}
|
||||
}
|
||||
@ -1,94 +0,0 @@
|
||||
package com.snp.batch.common.web.service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 모든 서비스의 공통 인터페이스 (JDBC 기반)
|
||||
* CRUD 기본 메서드 정의
|
||||
*
|
||||
* @param <T> Entity 타입
|
||||
* @param <D> DTO 타입
|
||||
* @param <ID> ID 타입
|
||||
*/
|
||||
public interface BaseService<T, D, ID> {
|
||||
|
||||
/**
|
||||
* 단건 생성
|
||||
*
|
||||
* @param dto 생성할 데이터 DTO
|
||||
* @return 생성된 데이터 DTO
|
||||
*/
|
||||
D create(D dto);
|
||||
|
||||
/**
|
||||
* 단건 조회
|
||||
*
|
||||
* @param id 조회할 ID
|
||||
* @return 조회된 데이터 DTO (Optional)
|
||||
*/
|
||||
Optional<D> findById(ID id);
|
||||
|
||||
/**
|
||||
* 전체 조회
|
||||
*
|
||||
* @return 전체 데이터 DTO 리스트
|
||||
*/
|
||||
List<D> findAll();
|
||||
|
||||
/**
|
||||
* 페이징 조회
|
||||
*
|
||||
* @param offset 시작 위치 (0부터 시작)
|
||||
* @param limit 조회 개수
|
||||
* @return 페이징된 데이터 리스트
|
||||
*/
|
||||
List<D> findAll(int offset, int limit);
|
||||
|
||||
/**
|
||||
* 전체 개수 조회
|
||||
*
|
||||
* @return 전체 데이터 개수
|
||||
*/
|
||||
long count();
|
||||
|
||||
/**
|
||||
* 단건 수정
|
||||
*
|
||||
* @param id 수정할 ID
|
||||
* @param dto 수정할 데이터 DTO
|
||||
* @return 수정된 데이터 DTO
|
||||
*/
|
||||
D update(ID id, D dto);
|
||||
|
||||
/**
|
||||
* 단건 삭제
|
||||
*
|
||||
* @param id 삭제할 ID
|
||||
*/
|
||||
void deleteById(ID id);
|
||||
|
||||
/**
|
||||
* 존재 여부 확인
|
||||
*
|
||||
* @param id 확인할 ID
|
||||
* @return 존재 여부
|
||||
*/
|
||||
boolean existsById(ID id);
|
||||
|
||||
/**
|
||||
* Entity를 DTO로 변환
|
||||
*
|
||||
* @param entity 엔티티
|
||||
* @return DTO
|
||||
*/
|
||||
D toDto(T entity);
|
||||
|
||||
/**
|
||||
* DTO를 Entity로 변환
|
||||
*
|
||||
* @param dto DTO
|
||||
* @return 엔티티
|
||||
*/
|
||||
T toEntity(D dto);
|
||||
}
|
||||
@ -1,131 +0,0 @@
|
||||
package com.snp.batch.common.web.service;
|
||||
|
||||
import com.snp.batch.common.batch.repository.BaseJdbcRepository;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* BaseService의 기본 구현 (JDBC 기반)
|
||||
* 공통 CRUD 로직 구현
|
||||
*
|
||||
* @param <T> Entity 타입
|
||||
* @param <D> DTO 타입
|
||||
* @param <ID> ID 타입
|
||||
*/
|
||||
@Slf4j
|
||||
@Transactional(readOnly = true)
|
||||
public abstract class BaseServiceImpl<T, D, ID> implements BaseService<T, D, ID> {
|
||||
|
||||
/**
|
||||
* Repository 반환 (하위 클래스에서 구현)
|
||||
*/
|
||||
protected abstract BaseJdbcRepository<T, ID> getRepository();
|
||||
|
||||
/**
|
||||
* 엔티티 이름 반환 (로깅용)
|
||||
*/
|
||||
protected abstract String getEntityName();
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public D create(D dto) {
|
||||
log.info("{} 생성 시작", getEntityName());
|
||||
T entity = toEntity(dto);
|
||||
T saved = getRepository().save(entity);
|
||||
log.info("{} 생성 완료: ID={}", getEntityName(), extractId(saved));
|
||||
return toDto(saved);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<D> findById(ID id) {
|
||||
log.debug("{} 조회: ID={}", getEntityName(), id);
|
||||
return getRepository().findById(id).map(this::toDto);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<D> findAll() {
|
||||
log.debug("{} 전체 조회", getEntityName());
|
||||
return getRepository().findAll().stream()
|
||||
.map(this::toDto)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<D> findAll(int offset, int limit) {
|
||||
log.debug("{} 페이징 조회: offset={}, limit={}", getEntityName(), offset, limit);
|
||||
|
||||
// 하위 클래스에서 제공하는 페이징 쿼리 실행
|
||||
List<T> entities = executePagingQuery(offset, limit);
|
||||
|
||||
return entities.stream()
|
||||
.map(this::toDto)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이징 쿼리 실행 (하위 클래스에서 구현)
|
||||
*
|
||||
* @param offset 시작 위치
|
||||
* @param limit 조회 개수
|
||||
* @return Entity 리스트
|
||||
*/
|
||||
protected abstract List<T> executePagingQuery(int offset, int limit);
|
||||
|
||||
@Override
|
||||
public long count() {
|
||||
log.debug("{} 개수 조회", getEntityName());
|
||||
return getRepository().count();
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public D update(ID id, D dto) {
|
||||
log.info("{} 수정 시작: ID={}", getEntityName(), id);
|
||||
|
||||
T entity = getRepository().findById(id)
|
||||
.orElseThrow(() -> new IllegalArgumentException(
|
||||
getEntityName() + " not found with id: " + id));
|
||||
|
||||
updateEntity(entity, dto);
|
||||
T updated = getRepository().save(entity);
|
||||
|
||||
log.info("{} 수정 완료: ID={}", getEntityName(), id);
|
||||
return toDto(updated);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void deleteById(ID id) {
|
||||
log.info("{} 삭제: ID={}", getEntityName(), id);
|
||||
|
||||
if (!getRepository().existsById(id)) {
|
||||
throw new IllegalArgumentException(
|
||||
getEntityName() + " not found with id: " + id);
|
||||
}
|
||||
|
||||
getRepository().deleteById(id);
|
||||
log.info("{} 삭제 완료: ID={}", getEntityName(), id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean existsById(ID id) {
|
||||
return getRepository().existsById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Entity 업데이트 (하위 클래스에서 구현)
|
||||
*
|
||||
* @param entity 업데이트할 엔티티
|
||||
* @param dto 업데이트 데이터
|
||||
*/
|
||||
protected abstract void updateEntity(T entity, D dto);
|
||||
|
||||
/**
|
||||
* Entity에서 ID 추출 (로깅용, 하위 클래스에서 구현)
|
||||
*/
|
||||
protected abstract ID extractId(T entity);
|
||||
}
|
||||
@ -15,9 +15,9 @@ import java.util.List;
|
||||
* Swagger/OpenAPI 3.0 설정
|
||||
*
|
||||
* Swagger UI 접속 URL:
|
||||
* - Swagger UI: http://localhost:8081/swagger-ui/index.html
|
||||
* - API 문서 (JSON): http://localhost:8081/v3/api-docs
|
||||
* - API 문서 (YAML): http://localhost:8081/v3/api-docs.yaml
|
||||
* - Swagger UI: http://localhost:8041/snp-api/swagger-ui/index.html
|
||||
* - API 문서 (JSON): http://localhost:8041/snp-api/v3/api-docs
|
||||
* - API 문서 (YAML): http://localhost:8041/snp-api/v3/api-docs.yaml
|
||||
*
|
||||
* 주요 기능:
|
||||
* - REST API 자동 문서화
|
||||
@ -62,17 +62,19 @@ public class SwaggerConfig {
|
||||
.description("""
|
||||
## SNP Batch 시스템 REST API 문서
|
||||
|
||||
Spring Batch 기반 데이터 통합 시스템의 REST API 문서입니다.
|
||||
해양 데이터 통합 배치 시스템의 REST API 문서입니다.
|
||||
|
||||
### 제공 API
|
||||
- **Batch API**: 배치 Job 실행 및 관리
|
||||
- **Product API**: 샘플 제품 데이터 CRUD (샘플용)
|
||||
- **Batch Management API**: 배치 Job 실행, 이력 조회, 스케줄 관리
|
||||
- **AIS Target API**: AIS 선박 위치 정보 조회 (캐시 기반, 공간/조건 검색)
|
||||
|
||||
### 주요 기능
|
||||
- 배치 Job 실행 및 중지
|
||||
- Job 실행 이력 조회
|
||||
- 스케줄 관리 (Quartz)
|
||||
- 제품 데이터 CRUD (샘플)
|
||||
- AIS 선박 실시간 위치 조회 (MMSI 단건/다건, 시간/공간 범위 검색)
|
||||
- 항해 조건 필터 검색 (SOG, COG, Heading, 목적지, 항행상태)
|
||||
- 폴리곤/WKT 범위 검색, 거리 포함 검색, 항적 조회
|
||||
|
||||
### 버전 정보
|
||||
- API Version: v1.0.0
|
||||
|
||||
@ -1,9 +1,6 @@
|
||||
package com.snp.batch.global.controller;
|
||||
|
||||
import com.snp.batch.global.dto.JobExecutionDto;
|
||||
import com.snp.batch.global.dto.JobLaunchRequest;
|
||||
import com.snp.batch.global.dto.ScheduleRequest;
|
||||
import com.snp.batch.global.dto.ScheduleResponse;
|
||||
import com.snp.batch.global.dto.*;
|
||||
import com.snp.batch.service.BatchService;
|
||||
import com.snp.batch.service.ScheduleService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
@ -20,6 +17,8 @@ import org.springdoc.core.annotations.ParameterObject;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@ -101,7 +100,7 @@ public class BatchController {
|
||||
})
|
||||
@GetMapping("/jobs")
|
||||
public ResponseEntity<List<String>> listJobs() {
|
||||
log.info("Received request to list all jobs");
|
||||
log.debug("Received request to list all jobs");
|
||||
List<String> jobs = batchService.listAllJobs();
|
||||
return ResponseEntity.ok(jobs);
|
||||
}
|
||||
@ -119,6 +118,16 @@ public class BatchController {
|
||||
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);
|
||||
@ -291,7 +300,7 @@ public class BatchController {
|
||||
public ResponseEntity<com.snp.batch.global.dto.TimelineResponse> getTimeline(
|
||||
@RequestParam String view,
|
||||
@RequestParam String date) {
|
||||
log.info("Received request to get timeline: view={}, date={}", view, date);
|
||||
log.debug("Received request to get timeline: view={}, date={}", view, date);
|
||||
try {
|
||||
com.snp.batch.global.dto.TimelineResponse timeline = batchService.getTimeline(view, date);
|
||||
return ResponseEntity.ok(timeline);
|
||||
@ -303,7 +312,7 @@ public class BatchController {
|
||||
|
||||
@GetMapping("/dashboard")
|
||||
public ResponseEntity<com.snp.batch.global.dto.DashboardResponse> getDashboard() {
|
||||
log.info("Received request to get dashboard data");
|
||||
log.debug("Received request to get dashboard data");
|
||||
try {
|
||||
com.snp.batch.global.dto.DashboardResponse dashboard = batchService.getDashboardData();
|
||||
return ResponseEntity.ok(dashboard);
|
||||
@ -327,4 +336,121 @@ public class BatchController {
|
||||
return ResponseEntity.internalServerError().build();
|
||||
}
|
||||
}
|
||||
|
||||
// ── F1: 강제 종료(Abandon) API ─────────────────────────────
|
||||
|
||||
@Operation(summary = "오래된 실행 중 목록 조회", description = "지정된 시간(분) 이상 STARTED/STARTING 상태인 실행 목록을 조회합니다")
|
||||
@GetMapping("/executions/stale")
|
||||
public ResponseEntity<List<JobExecutionDto>> getStaleExecutions(
|
||||
@Parameter(description = "임계 시간(분)", example = "60")
|
||||
@RequestParam(defaultValue = "60") int thresholdMinutes) {
|
||||
log.info("Received request to get stale executions: thresholdMinutes={}", thresholdMinutes);
|
||||
List<JobExecutionDto> executions = batchService.getStaleExecutions(thresholdMinutes);
|
||||
return ResponseEntity.ok(executions);
|
||||
}
|
||||
|
||||
@Operation(summary = "실행 강제 종료", description = "특정 실행을 ABANDONED 상태로 강제 변경합니다")
|
||||
@PostMapping("/executions/{executionId}/abandon")
|
||||
public ResponseEntity<Map<String, Object>> abandonExecution(@PathVariable Long executionId) {
|
||||
log.info("Received request to abandon execution: {}", executionId);
|
||||
try {
|
||||
batchService.abandonExecution(executionId);
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"success", true,
|
||||
"message", "Execution abandoned successfully"
|
||||
));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(Map.of(
|
||||
"success", false,
|
||||
"message", e.getMessage()
|
||||
));
|
||||
} catch (Exception e) {
|
||||
log.error("Error abandoning execution: {}", executionId, e);
|
||||
return ResponseEntity.internalServerError().body(Map.of(
|
||||
"success", false,
|
||||
"message", "Failed to abandon execution: " + e.getMessage()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@Operation(summary = "오래된 실행 전체 강제 종료", description = "지정된 시간(분) 이상 실행 중인 모든 Job을 ABANDONED로 변경합니다")
|
||||
@PostMapping("/executions/stale/abandon-all")
|
||||
public ResponseEntity<Map<String, Object>> abandonAllStaleExecutions(
|
||||
@Parameter(description = "임계 시간(분)", example = "60")
|
||||
@RequestParam(defaultValue = "60") int thresholdMinutes) {
|
||||
log.info("Received request to abandon all stale executions: thresholdMinutes={}", thresholdMinutes);
|
||||
try {
|
||||
int count = batchService.abandonAllStaleExecutions(thresholdMinutes);
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"success", true,
|
||||
"message", count + "건의 실행이 강제 종료되었습니다",
|
||||
"abandonedCount", count
|
||||
));
|
||||
} catch (Exception e) {
|
||||
log.error("Error abandoning all stale executions", e);
|
||||
return ResponseEntity.internalServerError().body(Map.of(
|
||||
"success", false,
|
||||
"message", "Failed to abandon stale executions: " + e.getMessage()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// ── F4: 실행 이력 검색 API ─────────────────────────────────
|
||||
|
||||
@Operation(summary = "실행 이력 검색", description = "조건별 실행 이력 검색 (페이지네이션 지원)")
|
||||
@GetMapping("/executions/search")
|
||||
public ResponseEntity<ExecutionSearchResponse> searchExecutions(
|
||||
@Parameter(description = "Job 이름 (콤마 구분, 복수 가능)") @RequestParam(required = false) String jobNames,
|
||||
@Parameter(description = "상태 (필터)", example = "COMPLETED") @RequestParam(required = false) String status,
|
||||
@Parameter(description = "시작일 (yyyy-MM-dd'T'HH:mm:ss)") @RequestParam(required = false) String startDate,
|
||||
@Parameter(description = "종료일 (yyyy-MM-dd'T'HH:mm:ss)") @RequestParam(required = false) String endDate,
|
||||
@Parameter(description = "페이지 번호 (0부터)") @RequestParam(defaultValue = "0") int page,
|
||||
@Parameter(description = "페이지 크기") @RequestParam(defaultValue = "50") int size) {
|
||||
log.debug("Search executions: jobNames={}, status={}, startDate={}, endDate={}, page={}, size={}",
|
||||
jobNames, status, startDate, endDate, page, size);
|
||||
|
||||
List<String> jobNameList = (jobNames != null && !jobNames.isBlank())
|
||||
? java.util.Arrays.stream(jobNames.split(","))
|
||||
.map(String::trim).filter(s -> !s.isEmpty()).toList()
|
||||
: null;
|
||||
|
||||
LocalDateTime start = startDate != null ? LocalDateTime.parse(startDate) : null;
|
||||
LocalDateTime end = endDate != null ? LocalDateTime.parse(endDate) : null;
|
||||
|
||||
ExecutionSearchResponse response = batchService.searchExecutions(jobNameList, status, start, end, page, size);
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
// ── F7: Job 상세 목록 API ──────────────────────────────────
|
||||
|
||||
@Operation(summary = "Job 상세 목록 조회", description = "모든 Job의 최근 실행 상태 및 스케줄 정보를 조회합니다")
|
||||
@GetMapping("/jobs/detail")
|
||||
public ResponseEntity<List<JobDetailDto>> getJobsDetail() {
|
||||
log.debug("Received request to get jobs with detail");
|
||||
List<JobDetailDto> jobs = batchService.getJobsWithDetail();
|
||||
return ResponseEntity.ok(jobs);
|
||||
}
|
||||
|
||||
// ── F8: 실행 통계 API ──────────────────────────────────────
|
||||
|
||||
@Operation(summary = "전체 실행 통계", description = "전체 배치 작업의 일별 실행 통계를 조회합니다")
|
||||
@GetMapping("/statistics")
|
||||
public ResponseEntity<ExecutionStatisticsDto> getStatistics(
|
||||
@Parameter(description = "조회 기간(일)", example = "30")
|
||||
@RequestParam(defaultValue = "30") int days) {
|
||||
log.debug("Received request to get statistics: days={}", days);
|
||||
ExecutionStatisticsDto stats = batchService.getStatistics(days);
|
||||
return ResponseEntity.ok(stats);
|
||||
}
|
||||
|
||||
@Operation(summary = "Job별 실행 통계", description = "특정 배치 작업의 일별 실행 통계를 조회합니다")
|
||||
@GetMapping("/statistics/{jobName}")
|
||||
public ResponseEntity<ExecutionStatisticsDto> getJobStatistics(
|
||||
@Parameter(description = "Job 이름", required = true) @PathVariable String jobName,
|
||||
@Parameter(description = "조회 기간(일)", example = "30")
|
||||
@RequestParam(defaultValue = "30") int days) {
|
||||
log.debug("Received request to get statistics for job: {}, days={}", jobName, days);
|
||||
ExecutionStatisticsDto stats = batchService.getJobStatistics(jobName, days);
|
||||
return ResponseEntity.ok(stats);
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,41 +3,18 @@ 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("/")
|
||||
public String index() {
|
||||
return "index";
|
||||
}
|
||||
|
||||
@GetMapping("/jobs")
|
||||
public String jobs() {
|
||||
return "jobs";
|
||||
}
|
||||
|
||||
@GetMapping("/executions")
|
||||
public String executions() {
|
||||
return "executions";
|
||||
}
|
||||
|
||||
@GetMapping("/schedules")
|
||||
public String schedules() {
|
||||
return "schedules";
|
||||
}
|
||||
|
||||
@GetMapping("/execution-detail")
|
||||
public String executionDetail() {
|
||||
return "execution-detail";
|
||||
}
|
||||
|
||||
@GetMapping("/executions/{id}")
|
||||
public String executionDetailById() {
|
||||
return "execution-detail";
|
||||
}
|
||||
|
||||
@GetMapping("/schedule-timeline")
|
||||
public String scheduleTimeline() {
|
||||
return "schedule-timeline";
|
||||
@GetMapping({"/", "/jobs", "/executions", "/executions/{id:\\d+}",
|
||||
"/execution-detail", "/schedules", "/schedule-timeline"})
|
||||
public String forward() {
|
||||
return "forward:/index.html";
|
||||
}
|
||||
}
|
||||
|
||||
@ -16,6 +16,9 @@ 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
|
||||
@ -50,4 +53,26 @@ public class DashboardResponse {
|
||||
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;
|
||||
}
|
||||
}
|
||||
30
src/main/java/com/snp/batch/global/dto/JobDetailDto.java
Normal file
30
src/main/java/com/snp/batch/global/dto/JobDetailDto.java
Normal file
@ -0,0 +1,30 @@
|
||||
package com.snp.batch.global.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class JobDetailDto {
|
||||
|
||||
private String jobName;
|
||||
private LastExecution lastExecution;
|
||||
private String scheduleCron;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class LastExecution {
|
||||
private Long executionId;
|
||||
private String status;
|
||||
private LocalDateTime startTime;
|
||||
private LocalDateTime endTime;
|
||||
}
|
||||
}
|
||||
@ -3,10 +3,13 @@ package com.snp.batch.global.repository;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 타임라인 조회를 위한 경량 Repository
|
||||
@ -33,6 +36,10 @@ public class TimelineRepository {
|
||||
return tablePrefix + "JOB_INSTANCE";
|
||||
}
|
||||
|
||||
private String getStepExecutionTable() {
|
||||
return tablePrefix + "STEP_EXECUTION";
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 Job의 특정 범위 내 실행 이력 조회 (경량)
|
||||
* Step Context를 조회하지 않아 성능이 매우 빠름
|
||||
@ -112,7 +119,9 @@ public class TimelineRepository {
|
||||
je.JOB_EXECUTION_ID as executionId,
|
||||
je.STATUS as status,
|
||||
je.START_TIME as startTime,
|
||||
je.END_TIME as endTime
|
||||
je.END_TIME as endTime,
|
||||
je.EXIT_CODE as exitCode,
|
||||
je.EXIT_MESSAGE as exitMessage
|
||||
FROM %s je
|
||||
INNER JOIN %s ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID
|
||||
ORDER BY je.START_TIME DESC
|
||||
@ -121,4 +130,263 @@ public class TimelineRepository {
|
||||
|
||||
return jdbcTemplate.queryForList(sql, limit);
|
||||
}
|
||||
|
||||
// ── F1: 강제 종료(Abandon) 관련 ──────────────────────────────
|
||||
|
||||
/**
|
||||
* 오래된 실행 중 Job 조회 (threshold 분 이상 STARTED/STARTING)
|
||||
*/
|
||||
public List<Map<String, Object>> findStaleExecutions(int thresholdMinutes) {
|
||||
String sql = String.format("""
|
||||
SELECT
|
||||
ji.JOB_NAME as jobName,
|
||||
je.JOB_EXECUTION_ID as executionId,
|
||||
je.STATUS as status,
|
||||
je.START_TIME as startTime
|
||||
FROM %s je
|
||||
INNER JOIN %s ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID
|
||||
WHERE je.STATUS IN ('STARTED', 'STARTING')
|
||||
AND je.START_TIME < NOW() - INTERVAL '%d minutes'
|
||||
ORDER BY je.START_TIME ASC
|
||||
""", getJobExecutionTable(), getJobInstanceTable(), thresholdMinutes);
|
||||
|
||||
return jdbcTemplate.queryForList(sql);
|
||||
}
|
||||
|
||||
/**
|
||||
* Job Execution 상태를 ABANDONED로 변경
|
||||
*/
|
||||
@Transactional
|
||||
public int abandonJobExecution(long executionId) {
|
||||
String sql = String.format("""
|
||||
UPDATE %s
|
||||
SET STATUS = 'ABANDONED',
|
||||
EXIT_CODE = 'ABANDONED',
|
||||
END_TIME = NOW(),
|
||||
EXIT_MESSAGE = 'Force abandoned by admin'
|
||||
WHERE JOB_EXECUTION_ID = ?
|
||||
AND STATUS IN ('STARTED', 'STARTING')
|
||||
""", getJobExecutionTable());
|
||||
|
||||
return jdbcTemplate.update(sql, executionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 해당 Job Execution의 Step Execution들도 ABANDONED로 변경
|
||||
*/
|
||||
@Transactional
|
||||
public int abandonStepExecutions(long jobExecutionId) {
|
||||
String sql = String.format("""
|
||||
UPDATE %s
|
||||
SET STATUS = 'ABANDONED',
|
||||
EXIT_CODE = 'ABANDONED',
|
||||
END_TIME = NOW(),
|
||||
EXIT_MESSAGE = 'Force abandoned by admin'
|
||||
WHERE JOB_EXECUTION_ID = ?
|
||||
AND STATUS IN ('STARTED', 'STARTING')
|
||||
""", getStepExecutionTable());
|
||||
|
||||
return jdbcTemplate.update(sql, jobExecutionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 오래된 실행 중 건수 조회
|
||||
*/
|
||||
public int countStaleExecutions(int thresholdMinutes) {
|
||||
String sql = String.format("""
|
||||
SELECT COUNT(*)
|
||||
FROM %s je
|
||||
WHERE je.STATUS IN ('STARTED', 'STARTING')
|
||||
AND je.START_TIME < NOW() - INTERVAL '%d minutes'
|
||||
""", getJobExecutionTable(), thresholdMinutes);
|
||||
|
||||
Integer count = jdbcTemplate.queryForObject(sql, Integer.class);
|
||||
return count != null ? count : 0;
|
||||
}
|
||||
|
||||
// ── F4: 실행 이력 검색 (페이지네이션) ─────────────────────────
|
||||
|
||||
/**
|
||||
* 실행 이력 검색 (동적 조건 + 페이지네이션)
|
||||
*/
|
||||
public List<Map<String, Object>> searchExecutions(
|
||||
List<String> jobNames, String status,
|
||||
LocalDateTime startDate, LocalDateTime endDate,
|
||||
int offset, int limit) {
|
||||
|
||||
StringBuilder sql = new StringBuilder(String.format("""
|
||||
SELECT
|
||||
ji.JOB_NAME as jobName,
|
||||
je.JOB_EXECUTION_ID as executionId,
|
||||
je.STATUS as status,
|
||||
je.START_TIME as startTime,
|
||||
je.END_TIME as endTime,
|
||||
je.EXIT_CODE as exitCode,
|
||||
je.EXIT_MESSAGE as exitMessage
|
||||
FROM %s je
|
||||
INNER JOIN %s ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID
|
||||
WHERE 1=1
|
||||
""", getJobExecutionTable(), getJobInstanceTable()));
|
||||
|
||||
List<Object> params = new ArrayList<>();
|
||||
appendSearchConditions(sql, params, jobNames, status, startDate, endDate);
|
||||
|
||||
sql.append(" ORDER BY je.START_TIME DESC LIMIT ? OFFSET ?");
|
||||
params.add(limit);
|
||||
params.add(offset);
|
||||
|
||||
return jdbcTemplate.queryForList(sql.toString(), params.toArray());
|
||||
}
|
||||
|
||||
/**
|
||||
* 실행 이력 검색 건수
|
||||
*/
|
||||
public int countExecutions(
|
||||
List<String> jobNames, String status,
|
||||
LocalDateTime startDate, LocalDateTime endDate) {
|
||||
|
||||
StringBuilder sql = new StringBuilder(String.format("""
|
||||
SELECT COUNT(*)
|
||||
FROM %s je
|
||||
INNER JOIN %s ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID
|
||||
WHERE 1=1
|
||||
""", getJobExecutionTable(), getJobInstanceTable()));
|
||||
|
||||
List<Object> params = new ArrayList<>();
|
||||
appendSearchConditions(sql, params, jobNames, status, startDate, endDate);
|
||||
|
||||
Integer count = jdbcTemplate.queryForObject(sql.toString(), Integer.class, params.toArray());
|
||||
return count != null ? count : 0;
|
||||
}
|
||||
|
||||
private void appendSearchConditions(
|
||||
StringBuilder sql, List<Object> params,
|
||||
List<String> jobNames, String status,
|
||||
LocalDateTime startDate, LocalDateTime endDate) {
|
||||
|
||||
if (jobNames != null && !jobNames.isEmpty()) {
|
||||
String placeholders = jobNames.stream().map(n -> "?").collect(Collectors.joining(", "));
|
||||
sql.append(" AND ji.JOB_NAME IN (").append(placeholders).append(")");
|
||||
params.addAll(jobNames);
|
||||
}
|
||||
if (status != null && !status.isBlank()) {
|
||||
sql.append(" AND je.STATUS = ?");
|
||||
params.add(status);
|
||||
}
|
||||
if (startDate != null) {
|
||||
sql.append(" AND je.START_TIME >= ?");
|
||||
params.add(startDate);
|
||||
}
|
||||
if (endDate != null) {
|
||||
sql.append(" AND je.START_TIME < ?");
|
||||
params.add(endDate);
|
||||
}
|
||||
}
|
||||
|
||||
// ── F6: 대시보드 실패 통계 ──────────────────────────────────
|
||||
|
||||
/**
|
||||
* 최근 실패 이력 조회
|
||||
*/
|
||||
public List<Map<String, Object>> findRecentFailures(int hours) {
|
||||
String sql = String.format("""
|
||||
SELECT
|
||||
ji.JOB_NAME as jobName,
|
||||
je.JOB_EXECUTION_ID as executionId,
|
||||
je.STATUS as status,
|
||||
je.START_TIME as startTime,
|
||||
je.END_TIME as endTime,
|
||||
je.EXIT_MESSAGE as exitMessage
|
||||
FROM %s je
|
||||
INNER JOIN %s ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID
|
||||
WHERE je.STATUS = 'FAILED'
|
||||
AND je.START_TIME >= NOW() - INTERVAL '%d hours'
|
||||
ORDER BY je.START_TIME DESC
|
||||
LIMIT 10
|
||||
""", getJobExecutionTable(), getJobInstanceTable(), hours);
|
||||
|
||||
return jdbcTemplate.queryForList(sql);
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 시점 이후 실패 건수
|
||||
*/
|
||||
public int countFailuresSince(LocalDateTime since) {
|
||||
String sql = String.format("""
|
||||
SELECT COUNT(*)
|
||||
FROM %s je
|
||||
WHERE je.STATUS = 'FAILED'
|
||||
AND je.START_TIME >= ?
|
||||
""", getJobExecutionTable());
|
||||
|
||||
Integer count = jdbcTemplate.queryForObject(sql, Integer.class, since);
|
||||
return count != null ? count : 0;
|
||||
}
|
||||
|
||||
// ── F7: Job별 최근 실행 정보 ────────────────────────────────
|
||||
|
||||
/**
|
||||
* Job별 가장 최근 실행 정보 조회 (DISTINCT ON 활용)
|
||||
*/
|
||||
public List<Map<String, Object>> findLastExecutionPerJob() {
|
||||
String sql = String.format("""
|
||||
SELECT DISTINCT ON (ji.JOB_NAME)
|
||||
ji.JOB_NAME as jobName,
|
||||
je.JOB_EXECUTION_ID as executionId,
|
||||
je.STATUS as status,
|
||||
je.START_TIME as startTime,
|
||||
je.END_TIME as endTime
|
||||
FROM %s je
|
||||
INNER JOIN %s ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID
|
||||
ORDER BY ji.JOB_NAME, je.START_TIME DESC
|
||||
""", getJobExecutionTable(), getJobInstanceTable());
|
||||
|
||||
return jdbcTemplate.queryForList(sql);
|
||||
}
|
||||
|
||||
// ── F8: 실행 통계 ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 일별 실행 통계 (전체)
|
||||
*/
|
||||
public List<Map<String, Object>> findDailyStatistics(int days) {
|
||||
String sql = String.format("""
|
||||
SELECT
|
||||
CAST(je.START_TIME AS DATE) as execDate,
|
||||
SUM(CASE WHEN je.STATUS = 'COMPLETED' THEN 1 ELSE 0 END) as successCount,
|
||||
SUM(CASE WHEN je.STATUS = 'FAILED' THEN 1 ELSE 0 END) as failedCount,
|
||||
SUM(CASE WHEN je.STATUS NOT IN ('COMPLETED', 'FAILED') THEN 1 ELSE 0 END) as otherCount,
|
||||
AVG(EXTRACT(EPOCH FROM (je.END_TIME - je.START_TIME)) * 1000) as avgDurationMs
|
||||
FROM %s je
|
||||
WHERE je.START_TIME >= NOW() - INTERVAL '%d days'
|
||||
AND je.START_TIME IS NOT NULL
|
||||
GROUP BY CAST(je.START_TIME AS DATE)
|
||||
ORDER BY execDate
|
||||
""", getJobExecutionTable(), days);
|
||||
|
||||
return jdbcTemplate.queryForList(sql);
|
||||
}
|
||||
|
||||
/**
|
||||
* 일별 실행 통계 (특정 Job)
|
||||
*/
|
||||
public List<Map<String, Object>> findDailyStatisticsForJob(String jobName, int days) {
|
||||
String sql = String.format("""
|
||||
SELECT
|
||||
CAST(je.START_TIME AS DATE) as execDate,
|
||||
SUM(CASE WHEN je.STATUS = 'COMPLETED' THEN 1 ELSE 0 END) as successCount,
|
||||
SUM(CASE WHEN je.STATUS = 'FAILED' THEN 1 ELSE 0 END) as failedCount,
|
||||
SUM(CASE WHEN je.STATUS NOT IN ('COMPLETED', 'FAILED') THEN 1 ELSE 0 END) as otherCount,
|
||||
AVG(EXTRACT(EPOCH FROM (je.END_TIME - je.START_TIME)) * 1000) as avgDurationMs
|
||||
FROM %s je
|
||||
INNER JOIN %s ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID
|
||||
WHERE ji.JOB_NAME = ?
|
||||
AND je.START_TIME >= NOW() - INTERVAL '%d days'
|
||||
AND je.START_TIME IS NOT NULL
|
||||
GROUP BY CAST(je.START_TIME AS DATE)
|
||||
ORDER BY execDate
|
||||
""", getJobExecutionTable(), getJobInstanceTable(), days);
|
||||
|
||||
return jdbcTemplate.queryForList(sql, jobName);
|
||||
}
|
||||
}
|
||||
|
||||
@ -84,6 +84,14 @@ public class AisTargetEntity extends BaseEntity {
|
||||
private OffsetDateTime receivedDate;
|
||||
private OffsetDateTime collectedAt; // 배치 수집 시점
|
||||
|
||||
// ========== 선종 분류 정보 ==========
|
||||
/**
|
||||
* MDA 범례코드 (signalKindCode)
|
||||
* - vesselType + extraInfo 기반으로 치환
|
||||
* - 예: "000020"(어선), "000023"(카고), "000027"(일반/기타)
|
||||
*/
|
||||
private String signalKindCode;
|
||||
|
||||
// ========== ClassType 분류 정보 ==========
|
||||
/**
|
||||
* 선박 클래스 타입
|
||||
|
||||
@ -26,6 +26,14 @@ public interface AisTargetRepository {
|
||||
*/
|
||||
List<AisTargetEntity> findLatestByMmsiIn(List<Long> mmsiList);
|
||||
|
||||
/**
|
||||
* 여러 MMSI의 최신 위치 조회 (시간 범위 필터)
|
||||
*
|
||||
* @param mmsiList 대상 MMSI 목록
|
||||
* @param since 이 시점 이후 데이터만 조회
|
||||
*/
|
||||
List<AisTargetEntity> findLatestByMmsiInSince(List<Long> mmsiList, OffsetDateTime since);
|
||||
|
||||
/**
|
||||
* 시간 범위 내 특정 MMSI의 항적 조회
|
||||
*/
|
||||
|
||||
@ -130,7 +130,7 @@ public class AisTargetRepositoryImpl implements AisTargetRepository {
|
||||
private final RowMapper<AisTargetEntity> rowMapper = (rs, rowNum) -> AisTargetEntity.builder()
|
||||
.mmsi(rs.getLong("mmsi"))
|
||||
.messageTimestamp(toOffsetDateTime(rs.getTimestamp("message_timestamp")))
|
||||
.imo(rs.getObject("imo", Long.class))
|
||||
.imo(toLong(rs, "imo"))
|
||||
.name(rs.getString("name"))
|
||||
.callsign(rs.getString("callsign"))
|
||||
.vesselType(rs.getString("vessel_type"))
|
||||
@ -140,45 +140,45 @@ public class AisTargetRepositoryImpl implements AisTargetRepository {
|
||||
.heading(rs.getObject("heading", Double.class))
|
||||
.sog(rs.getObject("sog", Double.class))
|
||||
.cog(rs.getObject("cog", Double.class))
|
||||
.rot(rs.getObject("rot", Integer.class))
|
||||
.length(rs.getObject("length", Integer.class))
|
||||
.width(rs.getObject("width", Integer.class))
|
||||
.rot(toInt(rs, "rot"))
|
||||
.length(toInt(rs, "length"))
|
||||
.width(toInt(rs, "width"))
|
||||
.draught(rs.getObject("draught", Double.class))
|
||||
.lengthBow(rs.getObject("length_bow", Integer.class))
|
||||
.lengthStern(rs.getObject("length_stern", Integer.class))
|
||||
.widthPort(rs.getObject("width_port", Integer.class))
|
||||
.widthStarboard(rs.getObject("width_starboard", Integer.class))
|
||||
.lengthBow(toInt(rs, "length_bow"))
|
||||
.lengthStern(toInt(rs, "length_stern"))
|
||||
.widthPort(toInt(rs, "width_port"))
|
||||
.widthStarboard(toInt(rs, "width_starboard"))
|
||||
.destination(rs.getString("destination"))
|
||||
.eta(toOffsetDateTime(rs.getTimestamp("eta")))
|
||||
.status(rs.getString("status"))
|
||||
.ageMinutes(rs.getObject("age_minutes", Double.class))
|
||||
.positionAccuracy(rs.getObject("position_accuracy", Integer.class))
|
||||
.timestampUtc(rs.getObject("timestamp_utc", Integer.class))
|
||||
.repeatIndicator(rs.getObject("repeat_indicator", Integer.class))
|
||||
.raimFlag(rs.getObject("raim_flag", Integer.class))
|
||||
.radioStatus(rs.getObject("radio_status", Integer.class))
|
||||
.regional(rs.getObject("regional", Integer.class))
|
||||
.regional2(rs.getObject("regional2", Integer.class))
|
||||
.spare(rs.getObject("spare", Integer.class))
|
||||
.spare2(rs.getObject("spare2", Integer.class))
|
||||
.aisVersion(rs.getObject("ais_version", Integer.class))
|
||||
.positionFixType(rs.getObject("position_fix_type", Integer.class))
|
||||
.dte(rs.getObject("dte", Integer.class))
|
||||
.bandFlag(rs.getObject("band_flag", Integer.class))
|
||||
.positionAccuracy(toInt(rs, "position_accuracy"))
|
||||
.timestampUtc(toInt(rs, "timestamp_utc"))
|
||||
.repeatIndicator(toInt(rs, "repeat_indicator"))
|
||||
.raimFlag(toInt(rs, "raim_flag"))
|
||||
.radioStatus(toInt(rs, "radio_status"))
|
||||
.regional(toInt(rs, "regional"))
|
||||
.regional2(toInt(rs, "regional2"))
|
||||
.spare(toInt(rs, "spare"))
|
||||
.spare2(toInt(rs, "spare2"))
|
||||
.aisVersion(toInt(rs, "ais_version"))
|
||||
.positionFixType(toInt(rs, "position_fix_type"))
|
||||
.dte(toInt(rs, "dte"))
|
||||
.bandFlag(toInt(rs, "band_flag"))
|
||||
.receivedDate(toOffsetDateTime(rs.getTimestamp("received_date")))
|
||||
.collectedAt(toOffsetDateTime(rs.getTimestamp("collected_at")))
|
||||
.tonnesCargo(rs.getObject("tonnes_cargo", Integer.class))
|
||||
.inSTS(rs.getObject("in_sts", Integer.class))
|
||||
.tonnesCargo(toInt(rs, "tonnes_cargo"))
|
||||
.inSTS(toInt(rs, "in_sts"))
|
||||
.onBerth(rs.getObject("on_berth", Boolean.class))
|
||||
.dwt(rs.getObject("dwt", Integer.class))
|
||||
.dwt(toInt(rs, "dwt"))
|
||||
.anomalous(rs.getString("anomalous"))
|
||||
.destinationPortID(rs.getObject("destination_port_id", Integer.class))
|
||||
.destinationPortID(toInt(rs, "destination_port_id"))
|
||||
.destinationTidied(rs.getString("destination_tidied"))
|
||||
.destinationUNLOCODE(rs.getString("destination_unlocode"))
|
||||
.imoVerified(rs.getString("imo_verified"))
|
||||
.lastStaticUpdateReceived(toOffsetDateTime(rs.getTimestamp("last_static_update_received")))
|
||||
.lpcCode(rs.getObject("lpc_code", Integer.class))
|
||||
.messageType(rs.getObject("message_type", Integer.class))
|
||||
.lpcCode(toInt(rs, "lpc_code"))
|
||||
.messageType(toInt(rs, "message_type"))
|
||||
.source(rs.getString("source"))
|
||||
.stationId(rs.getString("station_id"))
|
||||
.zoneId(rs.getObject("zone_id", Double.class))
|
||||
@ -223,6 +223,24 @@ public class AisTargetRepositoryImpl implements AisTargetRepository {
|
||||
return jdbcTemplate.query(sql, rowMapper, (Object) mmsiArray);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<AisTargetEntity> findLatestByMmsiInSince(List<Long> mmsiList, OffsetDateTime since) {
|
||||
if (mmsiList == null || mmsiList.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
String sql = """
|
||||
SELECT DISTINCT ON (mmsi) *
|
||||
FROM %s
|
||||
WHERE mmsi = ANY(?)
|
||||
AND message_timestamp >= ?
|
||||
ORDER BY mmsi, message_timestamp DESC
|
||||
""".formatted(tableName);
|
||||
|
||||
Long[] mmsiArray = mmsiList.toArray(new Long[0]);
|
||||
return jdbcTemplate.query(sql, rowMapper, (Object) mmsiArray, toTimestamp(since));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<AisTargetEntity> findByMmsiAndTimeRange(Long mmsi, OffsetDateTime start, OffsetDateTime end) {
|
||||
String sql = """
|
||||
@ -359,6 +377,23 @@ public class AisTargetRepositoryImpl implements AisTargetRepository {
|
||||
|
||||
// ==================== Helper Methods ====================
|
||||
|
||||
/**
|
||||
* int8(bigint) → Integer 안전 변환
|
||||
* PostgreSQL JDBC 드라이버는 int8 → Integer 자동 변환을 지원하지 않아
|
||||
* getObject("col", Integer.class) 사용 시 오류 발생. Number로 읽어서 변환.
|
||||
*/
|
||||
private Integer toInt(ResultSet rs, String column) throws SQLException {
|
||||
Object val = rs.getObject(column);
|
||||
if (val == null) return null;
|
||||
return ((Number) val).intValue();
|
||||
}
|
||||
|
||||
private Long toLong(ResultSet rs, String column) throws SQLException {
|
||||
Object val = rs.getObject(column);
|
||||
if (val == null) return null;
|
||||
return ((Number) val).longValue();
|
||||
}
|
||||
|
||||
private Timestamp toTimestamp(OffsetDateTime odt) {
|
||||
return odt != null ? Timestamp.from(odt.toInstant()) : null;
|
||||
}
|
||||
|
||||
@ -3,9 +3,12 @@ package com.snp.batch.jobs.aistarget.batch.writer;
|
||||
import com.snp.batch.common.batch.writer.BaseWriter;
|
||||
import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
|
||||
import com.snp.batch.jobs.aistarget.cache.AisTargetCacheManager;
|
||||
import com.snp.batch.jobs.aistarget.chnprmship.ChnPrmShipCacheManager;
|
||||
import com.snp.batch.jobs.aistarget.classifier.AisClassTypeClassifier;
|
||||
import com.snp.batch.jobs.aistarget.classifier.SignalKindCode;
|
||||
import com.snp.batch.jobs.aistarget.kafka.AisTargetKafkaProducer;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
@ -15,12 +18,15 @@ import java.util.List;
|
||||
*
|
||||
* 동작:
|
||||
* 1. ClassType 분류 (Core20 캐시 기반 A/B 분류)
|
||||
* 2. 캐시에 최신 위치 정보 업데이트 (classType, core20Mmsi 포함)
|
||||
* 3. Kafka 토픽으로 AIS Target 정보 전송 (서브청크 분할)
|
||||
* 2. SignalKindCode 치환 (vesselType + extraInfo → MDA 범례코드)
|
||||
* 3. 캐시에 최신 위치 정보 업데이트 (classType, core20Mmsi, signalKindCode 포함)
|
||||
* 4. ChnPrmShip 전용 캐시 업데이트 (대상 MMSI만 필터)
|
||||
* 5. Kafka 토픽으로 AIS Target 정보 전송 (활성화된 경우에만)
|
||||
*
|
||||
* 참고:
|
||||
* - DB 저장은 별도 Job(aisTargetDbSyncJob)에서 15분 주기로 수행
|
||||
* - Kafka 전송 실패는 기본적으로 로그만 남기고 다음 처리 계속
|
||||
* - Kafka가 비활성화(enabled=false)이면 kafkaProducer가 null이므로 전송 단계를 스킵
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@ -28,16 +34,20 @@ public class AisTargetDataWriter extends BaseWriter<AisTargetEntity> {
|
||||
|
||||
private final AisTargetCacheManager cacheManager;
|
||||
private final AisClassTypeClassifier classTypeClassifier;
|
||||
@Nullable
|
||||
private final AisTargetKafkaProducer kafkaProducer;
|
||||
private final ChnPrmShipCacheManager chnPrmShipCacheManager;
|
||||
|
||||
public AisTargetDataWriter(
|
||||
AisTargetCacheManager cacheManager,
|
||||
AisClassTypeClassifier classTypeClassifier,
|
||||
AisTargetKafkaProducer kafkaProducer) {
|
||||
@Nullable AisTargetKafkaProducer kafkaProducer,
|
||||
ChnPrmShipCacheManager chnPrmShipCacheManager) {
|
||||
super("AisTarget");
|
||||
this.cacheManager = cacheManager;
|
||||
this.classTypeClassifier = classTypeClassifier;
|
||||
this.kafkaProducer = kafkaProducer;
|
||||
this.chnPrmShipCacheManager = chnPrmShipCacheManager;
|
||||
}
|
||||
|
||||
@Override
|
||||
@ -48,15 +58,24 @@ public class AisTargetDataWriter extends BaseWriter<AisTargetEntity> {
|
||||
// - Core20 캐시의 IMO와 매칭하여 classType(A/B), core20Mmsi 설정
|
||||
classTypeClassifier.classifyAll(items);
|
||||
|
||||
// 2. 캐시 업데이트 (classType, core20Mmsi 포함)
|
||||
// 2. SignalKindCode 치환 (vesselType + extraInfo → MDA 범례코드)
|
||||
items.forEach(item -> {
|
||||
SignalKindCode kindCode = SignalKindCode.resolve(item.getVesselType(), item.getExtraInfo());
|
||||
item.setSignalKindCode(kindCode.getCode());
|
||||
});
|
||||
|
||||
// 3. 캐시 업데이트 (classType, core20Mmsi, signalKindCode 포함)
|
||||
cacheManager.putAll(items);
|
||||
|
||||
log.debug("AIS Target 캐시 업데이트 완료: {} 건 (캐시 크기: {})",
|
||||
items.size(), cacheManager.size());
|
||||
|
||||
// 3. Kafka 전송 (설정 enabled=true 인 경우)
|
||||
if (!kafkaProducer.isEnabled()) {
|
||||
log.debug("AIS Kafka 전송 비활성화 - topic 전송 스킵");
|
||||
// 4. ChnPrmShip 전용 캐시 업데이트 (대상 MMSI만 필터)
|
||||
chnPrmShipCacheManager.putIfTarget(items);
|
||||
|
||||
// 5. Kafka 전송 (kafkaProducer 빈이 존재하는 경우에만)
|
||||
if (kafkaProducer == null) {
|
||||
log.debug("AIS Kafka Producer 미등록 - topic 전송 스킵");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,131 @@
|
||||
package com.snp.batch.jobs.aistarget.chnprmship;
|
||||
|
||||
import com.github.benmanes.caffeine.cache.Cache;
|
||||
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||
import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 중국 허가선박 전용 캐시
|
||||
*
|
||||
* - 대상 MMSI(~1,400척)만 별도 관리
|
||||
* - TTL: expireAfterWrite (마지막 put 이후 N일 경과 시 만료)
|
||||
* - 순수 캐시 조회 전용 (DB fallback 없음)
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class ChnPrmShipCacheManager {
|
||||
|
||||
private final ChnPrmShipProperties properties;
|
||||
private Cache<Long, AisTargetEntity> cache;
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
this.cache = Caffeine.newBuilder()
|
||||
.maximumSize(properties.getMaxSize())
|
||||
.expireAfterWrite(properties.getTtlDays(), TimeUnit.DAYS)
|
||||
.recordStats()
|
||||
.build();
|
||||
|
||||
log.info("ChnPrmShip 캐시 초기화 - TTL: {}일, 최대 크기: {}",
|
||||
properties.getTtlDays(), properties.getMaxSize());
|
||||
}
|
||||
|
||||
/**
|
||||
* 대상 MMSI에 해당하는 항목만 필터링하여 캐시에 저장
|
||||
*
|
||||
* @param items 전체 AIS Target 데이터 (배치 수집 결과)
|
||||
* @return 저장된 건수
|
||||
*/
|
||||
public int putIfTarget(List<AisTargetEntity> items) {
|
||||
if (items == null || items.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int updated = 0;
|
||||
for (AisTargetEntity item : items) {
|
||||
if (!properties.isTarget(item.getMmsi())) {
|
||||
continue;
|
||||
}
|
||||
|
||||
AisTargetEntity existing = cache.getIfPresent(item.getMmsi());
|
||||
if (existing == null || isNewerOrEqual(item, existing)) {
|
||||
cache.put(item.getMmsi(), item);
|
||||
updated++;
|
||||
}
|
||||
}
|
||||
|
||||
if (updated > 0) {
|
||||
log.debug("ChnPrmShip 캐시 업데이트 - 입력: {}, 대상 저장: {}, 현재 크기: {}",
|
||||
items.size(), updated, cache.estimatedSize());
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* 시간 범위 내 캐시 데이터 조회
|
||||
*
|
||||
* @param minutes 조회 범위 (분)
|
||||
* @return 시간 범위 내 데이터 목록
|
||||
*/
|
||||
public List<AisTargetEntity> getByTimeRange(int minutes) {
|
||||
OffsetDateTime threshold = OffsetDateTime.now(ZoneOffset.UTC).minusMinutes(minutes);
|
||||
|
||||
return cache.asMap().values().stream()
|
||||
.filter(entity -> entity.getMessageTimestamp() != null)
|
||||
.filter(entity -> entity.getMessageTimestamp().isAfter(threshold))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* 워밍업용 직접 저장 (시간 비교 없이 저장)
|
||||
*/
|
||||
public void putAll(List<AisTargetEntity> entities) {
|
||||
if (entities == null || entities.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
for (AisTargetEntity entity : entities) {
|
||||
if (entity != null && entity.getMmsi() != null) {
|
||||
cache.put(entity.getMmsi(), entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public long size() {
|
||||
return cache.estimatedSize();
|
||||
}
|
||||
|
||||
public Map<String, Object> getStats() {
|
||||
var stats = cache.stats();
|
||||
return Map.of(
|
||||
"estimatedSize", cache.estimatedSize(),
|
||||
"maxSize", properties.getMaxSize(),
|
||||
"ttlDays", properties.getTtlDays(),
|
||||
"targetMmsiCount", properties.getMmsiSet().size(),
|
||||
"hitCount", stats.hitCount(),
|
||||
"missCount", stats.missCount(),
|
||||
"hitRate", String.format("%.2f%%", stats.hitRate() * 100)
|
||||
);
|
||||
}
|
||||
|
||||
private boolean isNewerOrEqual(AisTargetEntity candidate, AisTargetEntity existing) {
|
||||
if (candidate.getMessageTimestamp() == null) {
|
||||
return false;
|
||||
}
|
||||
if (existing.getMessageTimestamp() == null) {
|
||||
return true;
|
||||
}
|
||||
return !candidate.getMessageTimestamp().isBefore(existing.getMessageTimestamp());
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,79 @@
|
||||
package com.snp.batch.jobs.aistarget.chnprmship;
|
||||
|
||||
import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
|
||||
import com.snp.batch.jobs.aistarget.batch.repository.AisTargetRepository;
|
||||
import com.snp.batch.jobs.aistarget.classifier.SignalKindCode;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.ApplicationArguments;
|
||||
import org.springframework.boot.ApplicationRunner;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 기동 시 ChnPrmShip 캐시 워밍업
|
||||
*
|
||||
* DB(ais_target)에서 대상 MMSI의 최근 데이터를 조회하여 캐시를 채운다.
|
||||
* 이후 매 분 배치 수집에서 실시간 데이터가 캐시를 갱신한다.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class ChnPrmShipCacheWarmer implements ApplicationRunner {
|
||||
|
||||
private static final int DB_QUERY_CHUNK_SIZE = 500;
|
||||
|
||||
private final ChnPrmShipProperties properties;
|
||||
private final ChnPrmShipCacheManager cacheManager;
|
||||
private final AisTargetRepository aisTargetRepository;
|
||||
|
||||
@Override
|
||||
public void run(ApplicationArguments args) {
|
||||
if (!properties.isWarmupEnabled()) {
|
||||
log.info("ChnPrmShip 캐시 워밍업 비활성화");
|
||||
return;
|
||||
}
|
||||
|
||||
if (properties.getMmsiSet().isEmpty()) {
|
||||
log.warn("ChnPrmShip 대상 MMSI가 없어 워밍업을 건너뜁니다");
|
||||
return;
|
||||
}
|
||||
|
||||
OffsetDateTime since = OffsetDateTime.now(ZoneOffset.UTC)
|
||||
.minusDays(properties.getWarmupDays());
|
||||
|
||||
log.info("ChnPrmShip 캐시 워밍업 시작 - 대상: {}건, 조회 범위: 최근 {}일 (since: {})",
|
||||
properties.getMmsiSet().size(), properties.getWarmupDays(), since);
|
||||
long startTime = System.currentTimeMillis();
|
||||
|
||||
List<Long> mmsiList = new ArrayList<>(properties.getMmsiSet());
|
||||
int totalLoaded = 0;
|
||||
|
||||
for (int i = 0; i < mmsiList.size(); i += DB_QUERY_CHUNK_SIZE) {
|
||||
List<Long> chunk = mmsiList.subList(i,
|
||||
Math.min(i + DB_QUERY_CHUNK_SIZE, mmsiList.size()));
|
||||
|
||||
List<AisTargetEntity> fromDb = aisTargetRepository.findLatestByMmsiInSince(chunk, since);
|
||||
|
||||
// signalKindCode 치환 (DB 데이터는 치환이 안 되어 있을 수 있음)
|
||||
fromDb.forEach(entity -> {
|
||||
if (entity.getSignalKindCode() == null) {
|
||||
SignalKindCode kindCode = SignalKindCode.resolve(
|
||||
entity.getVesselType(), entity.getExtraInfo());
|
||||
entity.setSignalKindCode(kindCode.getCode());
|
||||
}
|
||||
});
|
||||
|
||||
cacheManager.putAll(fromDb);
|
||||
totalLoaded += fromDb.size();
|
||||
}
|
||||
|
||||
long elapsed = System.currentTimeMillis() - startTime;
|
||||
log.info("ChnPrmShip 캐시 워밍업 완료 - 대상: {}, 로딩: {}건, 소요: {}ms",
|
||||
properties.getMmsiSet().size(), totalLoaded, elapsed);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,82 @@
|
||||
package com.snp.batch.jobs.aistarget.chnprmship;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.core.io.DefaultResourceLoader;
|
||||
import org.springframework.core.io.Resource;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Collections;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 중국 허가선박(ChnPrmShip) 설정
|
||||
*
|
||||
* 대상 MMSI 목록을 리소스 파일에서 로딩하여 Set으로 보관한다.
|
||||
*/
|
||||
@Slf4j
|
||||
@Getter
|
||||
@Setter
|
||||
@ConfigurationProperties(prefix = "app.batch.chnprmship")
|
||||
public class ChnPrmShipProperties {
|
||||
|
||||
/**
|
||||
* MMSI 목록 리소스 경로
|
||||
*/
|
||||
private String mmsiResourcePath = "classpath:chnprmship-mmsi.txt";
|
||||
|
||||
/**
|
||||
* 캐시 TTL (일)
|
||||
* - 마지막 put() 이후 이 기간이 지나면 만료
|
||||
*/
|
||||
private int ttlDays = 2;
|
||||
|
||||
/**
|
||||
* 최대 캐시 크기
|
||||
*/
|
||||
private int maxSize = 2000;
|
||||
|
||||
/**
|
||||
* 기동 시 DB 워밍업 활성화 여부
|
||||
*/
|
||||
private boolean warmupEnabled = true;
|
||||
|
||||
/**
|
||||
* DB 워밍업 조회 범위 (일)
|
||||
*/
|
||||
private int warmupDays = 2;
|
||||
|
||||
/**
|
||||
* 로딩된 대상 MMSI 집합
|
||||
*/
|
||||
private Set<Long> mmsiSet = Collections.emptySet();
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
try {
|
||||
Resource resource = new DefaultResourceLoader().getResource(mmsiResourcePath);
|
||||
try (BufferedReader reader = new BufferedReader(
|
||||
new InputStreamReader(resource.getInputStream(), StandardCharsets.UTF_8))) {
|
||||
mmsiSet = reader.lines()
|
||||
.map(String::trim)
|
||||
.filter(line -> !line.isEmpty() && !line.startsWith("#"))
|
||||
.map(Long::parseLong)
|
||||
.collect(Collectors.toUnmodifiableSet());
|
||||
}
|
||||
log.info("ChnPrmShip MMSI 로딩 완료 - {}건 (경로: {})", mmsiSet.size(), mmsiResourcePath);
|
||||
} catch (Exception e) {
|
||||
log.error("ChnPrmShip MMSI 로딩 실패 - 경로: {}, 오류: {}", mmsiResourcePath, e.getMessage());
|
||||
mmsiSet = Collections.emptySet();
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isTarget(Long mmsi) {
|
||||
return mmsi != null && mmsiSet.contains(mmsi);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,118 @@
|
||||
package com.snp.batch.jobs.aistarget.classifier;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
/**
|
||||
* MDA 선종 범례코드
|
||||
*
|
||||
* GlobalAIS 원본 데이터의 vesselType + extraInfo를 기반으로
|
||||
* MDA 범례코드(signalKindCode)로 치환한다.
|
||||
*
|
||||
* @see <a href="GLOBALAIS - MDA 선종 범례 치환표.pdf">치환 규칙표</a>
|
||||
*/
|
||||
@Getter
|
||||
@RequiredArgsConstructor
|
||||
public enum SignalKindCode {
|
||||
|
||||
FISHING("000020", "어선"),
|
||||
KCGV("000021", "함정"),
|
||||
FERRY("000022", "여객선"),
|
||||
CARGO("000023", "카고"),
|
||||
TANKER("000024", "탱커"),
|
||||
GOV("000025", "관공선"),
|
||||
DEFAULT("000027", "일반/기타선박"),
|
||||
BUOY("000028", "부이/항로표지");
|
||||
|
||||
private final String code;
|
||||
private final String koreanName;
|
||||
|
||||
/**
|
||||
* GlobalAIS vesselType + extraInfo → MDA 범례코드 치환
|
||||
*
|
||||
* 치환 우선순위:
|
||||
* 1. vesselType 단독 매칭 (Cargo, Tanker, Passenger, AtoN 등)
|
||||
* 2. vesselType + extraInfo 조합 매칭 (Vessel + Fishing 등)
|
||||
* 3. fallback → DEFAULT (000027)
|
||||
*/
|
||||
public static SignalKindCode resolve(String vesselType, String extraInfo) {
|
||||
String vt = normalizeOrEmpty(vesselType);
|
||||
String ei = normalizeOrEmpty(extraInfo);
|
||||
|
||||
// 1. vesselType 단독 매칭 (extraInfo 무관)
|
||||
switch (vt) {
|
||||
case "cargo":
|
||||
return CARGO;
|
||||
case "tanker":
|
||||
return TANKER;
|
||||
case "passenger":
|
||||
return FERRY;
|
||||
case "aton":
|
||||
return BUOY;
|
||||
case "law enforcement":
|
||||
return GOV;
|
||||
case "search and rescue":
|
||||
return KCGV;
|
||||
case "local vessel":
|
||||
return FISHING;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
// vesselType 그룹 매칭 (복합 선종명)
|
||||
if (matchesAny(vt, "tug", "pilot boat", "tender", "anti pollution", "medical transport")) {
|
||||
return GOV;
|
||||
}
|
||||
if (matchesAny(vt, "high speed craft", "wing in ground-effect")) {
|
||||
return FERRY;
|
||||
}
|
||||
|
||||
// 2. "Vessel" + extraInfo 조합
|
||||
if ("vessel".equals(vt)) {
|
||||
return resolveVesselExtraInfo(ei);
|
||||
}
|
||||
|
||||
// 3. "N/A" + extraInfo 조합
|
||||
if ("n/a".equals(vt)) {
|
||||
if (ei.startsWith("hazardous cat")) {
|
||||
return CARGO;
|
||||
}
|
||||
return DEFAULT;
|
||||
}
|
||||
|
||||
// 4. fallback
|
||||
return DEFAULT;
|
||||
}
|
||||
|
||||
private static SignalKindCode resolveVesselExtraInfo(String extraInfo) {
|
||||
if ("fishing".equals(extraInfo)) {
|
||||
return FISHING;
|
||||
}
|
||||
if ("military operations".equals(extraInfo)) {
|
||||
return GOV;
|
||||
}
|
||||
if (matchesAny(extraInfo, "towing", "towing (large)", "dredging/underwater ops", "diving operations")) {
|
||||
return GOV;
|
||||
}
|
||||
if (matchesAny(extraInfo, "pleasure craft", "sailing", "n/a")) {
|
||||
return FISHING;
|
||||
}
|
||||
if (extraInfo.startsWith("hazardous cat")) {
|
||||
return CARGO;
|
||||
}
|
||||
return DEFAULT;
|
||||
}
|
||||
|
||||
private static boolean matchesAny(String value, String... candidates) {
|
||||
for (String candidate : candidates) {
|
||||
if (candidate.equals(value)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static String normalizeOrEmpty(String value) {
|
||||
return (value == null || value.isBlank()) ? "" : value.strip().toLowerCase();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
package com.snp.batch.jobs.aistarget.kafka;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.boot.autoconfigure.kafka.KafkaAutoConfiguration;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Import;
|
||||
|
||||
/**
|
||||
* Kafka 조건부 활성화 설정
|
||||
*
|
||||
* SnpBatchApplication에서 KafkaAutoConfiguration을 기본 제외한 뒤,
|
||||
* app.batch.ais-target.kafka.enabled=true인 경우에만 재활성화한다.
|
||||
*
|
||||
* enabled=false(기본값)이면 KafkaTemplate 등 Kafka 관련 빈이 전혀 생성되지 않는다.
|
||||
*/
|
||||
@Configuration
|
||||
@ConditionalOnProperty(
|
||||
name = "app.batch.ais-target.kafka.enabled",
|
||||
havingValue = "true"
|
||||
)
|
||||
@Import(KafkaAutoConfiguration.class)
|
||||
public class AisTargetKafkaConfig {
|
||||
}
|
||||
@ -6,6 +6,7 @@ import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.kafka.core.KafkaTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@ -21,10 +22,13 @@ import java.util.concurrent.atomic.AtomicInteger;
|
||||
* - key: MMSI
|
||||
* - value: AisTargetKafkaMessage(JSON)
|
||||
* - 실패 시 기본적으로 로그만 남기고 계속 진행 (failOnSendError=false)
|
||||
*
|
||||
* app.batch.ais-target.kafka.enabled=true인 경우에만 빈으로 등록된다.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@ConditionalOnProperty(name = "app.batch.ais-target.kafka.enabled", havingValue = "true")
|
||||
public class AisTargetKafkaProducer {
|
||||
|
||||
private final KafkaTemplate<String, String> kafkaTemplate;
|
||||
|
||||
@ -7,13 +7,17 @@ import com.snp.batch.jobs.aistarget.web.dto.AisTargetSearchRequest;
|
||||
import com.snp.batch.jobs.aistarget.web.service.AisTargetService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@ -25,6 +29,7 @@ import java.util.Map;
|
||||
* - 캐시 미스 시 DB 조회 후 캐시 업데이트
|
||||
*/
|
||||
@Slf4j
|
||||
@Validated
|
||||
@RestController
|
||||
@RequestMapping("/api/ais-target")
|
||||
@RequiredArgsConstructor
|
||||
@ -33,11 +38,51 @@ public class AisTargetController {
|
||||
|
||||
private final AisTargetService aisTargetService;
|
||||
|
||||
// ==================== 중국 허가선박 전용 ====================
|
||||
|
||||
@Operation(
|
||||
summary = "중국 허가선박 위치 조회",
|
||||
description = """
|
||||
중국 허가 어선(~1,400척) 전용 캐시에서 위치 정보를 조회합니다.
|
||||
|
||||
- 순수 캐시 조회 (DB fallback 없음)
|
||||
- 캐시에 없으면 빈 배열 반환
|
||||
- 응답 구조는 /search와 동일
|
||||
"""
|
||||
)
|
||||
@GetMapping("/chnprmship")
|
||||
public ResponseEntity<ApiResponse<List<AisTargetResponseDto>>> getChnPrmShip(
|
||||
@Parameter(description = "조회 범위 (분, 기본: 2880 = 2일)", example = "2880")
|
||||
@RequestParam(defaultValue = "2880") Integer minutes) {
|
||||
|
||||
log.info("ChnPrmShip 조회 요청 - minutes: {}", minutes);
|
||||
|
||||
List<AisTargetResponseDto> result = aisTargetService.findChnPrmShip(minutes);
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
"ChnPrmShip 조회 완료: " + result.size() + " 건",
|
||||
result
|
||||
));
|
||||
}
|
||||
|
||||
@Operation(
|
||||
summary = "중국 허가선박 캐시 통계",
|
||||
description = "중국 허가선박 전용 캐시의 현재 상태를 조회합니다"
|
||||
)
|
||||
@GetMapping("/chnprmship/stats")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getChnPrmShipStats() {
|
||||
Map<String, Object> stats = aisTargetService.getChnPrmShipCacheStats();
|
||||
return ResponseEntity.ok(ApiResponse.success(stats));
|
||||
}
|
||||
|
||||
// ==================== 단건 조회 ====================
|
||||
|
||||
@Operation(
|
||||
summary = "MMSI로 최신 위치 조회",
|
||||
description = "특정 MMSI의 최신 위치 정보를 조회합니다 (캐시 우선)"
|
||||
description = "특정 MMSI의 최신 위치 정보를 조회합니다 (캐시 우선)",
|
||||
responses = {
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "조회 성공"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "해당 MMSI의 위치 정보 없음")
|
||||
}
|
||||
)
|
||||
@GetMapping("/{mmsi}")
|
||||
public ResponseEntity<ApiResponse<AisTargetResponseDto>> getLatestByMmsi(
|
||||
@ -98,7 +143,7 @@ public class AisTargetController {
|
||||
@GetMapping("/search")
|
||||
public ResponseEntity<ApiResponse<List<AisTargetResponseDto>>> search(
|
||||
@Parameter(description = "조회 범위 (분)", required = true, example = "5")
|
||||
@RequestParam Integer minutes,
|
||||
@RequestParam @Min(value = 1, message = "minutes는 최소 1분 이상이어야 합니다") Integer minutes,
|
||||
@Parameter(description = "중심 경도", example = "129.0")
|
||||
@RequestParam(required = false) Double centerLon,
|
||||
@Parameter(description = "중심 위도", example = "35.0")
|
||||
@ -128,6 +173,10 @@ public class AisTargetController {
|
||||
|
||||
@Operation(
|
||||
summary = "시간/공간 범위로 선박 검색 (POST)",
|
||||
responses = {
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "검색 성공"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "요청 파라미터 검증 실패 (minutes 누락 또는 1 미만)")
|
||||
},
|
||||
description = """
|
||||
POST 방식으로 검색 조건을 전달합니다.
|
||||
|
||||
@ -167,6 +216,10 @@ public class AisTargetController {
|
||||
|
||||
@Operation(
|
||||
summary = "항해 조건 필터 검색",
|
||||
responses = {
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "필터 검색 성공"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "요청 파라미터 검증 실패")
|
||||
},
|
||||
description = """
|
||||
속도(SOG), 침로(COG), 선수방위(Heading), 목적지, 항행상태로 선박을 필터링합니다.
|
||||
|
||||
@ -218,30 +271,30 @@ public class AisTargetController {
|
||||
"headingCondition": "LT",
|
||||
"headingValue": 180.0,
|
||||
"destination": "BUSAN",
|
||||
"statusList": ["0", "1", "5"]
|
||||
"statusList": ["Under way using engine", "At anchor", "Moored"]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
## 항행상태 코드 (statusList)
|
||||
## 항행상태 값 (statusList)
|
||||
|
||||
| 코드 | 상태 |
|
||||
statusList에는 **텍스트 문자열**을 전달해야 합니다 (대소문자 무시).
|
||||
|
||||
| 값 | 설명 |
|
||||
|------|------|
|
||||
| 0 | Under way using engine (기관 사용 항해 중) |
|
||||
| 1 | At anchor (정박 중) |
|
||||
| 2 | Not under command (조종불능) |
|
||||
| 3 | Restricted manoeuverability (조종제한) |
|
||||
| 4 | Constrained by her draught (흘수제약) |
|
||||
| 5 | Moored (계류 중) |
|
||||
| 6 | Aground (좌초) |
|
||||
| 7 | Engaged in Fishing (어로 중) |
|
||||
| 8 | Under way sailing (돛 항해 중) |
|
||||
| 9-10 | Reserved for future use |
|
||||
| 11 | Power-driven vessel towing astern |
|
||||
| 12 | Power-driven vessel pushing ahead |
|
||||
| 13 | Reserved for future use |
|
||||
| 14 | AIS-SART, MOB-AIS, EPIRB-AIS |
|
||||
| 15 | Undefined (default) |
|
||||
| Under way using engine | 기관 사용 항해 중 |
|
||||
| At anchor | 정박 중 |
|
||||
| Not under command | 조종불능 |
|
||||
| Restricted manoeuverability | 조종제한 |
|
||||
| Constrained by her draught | 흘수제약 |
|
||||
| Moored | 계류 중 |
|
||||
| Aground | 좌초 |
|
||||
| Engaged in Fishing | 어로 중 |
|
||||
| Under way sailing | 돛 항해 중 |
|
||||
| Power Driven Towing Astern | 예인선 (후방) |
|
||||
| Power Driven Towing Alongside | 예인선 (측방) |
|
||||
| AIS Sart | 비상위치지시기 |
|
||||
| N/A | 정보없음 |
|
||||
|
||||
---
|
||||
**참고:** 모든 필터는 선택사항이며, 미지정 시 해당 필드는 조건에서 제외됩니다 (전체 값 포함).
|
||||
@ -269,6 +322,10 @@ public class AisTargetController {
|
||||
|
||||
@Operation(
|
||||
summary = "폴리곤 범위 내 선박 검색",
|
||||
responses = {
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "검색 성공"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "요청 파라미터 검증 실패 (coordinates 또는 minutes 누락)")
|
||||
},
|
||||
description = """
|
||||
폴리곤 범위 내 선박을 검색합니다.
|
||||
|
||||
@ -283,7 +340,7 @@ public class AisTargetController {
|
||||
)
|
||||
@PostMapping("/search/polygon")
|
||||
public ResponseEntity<ApiResponse<List<AisTargetResponseDto>>> searchByPolygon(
|
||||
@RequestBody PolygonSearchRequest request) {
|
||||
@Valid @RequestBody PolygonSearchRequest request) {
|
||||
log.info("폴리곤 검색 요청 - minutes: {}, points: {}",
|
||||
request.getMinutes(), request.getCoordinates().length);
|
||||
|
||||
@ -299,6 +356,10 @@ public class AisTargetController {
|
||||
|
||||
@Operation(
|
||||
summary = "WKT 범위 내 선박 검색",
|
||||
responses = {
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "검색 성공"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "요청 파라미터 검증 실패 (wkt 또는 minutes 누락)")
|
||||
},
|
||||
description = """
|
||||
WKT(Well-Known Text) 형식으로 정의된 범위 내 선박을 검색합니다.
|
||||
|
||||
@ -313,7 +374,7 @@ public class AisTargetController {
|
||||
)
|
||||
@PostMapping("/search/wkt")
|
||||
public ResponseEntity<ApiResponse<List<AisTargetResponseDto>>> searchByWkt(
|
||||
@RequestBody WktSearchRequest request) {
|
||||
@Valid @RequestBody WktSearchRequest request) {
|
||||
log.info("WKT 검색 요청 - minutes: {}, wkt: {}", request.getMinutes(), request.getWkt());
|
||||
|
||||
List<AisTargetResponseDto> result = aisTargetService.searchByWkt(
|
||||
@ -405,11 +466,17 @@ public class AisTargetController {
|
||||
* 폴리곤 검색 요청 DTO
|
||||
*/
|
||||
@lombok.Data
|
||||
@Schema(description = "폴리곤 범위 검색 요청")
|
||||
public static class PolygonSearchRequest {
|
||||
@Parameter(description = "조회 범위 (분)", required = true, example = "5")
|
||||
private int minutes;
|
||||
@NotNull(message = "minutes는 필수입니다")
|
||||
@Min(value = 1, message = "minutes는 최소 1분 이상이어야 합니다")
|
||||
@Schema(description = "조회 범위 (분)", example = "5", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private Integer minutes;
|
||||
|
||||
@Parameter(description = "폴리곤 좌표 [[lon, lat], ...]", required = true)
|
||||
@NotNull(message = "coordinates는 필수입니다")
|
||||
@Schema(description = "폴리곤 좌표 [[경도, 위도], ...] (닫힌 형태: 첫점=끝점)",
|
||||
example = "[[129.0, 35.0], [130.0, 35.0], [130.0, 36.0], [129.0, 36.0], [129.0, 35.0]]",
|
||||
requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private double[][] coordinates;
|
||||
}
|
||||
|
||||
@ -417,12 +484,17 @@ public class AisTargetController {
|
||||
* WKT 검색 요청 DTO
|
||||
*/
|
||||
@lombok.Data
|
||||
@Schema(description = "WKT 범위 검색 요청")
|
||||
public static class WktSearchRequest {
|
||||
@Parameter(description = "조회 범위 (분)", required = true, example = "5")
|
||||
private int minutes;
|
||||
@NotNull(message = "minutes는 필수입니다")
|
||||
@Min(value = 1, message = "minutes는 최소 1분 이상이어야 합니다")
|
||||
@Schema(description = "조회 범위 (분)", example = "5", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private Integer minutes;
|
||||
|
||||
@Parameter(description = "WKT 문자열", required = true,
|
||||
example = "POLYGON((129 35, 130 35, 130 36, 129 36, 129 35))")
|
||||
@NotNull(message = "wkt는 필수입니다")
|
||||
@Schema(description = "WKT 문자열 (POLYGON, MULTIPOLYGON 지원)",
|
||||
example = "POLYGON((129 35, 130 35, 130 36, 129 36, 129 35))",
|
||||
requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String wkt;
|
||||
}
|
||||
}
|
||||
|
||||
@ -22,40 +22,87 @@ import java.time.OffsetDateTime;
|
||||
public class AisTargetResponseDto {
|
||||
|
||||
// 선박 식별 정보
|
||||
@Schema(description = "MMSI (Maritime Mobile Service Identity) 번호", example = "440123456")
|
||||
private Long mmsi;
|
||||
|
||||
@Schema(description = "IMO 번호 (0인 경우 미등록)", example = "9137960")
|
||||
private Long imo;
|
||||
|
||||
@Schema(description = "선박명", example = "ROYAUME DES OCEANS")
|
||||
private String name;
|
||||
|
||||
@Schema(description = "호출 부호", example = "4SFTEST")
|
||||
private String callsign;
|
||||
|
||||
@Schema(description = "선박 유형 (외부 API 원본 텍스트)", example = "Vessel")
|
||||
private String vesselType;
|
||||
|
||||
// 위치 정보
|
||||
@Schema(description = "위도 (WGS84)", example = "35.0796")
|
||||
private Double lat;
|
||||
|
||||
@Schema(description = "경도 (WGS84)", example = "129.0756")
|
||||
private Double lon;
|
||||
|
||||
// 항해 정보
|
||||
@Schema(description = "선수방위 (degrees, 0-360)", example = "36.0")
|
||||
private Double heading;
|
||||
private Double sog; // Speed over Ground
|
||||
private Double cog; // Course over Ground
|
||||
private Integer rot; // Rate of Turn
|
||||
|
||||
@Schema(description = "대지속력 (knots)", example = "12.5")
|
||||
private Double sog;
|
||||
|
||||
@Schema(description = "대지침로 (degrees, 0-360)", example = "36.2")
|
||||
private Double cog;
|
||||
|
||||
@Schema(description = "회전율 (Rate of Turn)", example = "0")
|
||||
private Integer rot;
|
||||
|
||||
// 선박 제원
|
||||
@Schema(description = "선박 길이 (미터)", example = "19")
|
||||
private Integer length;
|
||||
|
||||
@Schema(description = "선박 폭 (미터)", example = "15")
|
||||
private Integer width;
|
||||
|
||||
@Schema(description = "흘수 (미터)", example = "5.5")
|
||||
private Double draught;
|
||||
|
||||
// 목적지 정보
|
||||
@Schema(description = "목적지", example = "BUSAN")
|
||||
private String destination;
|
||||
|
||||
@Schema(description = "예정 도착 시간 (UTC)")
|
||||
private OffsetDateTime eta;
|
||||
|
||||
@Schema(description = "항행상태 (텍스트)", example = "Under way using engine")
|
||||
private String status;
|
||||
|
||||
// 타임스탬프
|
||||
@Schema(description = "AIS 메시지 발생 시각 (UTC)")
|
||||
private OffsetDateTime messageTimestamp;
|
||||
|
||||
@Schema(description = "데이터 수신 시각 (UTC)")
|
||||
private OffsetDateTime receivedDate;
|
||||
|
||||
// 데이터 소스 (캐시/DB)
|
||||
@Schema(description = "데이터 소스", example = "cache", allowableValues = {"cache", "db"})
|
||||
private String source;
|
||||
|
||||
// 선종 분류 정보
|
||||
@Schema(description = """
|
||||
MDA 범례코드 (선종 분류)
|
||||
- 000020: 어선 (FISHING)
|
||||
- 000021: 함정 (KCGV)
|
||||
- 000022: 여객선 (FERRY)
|
||||
- 000023: 카고 (CARGO)
|
||||
- 000024: 탱커 (TANKER)
|
||||
- 000025: 관공선 (GOV)
|
||||
- 000027: 일반/기타선박 (DEFAULT)
|
||||
- 000028: 부이/항로표지 (BUOY)
|
||||
""",
|
||||
example = "000023")
|
||||
private String signalKindCode;
|
||||
|
||||
// ClassType 분류 정보
|
||||
@Schema(description = """
|
||||
선박 클래스 타입
|
||||
@ -102,6 +149,7 @@ public class AisTargetResponseDto {
|
||||
.messageTimestamp(entity.getMessageTimestamp())
|
||||
.receivedDate(entity.getReceivedDate())
|
||||
.source(source)
|
||||
.signalKindCode(entity.getSignalKindCode())
|
||||
.classType(entity.getClassType())
|
||||
.core20Mmsi(entity.getCore20Mmsi())
|
||||
.build();
|
||||
|
||||
@ -5,6 +5,7 @@ import com.snp.batch.jobs.aistarget.batch.repository.AisTargetRepository;
|
||||
import com.snp.batch.jobs.aistarget.cache.AisTargetCacheManager;
|
||||
import com.snp.batch.jobs.aistarget.cache.AisTargetFilterUtil;
|
||||
import com.snp.batch.jobs.aistarget.cache.SpatialFilterUtil;
|
||||
import com.snp.batch.jobs.aistarget.chnprmship.ChnPrmShipCacheManager;
|
||||
import com.snp.batch.jobs.aistarget.web.dto.AisTargetFilterRequest;
|
||||
import com.snp.batch.jobs.aistarget.web.dto.AisTargetResponseDto;
|
||||
import com.snp.batch.jobs.aistarget.web.dto.AisTargetSearchRequest;
|
||||
@ -38,6 +39,7 @@ public class AisTargetService {
|
||||
private final AisTargetCacheManager cacheManager;
|
||||
private final SpatialFilterUtil spatialFilterUtil;
|
||||
private final AisTargetFilterUtil filterUtil;
|
||||
private final ChnPrmShipCacheManager chnPrmShipCacheManager;
|
||||
|
||||
private static final String SOURCE_CACHE = "cache";
|
||||
private static final String SOURCE_DB = "db";
|
||||
@ -360,6 +362,36 @@ public class AisTargetService {
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
// ==================== 중국 허가선박 전용 조회 ====================
|
||||
|
||||
/**
|
||||
* 중국 허가선박 전용 캐시 조회 (DB fallback 없음)
|
||||
*
|
||||
* @param minutes 조회 범위 (분)
|
||||
* @return 시간 범위 내 대상 선박 목록
|
||||
*/
|
||||
public List<AisTargetResponseDto> findChnPrmShip(int minutes) {
|
||||
log.debug("ChnPrmShip 조회 - minutes: {}", minutes);
|
||||
|
||||
long startTime = System.currentTimeMillis();
|
||||
|
||||
List<AisTargetEntity> entities = chnPrmShipCacheManager.getByTimeRange(minutes);
|
||||
|
||||
long elapsed = System.currentTimeMillis() - startTime;
|
||||
log.info("ChnPrmShip 조회 완료 - 결과: {} 건, 소요: {}ms", entities.size(), elapsed);
|
||||
|
||||
return entities.stream()
|
||||
.map(e -> AisTargetResponseDto.from(e, SOURCE_CACHE))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* ChnPrmShip 캐시 통계 조회
|
||||
*/
|
||||
public Map<String, Object> getChnPrmShipCacheStats() {
|
||||
return chnPrmShipCacheManager.getStats();
|
||||
}
|
||||
|
||||
// ==================== 캐시 관리 ====================
|
||||
|
||||
/**
|
||||
|
||||
@ -3,7 +3,6 @@ package com.snp.batch.jobs.aistargetdbsync.batch.tasklet;
|
||||
import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
|
||||
import com.snp.batch.jobs.aistarget.batch.repository.AisTargetRepository;
|
||||
import com.snp.batch.jobs.aistarget.cache.AisTargetCacheManager;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.batch.core.StepContribution;
|
||||
import org.springframework.batch.core.scope.context.ChunkContext;
|
||||
@ -12,53 +11,69 @@ import org.springframework.batch.repeat.RepeatStatus;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
/**
|
||||
* AIS Target DB Sync Tasklet
|
||||
*
|
||||
* 스케줄: 매 15분 (0 0/15 * * * ?)
|
||||
*
|
||||
* 동작:
|
||||
* - Caffeine 캐시에서 최근 N분 이내 데이터 조회
|
||||
* - Caffeine 캐시에서 마지막 성공 이후 ~ 현재까지의 데이터를 조회
|
||||
* - MMSI별 최신 위치 1건씩 DB에 UPSERT
|
||||
* - 캐시의 모든 컬럼 정보를 그대로 DB에 저장
|
||||
*
|
||||
* 시간 범위 결정 전략:
|
||||
* - 첫 실행 또는 마지막 실행 정보 없음 → fallback(time-range-minutes) 사용
|
||||
* - 이후 실행 → 마지막 성공 시각 기준으로 경과 시간 자동 계산
|
||||
* - cron 주기를 변경해도 별도 설정 불필요 (자동 동기화)
|
||||
*
|
||||
* 참고:
|
||||
* - 캐시에는 MMSI별 최신 데이터만 유지됨 (120분 TTL)
|
||||
* - DB 저장은 15분 주기로 수행하여 볼륨 절감
|
||||
* - 기존 aisTargetImportJob은 캐시 업데이트만 수행
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class AisTargetDbSyncTasklet implements Tasklet {
|
||||
|
||||
private final AisTargetCacheManager cacheManager;
|
||||
private final AisTargetRepository aisTargetRepository;
|
||||
private final int fallbackMinutes;
|
||||
|
||||
/**
|
||||
* DB 동기화 시 조회할 캐시 데이터 시간 범위 (분)
|
||||
* 기본값: 15분 (스케줄 주기와 동일)
|
||||
* 마지막 성공 시각 (JVM 내 유지, 재기동 시 fallback 사용)
|
||||
*/
|
||||
@Value("${app.batch.ais-target-db-sync.time-range-minutes:15}")
|
||||
private int timeRangeMinutes;
|
||||
private final AtomicReference<Instant> lastSuccessTime = new AtomicReference<>();
|
||||
|
||||
public AisTargetDbSyncTasklet(
|
||||
AisTargetCacheManager cacheManager,
|
||||
AisTargetRepository aisTargetRepository,
|
||||
@Value("${app.batch.ais-target-db-sync.time-range-minutes:15}") int fallbackMinutes) {
|
||||
this.cacheManager = cacheManager;
|
||||
this.aisTargetRepository = aisTargetRepository;
|
||||
this.fallbackMinutes = fallbackMinutes;
|
||||
}
|
||||
|
||||
@Override
|
||||
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception {
|
||||
Instant now = Instant.now();
|
||||
int rangeMinutes = resolveRangeMinutes(now);
|
||||
|
||||
log.info("========================================");
|
||||
log.info("AIS Target DB Sync 시작");
|
||||
log.info("조회 범위: 최근 {}분", timeRangeMinutes);
|
||||
log.info("조회 범위: 최근 {}분 (방식: {})", rangeMinutes,
|
||||
lastSuccessTime.get() != null ? "마지막 성공 기준" : "fallback");
|
||||
log.info("현재 캐시 크기: {}", cacheManager.size());
|
||||
log.info("========================================");
|
||||
|
||||
long startTime = System.currentTimeMillis();
|
||||
|
||||
// 1. 캐시에서 최근 N분 이내 데이터 조회
|
||||
List<AisTargetEntity> entities = cacheManager.getByTimeRange(timeRangeMinutes);
|
||||
// 1. 캐시에서 시간 범위 내 데이터 조회
|
||||
List<AisTargetEntity> entities = cacheManager.getByTimeRange(rangeMinutes);
|
||||
|
||||
if (entities.isEmpty()) {
|
||||
log.warn("캐시에서 조회된 데이터가 없습니다 (범위: {}분)", timeRangeMinutes);
|
||||
log.warn("캐시에서 조회된 데이터가 없습니다 (범위: {}분)", rangeMinutes);
|
||||
lastSuccessTime.set(now);
|
||||
return RepeatStatus.FINISHED;
|
||||
}
|
||||
|
||||
@ -69,6 +84,9 @@ public class AisTargetDbSyncTasklet implements Tasklet {
|
||||
|
||||
long elapsed = System.currentTimeMillis() - startTime;
|
||||
|
||||
// 성공 시각 기록
|
||||
lastSuccessTime.set(now);
|
||||
|
||||
log.info("========================================");
|
||||
log.info("AIS Target DB Sync 완료");
|
||||
log.info("저장 건수: {} 건", entities.size());
|
||||
@ -80,4 +98,24 @@ public class AisTargetDbSyncTasklet implements Tasklet {
|
||||
|
||||
return RepeatStatus.FINISHED;
|
||||
}
|
||||
|
||||
private static final int MAX_RANGE_MINUTES = 60;
|
||||
|
||||
/**
|
||||
* 조회 범위(분) 결정
|
||||
* - 마지막 성공 시각이 있으면: 경과 시간 + 1분 버퍼 (최대 60분)
|
||||
* - 없으면: fallback 값 사용
|
||||
* - 오래 중단 후 재가동 시에도 최대 60분으로 제한하여 과부하 방지
|
||||
*/
|
||||
private int resolveRangeMinutes(Instant now) {
|
||||
Instant last = lastSuccessTime.get();
|
||||
if (last == null) {
|
||||
return Math.min(fallbackMinutes, MAX_RANGE_MINUTES);
|
||||
}
|
||||
|
||||
long elapsedMinutes = java.time.Duration.between(last, now).toMinutes();
|
||||
// 경과 시간 + 1분 버퍼 (겹침 허용, UPSERT이므로 중복 안전), 최대 60분
|
||||
int range = (int) Math.max(elapsedMinutes + 1, 1);
|
||||
return Math.min(range, MAX_RANGE_MINUTES);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
package com.snp.batch.service;
|
||||
|
||||
import com.snp.batch.global.dto.JobExecutionDto;
|
||||
import com.snp.batch.global.dto.*;
|
||||
import com.snp.batch.global.repository.TimelineRepository;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.batch.core.Job;
|
||||
@ -14,7 +14,10 @@ import org.springframework.batch.core.launch.JobOperator;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@ -86,6 +89,13 @@ public class BatchService {
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
public List<JobExecutionDto> getRecentExecutions(int limit) {
|
||||
List<Map<String, Object>> recentData = timelineRepository.findRecentExecutions(limit);
|
||||
return recentData.stream()
|
||||
.map(this::convertMapToDto)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
public JobExecutionDto getExecutionDetails(Long executionId) {
|
||||
JobExecution jobExecution = jobExplorer.getJobExecution(executionId);
|
||||
if (jobExecution == null) {
|
||||
@ -557,10 +567,206 @@ public class BatchService {
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
|
||||
return com.snp.batch.global.dto.DashboardResponse.builder()
|
||||
// 4. 최근 실패 이력 (24시간 이내, 최대 10건)
|
||||
List<Map<String, Object>> failureData = timelineRepository.findRecentFailures(24);
|
||||
List<DashboardResponse.RecentFailure> recentFailures = failureData.stream()
|
||||
.map(data -> {
|
||||
java.sql.Timestamp startTs = (java.sql.Timestamp) data.get("startTime");
|
||||
java.sql.Timestamp endTs = (java.sql.Timestamp) data.get("endTime");
|
||||
return DashboardResponse.RecentFailure.builder()
|
||||
.executionId(((Number) data.get("executionId")).longValue())
|
||||
.jobName((String) data.get("jobName"))
|
||||
.status((String) data.get("status"))
|
||||
.startTime(startTs != null ? startTs.toLocalDateTime() : null)
|
||||
.endTime(endTs != null ? endTs.toLocalDateTime() : null)
|
||||
.exitMessage((String) data.get("exitMessage"))
|
||||
.build();
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
|
||||
// 5. 오래된 실행 중 건수
|
||||
int staleExecutionCount = timelineRepository.countStaleExecutions(60);
|
||||
|
||||
// 6. 실패 통계
|
||||
int last24h = timelineRepository.countFailuresSince(LocalDateTime.now().minusHours(24));
|
||||
int last7d = timelineRepository.countFailuresSince(LocalDateTime.now().minusDays(7));
|
||||
DashboardResponse.FailureStats failureStats = DashboardResponse.FailureStats.builder()
|
||||
.last24h(last24h)
|
||||
.last7d(last7d)
|
||||
.build();
|
||||
|
||||
return DashboardResponse.builder()
|
||||
.stats(stats)
|
||||
.runningJobs(runningJobs)
|
||||
.recentExecutions(recentExecutions)
|
||||
.recentFailures(recentFailures)
|
||||
.staleExecutionCount(staleExecutionCount)
|
||||
.failureStats(failureStats)
|
||||
.build();
|
||||
}
|
||||
|
||||
// ── F1: 강제 종료(Abandon) 관련 ──────────────────────────────
|
||||
|
||||
public List<JobExecutionDto> getStaleExecutions(int thresholdMinutes) {
|
||||
List<Map<String, Object>> data = timelineRepository.findStaleExecutions(thresholdMinutes);
|
||||
return data.stream()
|
||||
.map(this::convertMapToDto)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void abandonExecution(long executionId) {
|
||||
int stepCount = timelineRepository.abandonStepExecutions(executionId);
|
||||
int jobCount = timelineRepository.abandonJobExecution(executionId);
|
||||
log.info("Abandoned execution {}: job={}, steps={}", executionId, jobCount, stepCount);
|
||||
if (jobCount == 0) {
|
||||
throw new IllegalArgumentException("실행 중 상태가 아니거나 존재하지 않는 executionId: " + executionId);
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public int abandonAllStaleExecutions(int thresholdMinutes) {
|
||||
List<Map<String, Object>> staleExecutions = timelineRepository.findStaleExecutions(thresholdMinutes);
|
||||
int abandonedCount = 0;
|
||||
for (Map<String, Object> exec : staleExecutions) {
|
||||
long executionId = ((Number) exec.get("executionId")).longValue();
|
||||
timelineRepository.abandonStepExecutions(executionId);
|
||||
int updated = timelineRepository.abandonJobExecution(executionId);
|
||||
abandonedCount += updated;
|
||||
}
|
||||
log.info("Abandoned {} stale executions (threshold: {} minutes)", abandonedCount, thresholdMinutes);
|
||||
return abandonedCount;
|
||||
}
|
||||
|
||||
// ── F4: 실행 이력 검색 (페이지네이션) ─────────────────────────
|
||||
|
||||
public ExecutionSearchResponse searchExecutions(
|
||||
List<String> jobNames, String status,
|
||||
LocalDateTime startDate, LocalDateTime endDate,
|
||||
int page, int size) {
|
||||
|
||||
int offset = page * size;
|
||||
List<Map<String, Object>> data = timelineRepository.searchExecutions(
|
||||
jobNames, status, startDate, endDate, offset, size);
|
||||
int totalCount = timelineRepository.countExecutions(jobNames, status, startDate, endDate);
|
||||
|
||||
List<JobExecutionDto> executions = data.stream()
|
||||
.map(this::convertMapToDto)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
return ExecutionSearchResponse.builder()
|
||||
.executions(executions)
|
||||
.totalCount(totalCount)
|
||||
.page(page)
|
||||
.size(size)
|
||||
.totalPages((int) Math.ceil((double) totalCount / size))
|
||||
.build();
|
||||
}
|
||||
|
||||
// ── F7: Job 상세 목록 ────────────────────────────────────────
|
||||
|
||||
public List<JobDetailDto> getJobsWithDetail() {
|
||||
// Job별 최근 실행 정보
|
||||
List<Map<String, Object>> lastExecutions = timelineRepository.findLastExecutionPerJob();
|
||||
Map<String, Map<String, Object>> lastExecMap = lastExecutions.stream()
|
||||
.collect(Collectors.toMap(
|
||||
data -> (String) data.get("jobName"),
|
||||
data -> data
|
||||
));
|
||||
|
||||
// 스케줄 정보
|
||||
List<ScheduleResponse> schedules = scheduleService.getAllSchedules();
|
||||
Map<String, String> cronMap = schedules.stream()
|
||||
.collect(Collectors.toMap(
|
||||
ScheduleResponse::getJobName,
|
||||
ScheduleResponse::getCronExpression,
|
||||
(a, b) -> a
|
||||
));
|
||||
|
||||
return jobMap.keySet().stream()
|
||||
.sorted()
|
||||
.map(jobName -> {
|
||||
JobDetailDto.LastExecution lastExec = null;
|
||||
Map<String, Object> execData = lastExecMap.get(jobName);
|
||||
if (execData != null) {
|
||||
java.sql.Timestamp startTs = (java.sql.Timestamp) execData.get("startTime");
|
||||
java.sql.Timestamp endTs = (java.sql.Timestamp) execData.get("endTime");
|
||||
lastExec = JobDetailDto.LastExecution.builder()
|
||||
.executionId(((Number) execData.get("executionId")).longValue())
|
||||
.status((String) execData.get("status"))
|
||||
.startTime(startTs != null ? startTs.toLocalDateTime() : null)
|
||||
.endTime(endTs != null ? endTs.toLocalDateTime() : null)
|
||||
.build();
|
||||
}
|
||||
|
||||
return JobDetailDto.builder()
|
||||
.jobName(jobName)
|
||||
.lastExecution(lastExec)
|
||||
.scheduleCron(cronMap.get(jobName))
|
||||
.build();
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
// ── F8: 실행 통계 ──────────────────────────────────────────
|
||||
|
||||
public ExecutionStatisticsDto getStatistics(int days) {
|
||||
List<Map<String, Object>> dailyData = timelineRepository.findDailyStatistics(days);
|
||||
return buildStatisticsDto(dailyData);
|
||||
}
|
||||
|
||||
public ExecutionStatisticsDto getJobStatistics(String jobName, int days) {
|
||||
List<Map<String, Object>> dailyData = timelineRepository.findDailyStatisticsForJob(jobName, days);
|
||||
return buildStatisticsDto(dailyData);
|
||||
}
|
||||
|
||||
private ExecutionStatisticsDto buildStatisticsDto(List<Map<String, Object>> dailyData) {
|
||||
List<ExecutionStatisticsDto.DailyStat> dailyStats = dailyData.stream()
|
||||
.map(data -> {
|
||||
Object dateObj = data.get("execDate");
|
||||
String dateStr = dateObj != null ? dateObj.toString() : "";
|
||||
Number avgMs = (Number) data.get("avgDurationMs");
|
||||
return ExecutionStatisticsDto.DailyStat.builder()
|
||||
.date(dateStr)
|
||||
.successCount(((Number) data.get("successCount")).intValue())
|
||||
.failedCount(((Number) data.get("failedCount")).intValue())
|
||||
.otherCount(((Number) data.get("otherCount")).intValue())
|
||||
.avgDurationMs(avgMs != null ? avgMs.doubleValue() : 0)
|
||||
.build();
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
|
||||
int totalSuccess = dailyStats.stream().mapToInt(ExecutionStatisticsDto.DailyStat::getSuccessCount).sum();
|
||||
int totalFailed = dailyStats.stream().mapToInt(ExecutionStatisticsDto.DailyStat::getFailedCount).sum();
|
||||
int totalOther = dailyStats.stream().mapToInt(ExecutionStatisticsDto.DailyStat::getOtherCount).sum();
|
||||
double avgDuration = dailyStats.stream()
|
||||
.mapToDouble(ExecutionStatisticsDto.DailyStat::getAvgDurationMs)
|
||||
.filter(d -> d > 0)
|
||||
.average()
|
||||
.orElse(0);
|
||||
|
||||
return ExecutionStatisticsDto.builder()
|
||||
.dailyStats(dailyStats)
|
||||
.totalExecutions(totalSuccess + totalFailed + totalOther)
|
||||
.totalSuccess(totalSuccess)
|
||||
.totalFailed(totalFailed)
|
||||
.avgDurationMs(avgDuration)
|
||||
.build();
|
||||
}
|
||||
|
||||
// ── 공통: Map → DTO 변환 헬퍼 ────────────────────────────────
|
||||
|
||||
private JobExecutionDto convertMapToDto(Map<String, Object> data) {
|
||||
java.sql.Timestamp startTimestamp = (java.sql.Timestamp) data.get("startTime");
|
||||
java.sql.Timestamp endTimestamp = (java.sql.Timestamp) data.get("endTime");
|
||||
return JobExecutionDto.builder()
|
||||
.executionId(((Number) data.get("executionId")).longValue())
|
||||
.jobName((String) data.get("jobName"))
|
||||
.status((String) data.get("status"))
|
||||
.startTime(startTimestamp != null ? startTimestamp.toLocalDateTime() : null)
|
||||
.endTime(endTimestamp != null ? endTimestamp.toLocalDateTime() : null)
|
||||
.exitCode((String) data.get("exitCode"))
|
||||
.exitMessage((String) data.get("exitMessage"))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
@ -328,24 +328,27 @@ public class ScheduleService {
|
||||
.createdBy(entity.getCreatedBy())
|
||||
.updatedBy(entity.getUpdatedBy());
|
||||
|
||||
// 다음 실행 시간 계산 (Cron 표현식 기반)
|
||||
// Quartz 트리거에서 실행 시간 정보 조회
|
||||
if (entity.getActive() && entity.getCronExpression() != null) {
|
||||
try {
|
||||
// Cron 표현식으로 임시 트리거 생성 (DB 조회 없이 계산)
|
||||
TriggerKey triggerKey = new TriggerKey(entity.getJobName() + "-trigger", "batch-triggers");
|
||||
Trigger quartzTrigger = scheduler.getTrigger(triggerKey);
|
||||
|
||||
if (quartzTrigger != null) {
|
||||
builder.nextFireTime(quartzTrigger.getNextFireTime());
|
||||
builder.previousFireTime(quartzTrigger.getPreviousFireTime());
|
||||
builder.triggerState(
|
||||
scheduler.getTriggerState(triggerKey).name());
|
||||
} else {
|
||||
// 트리거 미등록 시 Cron 표현식 기반 계산
|
||||
CronTrigger tempTrigger = TriggerBuilder.newTrigger()
|
||||
.withSchedule(CronScheduleBuilder.cronSchedule(entity.getCronExpression()))
|
||||
.build();
|
||||
|
||||
Date nextFireTime = tempTrigger.getFireTimeAfter(new Date());
|
||||
if (nextFireTime != null) {
|
||||
builder.nextFireTime(nextFireTime);
|
||||
builder.nextFireTime(tempTrigger.getFireTimeAfter(new Date()));
|
||||
builder.triggerState("NONE");
|
||||
}
|
||||
|
||||
// Trigger 상태는 active인 경우 NORMAL로 설정
|
||||
builder.triggerState("NORMAL");
|
||||
|
||||
} catch (Exception e) {
|
||||
log.debug("Cron 표현식 기반 다음 실행 시간 계산 실패: {}", entity.getJobName(), e);
|
||||
log.debug("Quartz 트리거 정보 조회 실패: {}", entity.getJobName(), e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -117,7 +117,7 @@ app:
|
||||
schedule:
|
||||
cron: "15 * * * * ?" # 매 분 15초 실행
|
||||
kafka:
|
||||
enabled: true
|
||||
enabled: false
|
||||
topic: tp_Global_AIS_Signal
|
||||
send-chunk-size: 5000
|
||||
fail-on-send-error: false
|
||||
|
||||
@ -169,7 +169,7 @@ app:
|
||||
schedule:
|
||||
cron: "15 * * * * ?" # 매 분 15초 실행
|
||||
kafka:
|
||||
enabled: true
|
||||
enabled: false # true로 변경 시 Kafka 브로커 연결 필요
|
||||
topic: tp_Global_AIS_Signal
|
||||
send-chunk-size: 5000
|
||||
fail-on-send-error: false
|
||||
@ -185,6 +185,14 @@ app:
|
||||
ttl-minutes: 120 # 캐시 TTL (분) - 2시간
|
||||
max-size: 300000 # 최대 캐시 크기 - 30만 건
|
||||
|
||||
# 중국 허가선박 전용 캐시 설정
|
||||
chnprmship:
|
||||
mmsi-resource-path: classpath:chnprmship-mmsi.txt
|
||||
ttl-days: 2
|
||||
max-size: 2000
|
||||
warmup-enabled: true
|
||||
warmup-days: 2
|
||||
|
||||
# ClassType 분류 설정
|
||||
class-type:
|
||||
refresh-hour: 4 # Core20 캐시 갱신 시간 (기본: 04시)
|
||||
|
||||
1402
src/main/resources/chnprmship-mmsi.txt
Normal file
1402
src/main/resources/chnprmship-mmsi.txt
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
2078
src/main/resources/static/css/bootstrap-icons.css
vendored
2078
src/main/resources/static/css/bootstrap-icons.css
vendored
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
@ -1,512 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>실행 상세 - SNP 배치</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
padding: 30px;
|
||||
margin-bottom: 30px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
padding: 10px 20px;
|
||||
background: #667eea;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
background: #5568d3;
|
||||
}
|
||||
|
||||
.back-btn.secondary {
|
||||
background: #48bb78;
|
||||
}
|
||||
|
||||
.back-btn.secondary:hover {
|
||||
background: #38a169;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.section {
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
padding: 30px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 2px solid #667eea;
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
padding: 15px;
|
||||
background: #f7fafc;
|
||||
border-radius: 6px;
|
||||
border-left: 4px solid #667eea;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: 12px;
|
||||
color: #718096;
|
||||
margin-bottom: 5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: 16px;
|
||||
color: #2d3748;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 6px 16px;
|
||||
border-radius: 20px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-COMPLETED {
|
||||
background: #c6f6d5;
|
||||
color: #22543d;
|
||||
}
|
||||
|
||||
.status-FAILED {
|
||||
background: #fed7d7;
|
||||
color: #742a2a;
|
||||
}
|
||||
|
||||
.status-STARTED {
|
||||
background: #bee3f8;
|
||||
color: #2c5282;
|
||||
}
|
||||
|
||||
.status-STOPPED {
|
||||
background: #feebc8;
|
||||
color: #7c2d12;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
opacity: 0.9;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.step-list {
|
||||
display: grid;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.step-item {
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.step-item:hover {
|
||||
border-color: #667eea;
|
||||
background: #f7fafc;
|
||||
}
|
||||
|
||||
.step-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.step-name {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.step-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.step-stat {
|
||||
padding: 10px;
|
||||
background: #edf2f7;
|
||||
border-radius: 6px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.step-stat-label {
|
||||
font-size: 11px;
|
||||
color: #718096;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.step-stat-value {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #2d3748;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 60px;
|
||||
color: #666;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.error {
|
||||
text-align: center;
|
||||
padding: 60px;
|
||||
color: #e53e3e;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.param-list {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.param-item {
|
||||
padding: 10px;
|
||||
margin-bottom: 8px;
|
||||
background: #f7fafc;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.param-key {
|
||||
font-weight: 600;
|
||||
color: #4a5568;
|
||||
}
|
||||
|
||||
.param-value {
|
||||
color: #2d3748;
|
||||
font-family: monospace;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>실행 상세 정보</h1>
|
||||
<div class="button-group">
|
||||
<a th:href="@{/}" href="/" class="back-btn secondary">← 대시보드로</a>
|
||||
<a th:href="@{/executions}" href="/executions" class="back-btn">← 실행 이력으로</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="content" class="content">
|
||||
<div class="loading">상세 정보 로딩 중...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script th:inline="javascript">
|
||||
// Context path for API calls
|
||||
const contextPath = /*[[@{/}]]*/ '/';
|
||||
|
||||
// URL에서 실행 ID 추출 (두 가지 형식 지원)
|
||||
// 1. Path parameter: /executions/123
|
||||
// 2. Query parameter: /execution-detail?id=123
|
||||
let executionId = null;
|
||||
|
||||
const pathMatch = window.location.pathname.match(/\/executions\/(\d+)/);
|
||||
if (pathMatch) {
|
||||
executionId = pathMatch[1];
|
||||
} else {
|
||||
executionId = new URLSearchParams(window.location.search).get('id');
|
||||
}
|
||||
|
||||
if (!executionId) {
|
||||
document.getElementById('content').innerHTML =
|
||||
'<div class="error">실행 ID가 제공되지 않았습니다.</div>';
|
||||
} else {
|
||||
loadExecutionDetail();
|
||||
}
|
||||
|
||||
async function loadExecutionDetail() {
|
||||
try {
|
||||
const response = await fetch(contextPath + `api/batch/executions/${executionId}/detail`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('실행 정보를 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
const detail = await response.json();
|
||||
renderDetail(detail);
|
||||
|
||||
} catch (error) {
|
||||
document.getElementById('content').innerHTML =
|
||||
`<div class="error">에러 발생: ${error.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderDetail(detail) {
|
||||
const duration = detail.duration ? formatDuration(detail.duration) : '-';
|
||||
const startTime = detail.startTime ? new Date(detail.startTime).toLocaleString('ko-KR') : '-';
|
||||
const endTime = detail.endTime ? new Date(detail.endTime).toLocaleString('ko-KR') : '-';
|
||||
|
||||
const html = `
|
||||
<!-- 기본 정보 -->
|
||||
<div class="section">
|
||||
<h2 class="section-title">Job 실행 정보</h2>
|
||||
<div class="info-grid">
|
||||
<div class="info-item">
|
||||
<div class="info-label">실행 ID</div>
|
||||
<div class="info-value">${detail.executionId}</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="info-label">Job 이름</div>
|
||||
<div class="info-value">${detail.jobName}</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="info-label">상태</div>
|
||||
<div class="info-value">
|
||||
<span class="status-badge status-${detail.status}">${detail.status}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="info-label">실행 시간</div>
|
||||
<div class="info-value">${duration}</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="info-label">시작 시간</div>
|
||||
<div class="info-value">${startTime}</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="info-label">종료 시간</div>
|
||||
<div class="info-value">${endTime}</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="info-label">Job Instance ID</div>
|
||||
<div class="info-value">${detail.jobInstanceId}</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="info-label">Exit Code</div>
|
||||
<div class="info-value">${detail.exitCode || '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
${detail.exitMessage ? `
|
||||
<div style="margin-top: 20px; padding: 15px; background: #fff5f5; border-left: 4px solid #fc8181; border-radius: 6px;">
|
||||
<div style="font-weight: 600; color: #742a2a; margin-bottom: 5px;">Exit Message</div>
|
||||
<div style="color: #742a2a;">${detail.exitMessage}</div>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
|
||||
<!-- 통계 -->
|
||||
<div class="section">
|
||||
<h2 class="section-title">실행 통계</h2>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">읽기</div>
|
||||
<div class="stat-value">${detail.readCount || 0}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">쓰기</div>
|
||||
<div class="stat-value">${detail.writeCount || 0}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">스킵</div>
|
||||
<div class="stat-value">${detail.skipCount || 0}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">필터</div>
|
||||
<div class="stat-value">${detail.filterCount || 0}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Job Parameters -->
|
||||
${detail.jobParameters && Object.keys(detail.jobParameters).length > 0 ? `
|
||||
<div class="section">
|
||||
<h2 class="section-title">Job Parameters</h2>
|
||||
<ul class="param-list">
|
||||
${Object.entries(detail.jobParameters).map(([key, value]) => `
|
||||
<li class="param-item">
|
||||
<span class="param-key">${key}</span>
|
||||
<span class="param-value">${value}</span>
|
||||
</li>
|
||||
`).join('')}
|
||||
</ul>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<!-- Step 실행 정보 -->
|
||||
<div class="section">
|
||||
<h2 class="section-title">Step 실행 정보 (${detail.stepExecutions.length}개)</h2>
|
||||
<div class="step-list">
|
||||
${detail.stepExecutions.map(step => renderStep(step)).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById('content').innerHTML = html;
|
||||
}
|
||||
|
||||
function renderStep(step) {
|
||||
const duration = step.duration ? formatDuration(step.duration) : '-';
|
||||
const startTime = step.startTime ? new Date(step.startTime).toLocaleString('ko-KR') : '-';
|
||||
const endTime = step.endTime ? new Date(step.endTime).toLocaleString('ko-KR') : '-';
|
||||
|
||||
return `
|
||||
<div class="step-item">
|
||||
<div class="step-header">
|
||||
<div class="step-name">${step.stepName}</div>
|
||||
<span class="status-badge status-${step.status}">${step.status}</span>
|
||||
</div>
|
||||
|
||||
<div class="info-grid" style="margin-bottom: 15px;">
|
||||
<div class="info-item">
|
||||
<div class="info-label">Step ID</div>
|
||||
<div class="info-value">${step.stepExecutionId}</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="info-label">실행 시간</div>
|
||||
<div class="info-value">${duration}</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="info-label">시작 시간</div>
|
||||
<div class="info-value">${startTime}</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="info-label">종료 시간</div>
|
||||
<div class="info-value">${endTime}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="step-stats">
|
||||
<div class="step-stat">
|
||||
<div class="step-stat-label">읽기</div>
|
||||
<div class="step-stat-value">${step.readCount || 0}</div>
|
||||
</div>
|
||||
<div class="step-stat">
|
||||
<div class="step-stat-label">쓰기</div>
|
||||
<div class="step-stat-value">${step.writeCount || 0}</div>
|
||||
</div>
|
||||
<div class="step-stat">
|
||||
<div class="step-stat-label">커밋</div>
|
||||
<div class="step-stat-value">${step.commitCount || 0}</div>
|
||||
</div>
|
||||
<div class="step-stat">
|
||||
<div class="step-stat-label">롤백</div>
|
||||
<div class="step-stat-value">${step.rollbackCount || 0}</div>
|
||||
</div>
|
||||
<div class="step-stat">
|
||||
<div class="step-stat-label">읽기 스킵</div>
|
||||
<div class="step-stat-value">${step.readSkipCount || 0}</div>
|
||||
</div>
|
||||
<div class="step-stat">
|
||||
<div class="step-stat-label">처리 스킵</div>
|
||||
<div class="step-stat-value">${step.processSkipCount || 0}</div>
|
||||
</div>
|
||||
<div class="step-stat">
|
||||
<div class="step-stat-label">쓰기 스킵</div>
|
||||
<div class="step-stat-value">${step.writeSkipCount || 0}</div>
|
||||
</div>
|
||||
<div class="step-stat">
|
||||
<div class="step-stat-label">필터</div>
|
||||
<div class="step-stat-value">${step.filterCount || 0}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${step.exitMessage ? `
|
||||
<div style="margin-top: 15px; padding: 10px; background: #fff5f5; border-radius: 6px;">
|
||||
<div style="font-size: 12px; font-weight: 600; color: #742a2a; margin-bottom: 5px;">Exit Message</div>
|
||||
<div style="font-size: 14px; color: #742a2a;">${step.exitMessage}</div>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function formatDuration(ms) {
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}시간 ${minutes % 60}분 ${seconds % 60}초`;
|
||||
} else if (minutes > 0) {
|
||||
return `${minutes}분 ${seconds % 60}초`;
|
||||
} else {
|
||||
return `${seconds}초`;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,392 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>작업 실행 이력 - SNP 배치</title>
|
||||
|
||||
<!-- Bootstrap 5 CSS (로컬) -->
|
||||
<link th:href="@{/css/bootstrap.min.css}" href="/css/bootstrap.min.css" rel="stylesheet">
|
||||
<!-- Bootstrap Icons (로컬) -->
|
||||
<link th:href="@{/css/bootstrap-icons.css}" href="/css/bootstrap-icons.css" rel="stylesheet">
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--primary-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
--card-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--primary-gradient);
|
||||
min-height: 100vh;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
padding: 30px;
|
||||
margin-bottom: 30px;
|
||||
box-shadow: var(--card-shadow);
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
color: #333;
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.content-card {
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
padding: 30px;
|
||||
box-shadow: var(--card-shadow);
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
|
||||
.filter-section {
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
|
||||
.filter-section label {
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.table-responsive {
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.table {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.table thead {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.table thead th {
|
||||
border: none;
|
||||
font-weight: 600;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.table tbody tr {
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.table tbody tr:hover {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.table tbody td {
|
||||
padding: 15px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.duration-text {
|
||||
color: #718096;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.empty-state i {
|
||||
font-size: 48px;
|
||||
margin-bottom: 15px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container-fluid" style="max-width: 1400px;">
|
||||
<!-- Header -->
|
||||
<div class="page-header d-flex justify-content-between align-items-center">
|
||||
<h1><i class="bi bi-clock-history"></i> 작업 실행 이력</h1>
|
||||
<a th:href="@{/}" href="/" class="btn btn-primary">
|
||||
<i class="bi bi-house-door"></i> 대시보드로 돌아가기
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="content-card">
|
||||
<!-- Filter Section -->
|
||||
<div class="filter-section">
|
||||
<label for="jobFilter"><i class="bi bi-funnel"></i> 작업으로 필터링</label>
|
||||
<select id="jobFilter" class="form-select" onchange="loadExecutions()">
|
||||
<option value="">작업 로딩 중...</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Executions Table -->
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover" id="executionTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>실행 ID</th>
|
||||
<th>작업명</th>
|
||||
<th>상태</th>
|
||||
<th>시작 시간</th>
|
||||
<th>종료 시간</th>
|
||||
<th>소요 시간</th>
|
||||
<th>액션</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="executionTableBody">
|
||||
<tr>
|
||||
<td colspan="7">
|
||||
<div class="loading-state">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
<div class="mt-2">실행 이력 로딩 중...</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bootstrap 5 JS Bundle (로컬) -->
|
||||
<script th:src="@{/js/bootstrap.bundle.min.js}" src="/js/bootstrap.bundle.min.js"></script>
|
||||
|
||||
<script th:inline="javascript">
|
||||
// Context path for API calls
|
||||
const contextPath = /*[[@{/}]]*/ '/';
|
||||
let currentJobName = null;
|
||||
|
||||
// Load jobs for filter dropdown
|
||||
async function loadJobs() {
|
||||
try {
|
||||
const response = await fetch(contextPath + 'api/batch/jobs');
|
||||
const jobs = await response.json();
|
||||
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const preselectedJob = urlParams.get('job');
|
||||
|
||||
const select = document.getElementById('jobFilter');
|
||||
select.innerHTML = '<option value="">모든 작업</option>' +
|
||||
jobs.map(job => `<option value="${job}" ${job === preselectedJob ? 'selected' : ''}>${job}</option>`).join('');
|
||||
|
||||
if (preselectedJob) {
|
||||
currentJobName = preselectedJob;
|
||||
}
|
||||
|
||||
loadExecutions();
|
||||
} catch (error) {
|
||||
console.error('작업 로드 오류:', error);
|
||||
const select = document.getElementById('jobFilter');
|
||||
select.innerHTML = '<option value="">작업 로드 실패</option>';
|
||||
}
|
||||
}
|
||||
|
||||
// Load executions for selected job
|
||||
async function loadExecutions() {
|
||||
const jobFilter = document.getElementById('jobFilter').value;
|
||||
currentJobName = jobFilter || null;
|
||||
|
||||
const tbody = document.getElementById('executionTableBody');
|
||||
|
||||
if (!currentJobName) {
|
||||
tbody.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="7">
|
||||
<div class="empty-state">
|
||||
<i class="bi bi-inbox"></i>
|
||||
<div>실행 이력을 보려면 작업을 선택하세요</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="7">
|
||||
<div class="loading-state">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
<div class="mt-2">실행 이력 로딩 중...</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
|
||||
try {
|
||||
const response = await fetch(contextPath + `api/batch/jobs/${currentJobName}/executions`);
|
||||
const executions = await response.json();
|
||||
|
||||
if (executions.length === 0) {
|
||||
tbody.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="7">
|
||||
<div class="empty-state">
|
||||
<i class="bi bi-inbox"></i>
|
||||
<div>이 작업의 실행 이력이 없습니다</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = executions.map(execution => {
|
||||
const duration = calculateDuration(execution.startTime, execution.endTime);
|
||||
const statusBadge = getStatusBadge(execution.status);
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td><strong>${execution.executionId}</strong></td>
|
||||
<td>${execution.jobName}</td>
|
||||
<td>${statusBadge}</td>
|
||||
<td>${formatDateTime(execution.startTime)}</td>
|
||||
<td>${formatDateTime(execution.endTime)}</td>
|
||||
<td><span class="duration-text">${duration}</span></td>
|
||||
<td>
|
||||
${execution.status === 'STARTED' || execution.status === 'STARTING' ?
|
||||
`<button class="btn btn-sm btn-danger" onclick="stopExecution(${execution.executionId})">
|
||||
<i class="bi bi-stop-circle"></i> 중지
|
||||
</button>` :
|
||||
`<button class="btn btn-sm btn-info" onclick="viewDetails(${execution.executionId})">
|
||||
<i class="bi bi-info-circle"></i> 상세
|
||||
</button>`
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
} catch (error) {
|
||||
tbody.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="7">
|
||||
<div class="empty-state">
|
||||
<i class="bi bi-exclamation-circle text-danger"></i>
|
||||
<div>실행 이력 로드 오류: ${error.message}</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// Get status badge HTML
|
||||
function getStatusBadge(status) {
|
||||
const statusMap = {
|
||||
'COMPLETED': { class: 'bg-success', icon: 'check-circle', text: '완료' },
|
||||
'FAILED': { class: 'bg-danger', icon: 'x-circle', text: '실패' },
|
||||
'STARTED': { class: 'bg-primary', icon: 'arrow-repeat', text: '실행중' },
|
||||
'STARTING': { class: 'bg-info', icon: 'hourglass-split', text: '시작중' },
|
||||
'STOPPED': { class: 'bg-warning', icon: 'stop-circle', text: '중지됨' },
|
||||
'STOPPING': { class: 'bg-warning', icon: 'stop-circle', text: '중지중' },
|
||||
'UNKNOWN': { class: 'bg-secondary', icon: 'question-circle', text: '알수없음' }
|
||||
};
|
||||
|
||||
const badge = statusMap[status] || statusMap['UNKNOWN'];
|
||||
return `<span class="badge ${badge.class}"><i class="bi bi-${badge.icon}"></i> ${badge.text}</span>`;
|
||||
}
|
||||
|
||||
// Format datetime
|
||||
function formatDateTime(dateTime) {
|
||||
if (!dateTime) return '<span class="text-muted">-</span>';
|
||||
|
||||
try {
|
||||
const date = new Date(dateTime);
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(date.getSeconds()).padStart(2, '0');
|
||||
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||
} catch (error) {
|
||||
return dateTime;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate duration between start and end time
|
||||
function calculateDuration(startTime, endTime) {
|
||||
if (!startTime) return '없음';
|
||||
if (!endTime) return '<span class="badge bg-primary">실행 중...</span>';
|
||||
|
||||
const start = new Date(startTime);
|
||||
const end = new Date(endTime);
|
||||
const diff = end - start;
|
||||
|
||||
const seconds = Math.floor(diff / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}시간 ${minutes % 60}분 ${seconds % 60}초`;
|
||||
} else if (minutes > 0) {
|
||||
return `${minutes}분 ${seconds % 60}초`;
|
||||
} else {
|
||||
return `${seconds}초`;
|
||||
}
|
||||
}
|
||||
|
||||
// Stop execution
|
||||
async function stopExecution(executionId) {
|
||||
if (!confirm(`실행을 중지하시겠습니까?\n실행 ID: ${executionId}`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(contextPath + `api/batch/executions/${executionId}/stop`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
alert('실행 중지 요청이 완료되었습니다');
|
||||
setTimeout(() => loadExecutions(), 1000);
|
||||
} else {
|
||||
alert('실행 중지 실패: ' + result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
alert('실행 중지 오류: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// View execution details
|
||||
function viewDetails(executionId) {
|
||||
window.location.href = contextPath + `executions/${executionId}`;
|
||||
}
|
||||
|
||||
// Initialize on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadJobs();
|
||||
|
||||
// Auto-refresh every 5 seconds if viewing executions
|
||||
setInterval(() => {
|
||||
if (currentJobName) {
|
||||
loadExecutions();
|
||||
}
|
||||
}, 5000);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,593 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>S&P 배치 관리 시스템</title>
|
||||
|
||||
<!-- Bootstrap 5 CSS (로컬) -->
|
||||
<link th:href="@{/css/bootstrap.min.css}" href="/css/bootstrap.min.css" rel="stylesheet">
|
||||
<!-- Bootstrap Icons (로컬) -->
|
||||
<link th:href="@{/css/bootstrap-icons.css}" href="/css/bootstrap-icons.css" rel="stylesheet">
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--primary-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
--card-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
--card-shadow-hover: 0 8px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--primary-gradient);
|
||||
min-height: 100vh;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
padding: 30px;
|
||||
margin-bottom: 30px;
|
||||
box-shadow: var(--card-shadow);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dashboard-header h1 {
|
||||
color: #333;
|
||||
margin-bottom: 10px;
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
padding-right: 150px; /* 버튼 공간 확보 */
|
||||
}
|
||||
|
||||
.dashboard-header .subtitle {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.swagger-btn {
|
||||
position: absolute;
|
||||
top: 30px;
|
||||
right: 30px;
|
||||
background: linear-gradient(135deg, #85ce36 0%, #5fa529 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
transition: all 0.3s;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.swagger-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
|
||||
background: linear-gradient(135deg, #5fa529 0%, #85ce36 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.swagger-btn i {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
/* 반응형: 모바일 환경 */
|
||||
@media (max-width: 768px) {
|
||||
.dashboard-header h1 {
|
||||
font-size: 22px;
|
||||
padding-right: 0;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.swagger-btn {
|
||||
position: static;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.dashboard-header .subtitle {
|
||||
margin-top: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.section-card {
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
padding: 25px;
|
||||
margin-bottom: 25px;
|
||||
box-shadow: var(--card-shadow);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
transition: all 0.3s;
|
||||
cursor: pointer;
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: var(--card-shadow-hover);
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.stat-card .icon {
|
||||
font-size: 36px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.stat-card .value {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #667eea;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.stat-card .label {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.job-item {
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.job-item:hover {
|
||||
background: #e9ecef;
|
||||
transform: translateX(5px);
|
||||
}
|
||||
|
||||
.job-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.job-icon {
|
||||
font-size: 24px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.job-details h5 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.job-details p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.execution-item {
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
transition: all 0.3s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.execution-item:hover {
|
||||
background: #e9ecef;
|
||||
}
|
||||
|
||||
.execution-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.execution-info .job-name {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.execution-info .execution-meta {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.empty-state i {
|
||||
font-size: 48px;
|
||||
margin-bottom: 15px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.spinner-container {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.view-all-link {
|
||||
text-align: center;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.view-all-link a {
|
||||
color: #667eea;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.view-all-link a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<!-- Header -->
|
||||
<div class="dashboard-header">
|
||||
<a th:href="@{/swagger-ui/index.html}" href="/swagger-ui/index.html" target="_blank" class="swagger-btn" title="Swagger API 문서 열기">
|
||||
<i class="bi bi-file-earmark-code"></i>
|
||||
<span>API 문서</span>
|
||||
</a>
|
||||
<h1><i class="bi bi-grid-3x3-gap-fill"></i> S&P 배치 관리 시스템</h1>
|
||||
<p class="subtitle">S&P Global Web API 데이터를 PostgreSQL에 통합하는 배치 모니터링 페이지</p>
|
||||
</div>
|
||||
|
||||
<!-- Schedule Status Overview -->
|
||||
<div class="section-card">
|
||||
<div class="section-title">
|
||||
<i class="bi bi-clock-history"></i>
|
||||
스케줄 현황
|
||||
<a th:href="@{/schedule-timeline}" href="/schedule-timeline" class="btn btn-warning btn-sm ms-auto">
|
||||
<i class="bi bi-calendar3"></i> 스케줄 타임라인
|
||||
</a>
|
||||
</div>
|
||||
<div class="row g-3" id="scheduleStats">
|
||||
<div class="col-md-3 col-sm-6">
|
||||
<div class="stat-card" onclick="navigateTo('schedules')">
|
||||
<div class="icon"><i class="bi bi-calendar-check text-primary"></i></div>
|
||||
<div class="value" id="totalSchedules">-</div>
|
||||
<div class="label">전체 스케줄</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 col-sm-6">
|
||||
<div class="stat-card" onclick="navigateTo('schedules')">
|
||||
<div class="icon"><i class="bi bi-play-circle text-success"></i></div>
|
||||
<div class="value" id="activeSchedules">-</div>
|
||||
<div class="label">활성 스케줄</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 col-sm-6">
|
||||
<div class="stat-card" onclick="navigateTo('schedules')">
|
||||
<div class="icon"><i class="bi bi-pause-circle text-warning"></i></div>
|
||||
<div class="value" id="inactiveSchedules">-</div>
|
||||
<div class="label">비활성 스케줄</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 col-sm-6">
|
||||
<div class="stat-card" onclick="navigateTo('jobs')">
|
||||
<div class="icon"><i class="bi bi-file-earmark-code text-info"></i></div>
|
||||
<div class="value" id="totalJobs">-</div>
|
||||
<div class="label">등록된 Job</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Currently Running Jobs -->
|
||||
<div class="section-card">
|
||||
<div class="section-title">
|
||||
<i class="bi bi-arrow-repeat"></i>
|
||||
현재 진행 중인 Job
|
||||
<span class="badge bg-primary ms-auto" id="runningCount">0</span>
|
||||
</div>
|
||||
<div id="runningJobs">
|
||||
<div class="spinner-container">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Execution History -->
|
||||
<div class="section-card">
|
||||
<div class="section-title">
|
||||
<i class="bi bi-list-check"></i>
|
||||
최근 실행 이력
|
||||
</div>
|
||||
<div id="recentExecutions">
|
||||
<div class="spinner-container">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="view-all-link">
|
||||
<a th:href="@{/executions}" href="/executions">전체 실행 이력 보기 <i class="bi bi-arrow-right"></i></a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="section-card">
|
||||
<div class="section-title">
|
||||
<i class="bi bi-lightning-charge"></i>
|
||||
빠른 작업
|
||||
</div>
|
||||
<div class="d-flex gap-3 flex-wrap">
|
||||
<button class="btn btn-primary" onclick="showExecuteJobModal()">
|
||||
<i class="bi bi-play-fill"></i> 작업 즉시 실행
|
||||
</button>
|
||||
<a th:href="@{/jobs}" href="/jobs" class="btn btn-info">
|
||||
<i class="bi bi-list-ul"></i> 모든 작업 보기
|
||||
</a>
|
||||
<a th:href="@{/schedules}" href="/schedules" class="btn btn-success">
|
||||
<i class="bi bi-calendar-plus"></i> 스케줄 관리
|
||||
</a>
|
||||
<a th:href="@{/executions}" href="/executions" class="btn btn-secondary">
|
||||
<i class="bi bi-clock-history"></i> 실행 이력
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Job Execution Modal -->
|
||||
<div class="modal fade" id="executeJobModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">작업 즉시 실행</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label for="jobSelect" class="form-label">실행할 작업 선택</label>
|
||||
<select class="form-select" id="jobSelect">
|
||||
<option value="">작업을 선택하세요...</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">취소</button>
|
||||
<button type="button" class="btn btn-primary" onclick="executeJob()">실행</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bootstrap 5 JS Bundle (로컬) -->
|
||||
<script th:src="@{/js/bootstrap.bundle.min.js}" src="/js/bootstrap.bundle.min.js"></script>
|
||||
|
||||
<script th:inline="javascript">
|
||||
let executeModal;
|
||||
// Context path for API calls
|
||||
const contextPath = /*[[@{/}]]*/ '/';
|
||||
|
||||
// Navigate to a page with context path
|
||||
function navigateTo(path) {
|
||||
location.href = contextPath + path;
|
||||
}
|
||||
|
||||
// Initialize on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
executeModal = new bootstrap.Modal(document.getElementById('executeJobModal'));
|
||||
loadDashboardData();
|
||||
|
||||
// Auto-refresh dashboard every 5 seconds
|
||||
setInterval(loadDashboardData, 5000);
|
||||
});
|
||||
|
||||
// Load all dashboard data (single API call)
|
||||
async function loadDashboardData() {
|
||||
try {
|
||||
const response = await fetch(contextPath + 'api/batch/dashboard');
|
||||
const data = await response.json();
|
||||
|
||||
// Update stats
|
||||
document.getElementById('totalSchedules').textContent = data.stats.totalSchedules;
|
||||
document.getElementById('activeSchedules').textContent = data.stats.activeSchedules;
|
||||
document.getElementById('inactiveSchedules').textContent = data.stats.inactiveSchedules;
|
||||
document.getElementById('totalJobs').textContent = data.stats.totalJobs;
|
||||
|
||||
// Update running jobs
|
||||
document.getElementById('runningCount').textContent = data.runningJobs.length;
|
||||
|
||||
const runningContainer = document.getElementById('runningJobs');
|
||||
if (data.runningJobs.length === 0) {
|
||||
runningContainer.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<i class="bi bi-inbox"></i>
|
||||
<div>현재 진행 중인 작업이 없습니다</div>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
runningContainer.innerHTML = data.runningJobs.map(job => `
|
||||
<div class="job-item">
|
||||
<div class="job-info">
|
||||
<div class="job-icon">
|
||||
<i class="bi bi-arrow-repeat text-primary"></i>
|
||||
</div>
|
||||
<div class="job-details">
|
||||
<h5>${job.jobName}</h5>
|
||||
<p>실행 ID: ${job.executionId} | 시작: ${formatDateTime(job.startTime)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span class="badge bg-primary">
|
||||
<i class="bi bi-arrow-repeat"></i> ${job.status}
|
||||
</span>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// Update recent executions
|
||||
const recentContainer = document.getElementById('recentExecutions');
|
||||
if (data.recentExecutions.length === 0) {
|
||||
recentContainer.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<i class="bi bi-inbox"></i>
|
||||
<div>실행 이력이 없습니다</div>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
recentContainer.innerHTML = data.recentExecutions.map(exec => `
|
||||
<div class="execution-item" onclick="location.href='${contextPath}executions/${exec.executionId}'">
|
||||
<div class="execution-info">
|
||||
<div class="job-name">${exec.jobName}</div>
|
||||
<div class="execution-meta">
|
||||
ID: ${exec.executionId} | 시작: ${formatDateTime(exec.startTime)}
|
||||
${exec.endTime ? ` | 종료: ${formatDateTime(exec.endTime)}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
${getStatusBadge(exec.status)}
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('대시보드 데이터 로드 오류:', error);
|
||||
// Show error state for all sections
|
||||
document.getElementById('totalSchedules').textContent = '0';
|
||||
document.getElementById('activeSchedules').textContent = '0';
|
||||
document.getElementById('inactiveSchedules').textContent = '0';
|
||||
document.getElementById('totalJobs').textContent = '0';
|
||||
|
||||
document.getElementById('runningJobs').innerHTML = `
|
||||
<div class="empty-state">
|
||||
<i class="bi bi-exclamation-circle"></i>
|
||||
<div>데이터를 불러올 수 없습니다</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById('recentExecutions').innerHTML = `
|
||||
<div class="empty-state">
|
||||
<i class="bi bi-exclamation-circle"></i>
|
||||
<div>데이터를 불러올 수 없습니다</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// Show execute job modal
|
||||
async function showExecuteJobModal() {
|
||||
try {
|
||||
const response = await fetch(contextPath + 'api/batch/jobs');
|
||||
const jobs = await response.json();
|
||||
|
||||
const select = document.getElementById('jobSelect');
|
||||
select.innerHTML = '<option value="">작업을 선택하세요...</option>';
|
||||
|
||||
jobs.forEach(job => {
|
||||
const option = document.createElement('option');
|
||||
option.value = job;
|
||||
option.textContent = job;
|
||||
select.appendChild(option);
|
||||
});
|
||||
|
||||
executeModal.show();
|
||||
} catch (error) {
|
||||
alert('작업 목록을 불러올 수 없습니다: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Execute selected job
|
||||
async function executeJob() {
|
||||
const jobName = document.getElementById('jobSelect').value;
|
||||
|
||||
if (!jobName) {
|
||||
alert('실행할 작업을 선택하세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${contextPath}api/batch/jobs/${jobName}/execute`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
executeModal.hide();
|
||||
|
||||
if (result.success) {
|
||||
alert(`작업이 성공적으로 시작되었습니다!\n실행 ID: ${result.executionId}`);
|
||||
// Reload dashboard data after 1 second
|
||||
setTimeout(loadDashboardData, 1000);
|
||||
} else {
|
||||
alert('작업 시작 실패: ' + result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
alert('작업 실행 오류: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Utility: Get status badge HTML
|
||||
function getStatusBadge(status) {
|
||||
const statusMap = {
|
||||
'COMPLETED': { class: 'bg-success', icon: 'check-circle', text: '완료' },
|
||||
'FAILED': { class: 'bg-danger', icon: 'x-circle', text: '실패' },
|
||||
'STARTED': { class: 'bg-primary', icon: 'arrow-repeat', text: '실행중' },
|
||||
'STARTING': { class: 'bg-info', icon: 'hourglass-split', text: '시작중' },
|
||||
'STOPPED': { class: 'bg-warning', icon: 'stop-circle', text: '중지됨' },
|
||||
'UNKNOWN': { class: 'bg-secondary', icon: 'question-circle', text: '알수없음' }
|
||||
};
|
||||
|
||||
const badge = statusMap[status] || statusMap['UNKNOWN'];
|
||||
return `<span class="badge ${badge.class}"><i class="bi bi-${badge.icon}"></i> ${badge.text}</span>`;
|
||||
}
|
||||
|
||||
// Utility: Format datetime
|
||||
function formatDateTime(dateTimeStr) {
|
||||
if (!dateTimeStr) return '-';
|
||||
|
||||
try {
|
||||
const date = new Date(dateTimeStr);
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(date.getSeconds()).padStart(2, '0');
|
||||
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||
} catch (error) {
|
||||
return dateTimeStr;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,298 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>배치 작업 - SNP 배치</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
padding: 30px;
|
||||
margin-bottom: 30px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
padding: 10px 20px;
|
||||
background: #667eea;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
background: #5568d3;
|
||||
}
|
||||
|
||||
.content {
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
padding: 30px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.job-list {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.job-item {
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.job-item:hover {
|
||||
border-color: #667eea;
|
||||
background: #f7fafc;
|
||||
}
|
||||
|
||||
.job-info h3 {
|
||||
color: #333;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.job-info p {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.job-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.btn-execute {
|
||||
background: #48bb78;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-execute:hover {
|
||||
background: #38a169;
|
||||
}
|
||||
|
||||
.btn-view {
|
||||
background: #4299e1;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-view:hover {
|
||||
background: #3182ce;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 60px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.status-active {
|
||||
background: #c6f6d5;
|
||||
color: #22543d;
|
||||
}
|
||||
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: white;
|
||||
margin: 10% auto;
|
||||
padding: 30px;
|
||||
border-radius: 10px;
|
||||
max-width: 500px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.close-modal {
|
||||
position: absolute;
|
||||
top: 15px;
|
||||
right: 20px;
|
||||
font-size: 28px;
|
||||
cursor: pointer;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.close-modal:hover {
|
||||
color: #333;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>배치 작업</h1>
|
||||
<a th:href="@{/}" href="/" class="back-btn">← 대시보드로 돌아가기</a>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<div id="jobList" class="job-list">
|
||||
<div class="loading">작업 로딩 중...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="resultModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<span class="close-modal" onclick="closeModal()">×</span>
|
||||
<h2 id="modalTitle">결과</h2>
|
||||
<p id="modalMessage"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script th:inline="javascript">
|
||||
// Context path for API calls
|
||||
const contextPath = /*[[@{/}]]*/ '/';
|
||||
|
||||
async function loadJobs() {
|
||||
try {
|
||||
const response = await fetch(contextPath + 'api/batch/jobs');
|
||||
const jobs = await response.json();
|
||||
|
||||
const jobListDiv = document.getElementById('jobList');
|
||||
|
||||
if (jobs.length === 0) {
|
||||
jobListDiv.innerHTML = '<div class="empty">작업을 찾을 수 없습니다</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
jobListDiv.innerHTML = jobs.map(job => `
|
||||
<div class="job-item">
|
||||
<div class="job-info">
|
||||
<h3>
|
||||
${job}
|
||||
<span class="status-badge status-active">활성</span>
|
||||
</h3>
|
||||
<p>JSON 데이터를 PostgreSQL로 통합하는 배치 작업</p>
|
||||
</div>
|
||||
<div class="job-actions">
|
||||
<button class="btn btn-execute" onclick="executeJob('${job}')">
|
||||
실행
|
||||
</button>
|
||||
<button class="btn btn-view" onclick="viewExecutions('${job}')">
|
||||
이력 보기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
} catch (error) {
|
||||
document.getElementById('jobList').innerHTML =
|
||||
'<div class="empty">작업 로드 오류: ' + error.message + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
async function executeJob(jobName) {
|
||||
if (!confirm(`작업을 실행하시겠습니까: ${jobName}?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(contextPath + `api/batch/jobs/${jobName}/execute`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
showModal('성공', `작업이 성공적으로 시작되었습니다!\n실행 ID: ${result.executionId}`);
|
||||
} else {
|
||||
showModal('오류', '작업 시작 실패: ' + result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
showModal('오류', '작업 실행 오류: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function viewExecutions(jobName) {
|
||||
window.location.href = contextPath + `executions?job=${jobName}`;
|
||||
}
|
||||
|
||||
function showModal(title, message) {
|
||||
document.getElementById('modalTitle').textContent = title;
|
||||
document.getElementById('modalMessage').textContent = message;
|
||||
document.getElementById('resultModal').style.display = 'block';
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById('resultModal').style.display = 'none';
|
||||
}
|
||||
|
||||
// Close modal when clicking outside
|
||||
window.onclick = function(event) {
|
||||
const modal = document.getElementById('resultModal');
|
||||
if (event.target === modal) {
|
||||
closeModal();
|
||||
}
|
||||
}
|
||||
|
||||
// Load jobs on page load
|
||||
loadJobs();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,832 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>스케줄 타임라인 - SNP 배치</title>
|
||||
|
||||
<!-- Bootstrap 5 CSS (로컬) -->
|
||||
<link th:href="@{/css/bootstrap.min.css}" href="/css/bootstrap.min.css" rel="stylesheet">
|
||||
<!-- Bootstrap Icons (로컬) -->
|
||||
<link th:href="@{/css/bootstrap-icons.css}" href="/css/bootstrap-icons.css" rel="stylesheet">
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--primary-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
--card-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
--success-color: #10b981;
|
||||
--error-color: #ef4444;
|
||||
--running-color: #3b82f6;
|
||||
--scheduled-color: #8b5cf6;
|
||||
--stopped-color: #6b7280;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--primary-gradient);
|
||||
min-height: 100vh;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
padding: 30px;
|
||||
margin-bottom: 30px;
|
||||
box-shadow: var(--card-shadow);
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
color: #333;
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.content-card {
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
padding: 30px;
|
||||
box-shadow: var(--card-shadow);
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
|
||||
.view-controls {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.view-btn {
|
||||
padding: 8px 16px;
|
||||
border: 2px solid #e5e7eb;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.view-btn.active {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.timeline-container {
|
||||
overflow-x: auto;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.timeline-grid {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.timeline-header {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
margin-bottom: 10px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: white;
|
||||
z-index: 10;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.timeline-header-cell {
|
||||
text-align: center;
|
||||
padding: 10px 5px;
|
||||
background: #f3f4f6;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.timeline-header-label {
|
||||
text-align: left;
|
||||
padding: 10px;
|
||||
background: #f3f4f6;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 15;
|
||||
}
|
||||
|
||||
.timeline-row {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
margin-bottom: 15px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.timeline-job-label {
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
padding: 10px;
|
||||
background: #f9fafb;
|
||||
border-radius: 6px;
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.timeline-cell {
|
||||
height: 50px;
|
||||
border-radius: 6px;
|
||||
border: 2px solid #e5e7eb;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.timeline-cell:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.timeline-cell.completed {
|
||||
background: var(--success-color);
|
||||
border-color: var(--success-color);
|
||||
}
|
||||
|
||||
.timeline-cell.failed {
|
||||
background: var(--error-color);
|
||||
border-color: var(--error-color);
|
||||
}
|
||||
|
||||
.timeline-cell.running {
|
||||
background: var(--running-color);
|
||||
border-color: var(--running-color);
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
.timeline-cell.scheduled {
|
||||
background: var(--scheduled-color);
|
||||
border-color: var(--scheduled-color);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.timeline-cell.stopped {
|
||||
background: var(--stopped-color);
|
||||
border-color: var(--stopped-color);
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
color: white;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
padding: 15px;
|
||||
background: #f9fafb;
|
||||
border-radius: 8px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.legend-box {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.legend-box.completed {
|
||||
background: var(--success-color);
|
||||
border: 2px solid var(--success-color);
|
||||
}
|
||||
|
||||
.legend-box.failed {
|
||||
background: var(--error-color);
|
||||
border: 2px solid var(--error-color);
|
||||
}
|
||||
|
||||
.legend-box.running {
|
||||
background: var(--running-color);
|
||||
border: 2px solid var(--running-color);
|
||||
}
|
||||
|
||||
.legend-box.scheduled {
|
||||
background: var(--scheduled-color);
|
||||
border: 2px solid var(--scheduled-color);
|
||||
}
|
||||
|
||||
.legend-box.stopped {
|
||||
background: var(--stopped-color);
|
||||
border: 2px solid var(--stopped-color);
|
||||
}
|
||||
|
||||
/* Tooltip */
|
||||
.custom-tooltip {
|
||||
position: absolute;
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
color: white;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
display: none;
|
||||
}
|
||||
|
||||
.custom-tooltip.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.tooltip-row {
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.tooltip-label {
|
||||
font-weight: 600;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
/* Period Executions Panel */
|
||||
.period-executions-panel {
|
||||
margin-top: 30px;
|
||||
padding: 20px;
|
||||
background: #f9fafb;
|
||||
border-radius: 8px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.period-executions-panel.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 15px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 2px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.executions-table {
|
||||
width: 100%;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.executions-table table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.executions-table th {
|
||||
background: #f3f4f6;
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
font-size: 14px;
|
||||
border-bottom: 2px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.executions-table td {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
color: #4b5563;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.executions-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.executions-table tbody tr:hover {
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-badge.completed {
|
||||
background: var(--success-color);
|
||||
}
|
||||
|
||||
.status-badge.failed {
|
||||
background: var(--error-color);
|
||||
}
|
||||
|
||||
.status-badge.running {
|
||||
background: var(--running-color);
|
||||
}
|
||||
|
||||
.status-badge.stopped {
|
||||
background: var(--stopped-color);
|
||||
}
|
||||
|
||||
.timeline-cell.selected {
|
||||
box-shadow: 0 0 0 3px #fbbf24;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container-fluid">
|
||||
<!-- Header -->
|
||||
<div class="page-header d-flex justify-content-between align-items-center">
|
||||
<h1><i class="bi bi-calendar3"></i> 스케줄 타임라인</h1>
|
||||
<div>
|
||||
<a th:href="@{/schedules}" href="/schedules" class="btn btn-outline-primary me-2">
|
||||
<i class="bi bi-calendar-check"></i> 스케줄 관리
|
||||
</a>
|
||||
<a th:href="@{/}" href="/" class="btn btn-primary">
|
||||
<i class="bi bi-house-door"></i> 대시보드
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Timeline View -->
|
||||
<div class="content-card">
|
||||
<div class="view-controls">
|
||||
<button class="view-btn active" data-view="day" onclick="changeView('day')">
|
||||
<i class="bi bi-calendar-day"></i> 일별
|
||||
</button>
|
||||
<button class="view-btn" data-view="week" onclick="changeView('week')">
|
||||
<i class="bi bi-calendar-week"></i> 주별
|
||||
</button>
|
||||
<button class="view-btn" data-view="month" onclick="changeView('month')">
|
||||
<i class="bi bi-calendar-month"></i> 월별
|
||||
</button>
|
||||
|
||||
<div style="margin-left: auto; display: flex; gap: 10px; align-items: center;">
|
||||
<button class="btn btn-outline-secondary btn-sm" onclick="navigatePeriod(-1)">
|
||||
<i class="bi bi-chevron-left"></i> 이전
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" onclick="navigatePeriod(0)">
|
||||
<i class="bi bi-calendar-today"></i> 오늘
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" onclick="navigatePeriod(1)">
|
||||
다음 <i class="bi bi-chevron-right"></i>
|
||||
</button>
|
||||
<button class="btn btn-primary btn-sm" onclick="loadTimeline()">
|
||||
<i class="bi bi-arrow-clockwise"></i> 새로고침
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="periodInfo" class="mb-3 text-center" style="font-weight: 600; font-size: 16px; color: #374151;"></div>
|
||||
|
||||
<div class="timeline-container">
|
||||
<div id="timelineGrid">
|
||||
<div class="text-center py-5">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
<div class="mt-2">타임라인 로딩 중...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Legend -->
|
||||
<div class="legend">
|
||||
<div class="legend-item">
|
||||
<div class="legend-box completed"><i class="bi bi-check-lg status-icon"></i></div>
|
||||
<span>완료</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<div class="legend-box failed"><i class="bi bi-x-lg status-icon"></i></div>
|
||||
<span>실패</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<div class="legend-box running"><i class="bi bi-arrow-clockwise status-icon"></i></div>
|
||||
<span>실행중</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<div class="legend-box scheduled"><i class="bi bi-clock status-icon"></i></div>
|
||||
<span>예정</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<div class="legend-box stopped"><i class="bi bi-pause-circle status-icon"></i></div>
|
||||
<span>중지됨</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Period Executions Panel -->
|
||||
<div id="periodExecutionsPanel" class="content-card period-executions-panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<div class="panel-title" id="panelTitle">구간 실행 이력</div>
|
||||
<div style="font-size: 14px; color: #6b7280; margin-top: 5px;" id="panelSubtitle"></div>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="closePeriodPanel()">
|
||||
<i class="bi bi-x-lg"></i> 닫기
|
||||
</button>
|
||||
</div>
|
||||
<div id="executionsContent">
|
||||
<div class="text-center py-4">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Tooltip -->
|
||||
<div id="customTooltip" class="custom-tooltip"></div>
|
||||
|
||||
<!-- Bootstrap 5 JS Bundle (로컬) -->
|
||||
<script th:src="@{/js/bootstrap.bundle.min.js}" src="/js/bootstrap.bundle.min.js"></script>
|
||||
|
||||
<script th:inline="javascript">
|
||||
// Context path for API calls
|
||||
const contextPath = /*[[@{/}]]*/ '/';
|
||||
|
||||
let currentView = 'day';
|
||||
let currentDate = new Date();
|
||||
|
||||
// Change view type
|
||||
function changeView(view) {
|
||||
currentView = view;
|
||||
document.querySelectorAll('.view-btn').forEach(btn => {
|
||||
btn.classList.remove('active');
|
||||
});
|
||||
document.querySelector(`[data-view="${view}"]`).classList.add('active');
|
||||
loadTimeline();
|
||||
}
|
||||
|
||||
// Navigate period
|
||||
function navigatePeriod(direction) {
|
||||
if (direction === 0) {
|
||||
currentDate = new Date();
|
||||
} else if (currentView === 'day') {
|
||||
currentDate.setDate(currentDate.getDate() + direction);
|
||||
} else if (currentView === 'week') {
|
||||
currentDate.setDate(currentDate.getDate() + (direction * 7));
|
||||
} else if (currentView === 'month') {
|
||||
currentDate.setMonth(currentDate.getMonth() + direction);
|
||||
}
|
||||
loadTimeline();
|
||||
}
|
||||
|
||||
// Load timeline data
|
||||
async function loadTimeline() {
|
||||
try {
|
||||
const response = await fetch(contextPath + `api/batch/timeline?view=${currentView}&date=${currentDate.toISOString()}`);
|
||||
const data = await response.json();
|
||||
|
||||
renderTimeline(data);
|
||||
} catch (error) {
|
||||
console.error('타임라인 로드 오류:', error);
|
||||
document.getElementById('timelineGrid').innerHTML = `
|
||||
<div class="text-center py-5 text-danger">
|
||||
<i class="bi bi-exclamation-circle" style="font-size: 48px;"></i>
|
||||
<div class="mt-2">타임라인 로드 실패: ${error.message}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// Render timeline
|
||||
function renderTimeline(data) {
|
||||
const grid = document.getElementById('timelineGrid');
|
||||
const periodInfo = document.getElementById('periodInfo');
|
||||
|
||||
if (!data.schedules || data.schedules.length === 0) {
|
||||
grid.innerHTML = `
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-inbox" style="font-size: 48px; color: #9ca3af;"></i>
|
||||
<div class="mt-2" style="color: #6b7280;">활성화된 스케줄이 없습니다</div>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Period info
|
||||
periodInfo.textContent = data.periodLabel || '';
|
||||
|
||||
// Calculate grid columns
|
||||
const columnCount = data.periods.length;
|
||||
const gridColumns = `200px repeat(${columnCount}, minmax(80px, 1fr))`;
|
||||
|
||||
// Build header
|
||||
let headerHTML = `<div class="timeline-header" style="grid-template-columns: ${gridColumns};">`;
|
||||
headerHTML += '<div class="timeline-header-label">작업명</div>';
|
||||
data.periods.forEach(period => {
|
||||
headerHTML += `<div class="timeline-header-cell">${period.label}</div>`;
|
||||
});
|
||||
headerHTML += '</div>';
|
||||
|
||||
// Build rows
|
||||
let rowsHTML = '';
|
||||
data.schedules.forEach(schedule => {
|
||||
rowsHTML += `<div class="timeline-row" style="grid-template-columns: ${gridColumns};">`;
|
||||
rowsHTML += `<div class="timeline-job-label">${schedule.jobName}</div>`;
|
||||
|
||||
data.periods.forEach(period => {
|
||||
const execution = schedule.executions[period.key];
|
||||
const statusClass = execution ? execution.status.toLowerCase() : '';
|
||||
const icon = getStatusIcon(execution?.status);
|
||||
|
||||
rowsHTML += `<div class="timeline-cell ${statusClass}"
|
||||
data-execution='${JSON.stringify(execution || {})}'
|
||||
data-period="${period.label}"
|
||||
data-period-key="${period.key}"
|
||||
data-job="${schedule.jobName}"
|
||||
onclick="loadPeriodExecutions('${schedule.jobName}', '${period.key}', '${period.label}')"
|
||||
onmouseenter="showTooltip(event)"
|
||||
onmouseleave="hideTooltip()">
|
||||
${icon}
|
||||
</div>`;
|
||||
});
|
||||
rowsHTML += '</div>';
|
||||
});
|
||||
|
||||
grid.innerHTML = headerHTML + rowsHTML;
|
||||
}
|
||||
|
||||
// Get status icon
|
||||
function getStatusIcon(status) {
|
||||
if (!status) return '';
|
||||
|
||||
const icons = {
|
||||
'COMPLETED': '<i class="bi bi-check-lg status-icon"></i>',
|
||||
'FAILED': '<i class="bi bi-x-lg status-icon"></i>',
|
||||
'RUNNING': '<i class="bi bi-arrow-clockwise status-icon"></i>',
|
||||
'SCHEDULED': '<i class="bi bi-clock status-icon"></i>',
|
||||
'STOPPED': '<i class="bi bi-pause-circle status-icon"></i>'
|
||||
};
|
||||
|
||||
return icons[status.toUpperCase()] || '';
|
||||
}
|
||||
|
||||
// Show tooltip
|
||||
function showTooltip(event) {
|
||||
const cell = event.currentTarget;
|
||||
const execution = JSON.parse(cell.dataset.execution);
|
||||
const period = cell.dataset.period;
|
||||
const jobName = cell.dataset.job;
|
||||
|
||||
const tooltip = document.getElementById('customTooltip');
|
||||
|
||||
if (!execution.status) {
|
||||
tooltip.innerHTML = `
|
||||
<div class="tooltip-row">
|
||||
<span class="tooltip-label">작업:</span>${jobName}
|
||||
</div>
|
||||
<div class="tooltip-row">
|
||||
<span class="tooltip-label">기간:</span>${period}
|
||||
</div>
|
||||
<div class="tooltip-row">
|
||||
<span class="tooltip-label">상태:</span>실행 이력 없음
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
tooltip.innerHTML = `
|
||||
<div class="tooltip-row">
|
||||
<span class="tooltip-label">작업:</span>${jobName}
|
||||
</div>
|
||||
<div class="tooltip-row">
|
||||
<span class="tooltip-label">기간:</span>${period}
|
||||
</div>
|
||||
<div class="tooltip-row">
|
||||
<span class="tooltip-label">상태:</span>${getStatusLabel(execution.status)}
|
||||
</div>
|
||||
${execution.startTime ? `<div class="tooltip-row">
|
||||
<span class="tooltip-label">시작:</span>${formatDateTime(execution.startTime)}
|
||||
</div>` : ''}
|
||||
${execution.endTime ? `<div class="tooltip-row">
|
||||
<span class="tooltip-label">종료:</span>${formatDateTime(execution.endTime)}
|
||||
</div>` : ''}
|
||||
${execution.executionId ? `<div class="tooltip-row">
|
||||
<span class="tooltip-label">실행 ID:</span>${execution.executionId}
|
||||
</div>` : ''}
|
||||
`;
|
||||
}
|
||||
|
||||
tooltip.classList.add('show');
|
||||
positionTooltip(event, tooltip);
|
||||
}
|
||||
|
||||
// Hide tooltip
|
||||
function hideTooltip() {
|
||||
document.getElementById('customTooltip').classList.remove('show');
|
||||
}
|
||||
|
||||
// Position tooltip
|
||||
function positionTooltip(event, tooltip) {
|
||||
const x = event.pageX + 15;
|
||||
const y = event.pageY + 15;
|
||||
|
||||
tooltip.style.left = x + 'px';
|
||||
tooltip.style.top = y + 'px';
|
||||
|
||||
// Adjust if tooltip goes off screen
|
||||
const rect = tooltip.getBoundingClientRect();
|
||||
if (rect.right > window.innerWidth) {
|
||||
tooltip.style.left = (event.pageX - rect.width - 15) + 'px';
|
||||
}
|
||||
if (rect.bottom > window.innerHeight) {
|
||||
tooltip.style.top = (event.pageY - rect.height - 15) + 'px';
|
||||
}
|
||||
}
|
||||
|
||||
// Get status label
|
||||
function getStatusLabel(status) {
|
||||
const labels = {
|
||||
'COMPLETED': '완료',
|
||||
'FAILED': '실패',
|
||||
'RUNNING': '실행중',
|
||||
'SCHEDULED': '예정',
|
||||
'STOPPED': '중지됨'
|
||||
};
|
||||
return labels[status.toUpperCase()] || status;
|
||||
}
|
||||
|
||||
// Format datetime
|
||||
function formatDateTime(dateTimeStr) {
|
||||
if (!dateTimeStr) return '-';
|
||||
|
||||
try {
|
||||
const date = new Date(dateTimeStr);
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
|
||||
return `${month}/${day} ${hours}:${minutes}`;
|
||||
} catch (error) {
|
||||
return dateTimeStr;
|
||||
}
|
||||
}
|
||||
|
||||
// Load period executions
|
||||
async function loadPeriodExecutions(jobName, periodKey, periodLabel) {
|
||||
// Remove previous selection
|
||||
document.querySelectorAll('.timeline-cell.selected').forEach(cell => {
|
||||
cell.classList.remove('selected');
|
||||
});
|
||||
|
||||
// Add selection to clicked cell
|
||||
event.target.closest('.timeline-cell').classList.add('selected');
|
||||
|
||||
const panel = document.getElementById('periodExecutionsPanel');
|
||||
const content = document.getElementById('executionsContent');
|
||||
const subtitle = document.getElementById('panelSubtitle');
|
||||
|
||||
// Show panel
|
||||
panel.classList.add('show');
|
||||
|
||||
// Update subtitle
|
||||
subtitle.textContent = `작업: ${jobName} | 기간: ${periodLabel}`;
|
||||
|
||||
// Show loading
|
||||
content.innerHTML = `
|
||||
<div class="text-center py-4">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
<div class="mt-2">실행 이력 조회 중...</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Scroll to panel
|
||||
setTimeout(() => {
|
||||
panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}, 100);
|
||||
|
||||
try {
|
||||
const response = await fetch(contextPath + `api/batch/timeline/period-executions?jobName=${encodeURIComponent(jobName)}&view=${currentView}&periodKey=${encodeURIComponent(periodKey)}`);
|
||||
const executions = await response.json();
|
||||
|
||||
renderPeriodExecutions(executions);
|
||||
} catch (error) {
|
||||
console.error('실행 이력 로드 오류:', error);
|
||||
content.innerHTML = `
|
||||
<div class="text-center py-4 text-danger">
|
||||
<i class="bi bi-exclamation-circle" style="font-size: 36px;"></i>
|
||||
<div class="mt-2">실행 이력 로드 실패: ${error.message}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// Render period executions
|
||||
function renderPeriodExecutions(executions) {
|
||||
const content = document.getElementById('executionsContent');
|
||||
|
||||
if (!executions || executions.length === 0) {
|
||||
content.innerHTML = `
|
||||
<div class="text-center py-4">
|
||||
<i class="bi bi-inbox" style="font-size: 36px; color: #9ca3af;"></i>
|
||||
<div class="mt-2" style="color: #6b7280;">해당 구간에 실행 이력이 없습니다</div>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
let tableHTML = `
|
||||
<div class="executions-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 100px;">실행 ID</th>
|
||||
<th style="width: 120px;">상태</th>
|
||||
<th>시작 시간</th>
|
||||
<th>종료 시간</th>
|
||||
<th style="width: 100px;">종료 코드</th>
|
||||
<th>종료 메시지</th>
|
||||
<th style="width: 100px;">작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
`;
|
||||
|
||||
executions.forEach(exec => {
|
||||
const statusBadge = `<span class="status-badge ${exec.status.toLowerCase()}">${getStatusLabel(exec.status)}</span>`;
|
||||
const startTime = formatDateTime(exec.startTime);
|
||||
const endTime = exec.endTime ? formatDateTime(exec.endTime) : '-';
|
||||
const exitMessage = exec.exitMessage || '-';
|
||||
|
||||
tableHTML += `
|
||||
<tr>
|
||||
<td><a href="${contextPath}executions/${exec.executionId}" class="text-primary" style="text-decoration: none; font-weight: 600;">#${exec.executionId}</a></td>
|
||||
<td>${statusBadge}</td>
|
||||
<td>${startTime}</td>
|
||||
<td>${endTime}</td>
|
||||
<td><code style="font-size: 12px;">${exec.exitCode}</code></td>
|
||||
<td style="max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${exitMessage}">${exitMessage}</td>
|
||||
<td>
|
||||
<a href="${contextPath}executions/${exec.executionId}" class="btn btn-sm btn-outline-primary" style="font-size: 12px;">
|
||||
<i class="bi bi-eye"></i> 상세
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
|
||||
tableHTML += `
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="mt-3 text-end">
|
||||
<small class="text-muted">총 ${executions.length}건의 실행 이력</small>
|
||||
</div>
|
||||
`;
|
||||
|
||||
content.innerHTML = tableHTML;
|
||||
}
|
||||
|
||||
// Close period panel
|
||||
function closePeriodPanel() {
|
||||
document.getElementById('periodExecutionsPanel').classList.remove('show');
|
||||
document.querySelectorAll('.timeline-cell.selected').forEach(cell => {
|
||||
cell.classList.remove('selected');
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadTimeline();
|
||||
// Auto refresh every 30 seconds
|
||||
setInterval(loadTimeline, 30000);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,528 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>작업 스케줄 - SNP 배치</title>
|
||||
|
||||
<!-- Bootstrap 5 CSS (로컬) -->
|
||||
<link th:href="@{/css/bootstrap.min.css}" href="/css/bootstrap.min.css" rel="stylesheet">
|
||||
<!-- Bootstrap Icons (로컬) -->
|
||||
<link th:href="@{/css/bootstrap-icons.css}" href="/css/bootstrap-icons.css" rel="stylesheet">
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--primary-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
--card-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--primary-gradient);
|
||||
min-height: 100vh;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
padding: 30px;
|
||||
margin-bottom: 30px;
|
||||
box-shadow: var(--card-shadow);
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
color: #333;
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.content-card {
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
padding: 30px;
|
||||
box-shadow: var(--card-shadow);
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
|
||||
.schedule-card {
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 15px;
|
||||
border: 2px solid #e9ecef;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.schedule-card:hover {
|
||||
border-color: #667eea;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.schedule-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.schedule-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.schedule-details {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
padding: 10px;
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-size: 12px;
|
||||
color: #718096;
|
||||
margin-bottom: 5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
font-size: 14px;
|
||||
color: #2d3748;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.add-schedule-section {
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
padding: 25px;
|
||||
border: 2px dashed #cbd5e0;
|
||||
}
|
||||
|
||||
.add-schedule-section h2 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.cron-helper {
|
||||
font-size: 12px;
|
||||
color: #718096;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.empty-state i {
|
||||
font-size: 48px;
|
||||
margin-bottom: 15px;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<!-- Header -->
|
||||
<div class="page-header d-flex justify-content-between align-items-center">
|
||||
<h1><i class="bi bi-calendar-check"></i> 작업 스케줄</h1>
|
||||
<a th:href="@{/}" href="/" class="btn btn-primary">
|
||||
<i class="bi bi-house-door"></i> 대시보드로 돌아가기
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Add/Edit Schedule Form -->
|
||||
<div class="content-card">
|
||||
<div class="add-schedule-section">
|
||||
<h2><i class="bi bi-plus-circle"></i> 스케줄 추가/수정</h2>
|
||||
<form id="scheduleForm">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label for="jobName" class="form-label">
|
||||
작업명
|
||||
<span id="scheduleStatus" class="badge bg-secondary ms-2" style="display: none;">새 스케줄</span>
|
||||
</label>
|
||||
<select id="jobName" class="form-select" required>
|
||||
<option value="">작업을 선택하세요...</option>
|
||||
</select>
|
||||
<div id="scheduleInfo" class="mt-2" style="display: none;">
|
||||
<div class="alert alert-info mb-0 py-2 px-3" role="alert">
|
||||
<i class="bi bi-info-circle"></i>
|
||||
<span id="scheduleInfoText"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="cronExpression" class="form-label">Cron 표현식</label>
|
||||
<input type="text" id="cronExpression" class="form-control" placeholder="0 0 * * * ?" required>
|
||||
<div class="cron-helper">
|
||||
예시: "0 0 * * * ?" (매 시간), "0 0 0 * * ?" (매일 자정)
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-12">
|
||||
<label for="description" class="form-label">설명</label>
|
||||
<textarea id="description" class="form-control" rows="2" placeholder="이 스케줄에 대한 설명을 입력하세요 (선택사항)"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-save"></i> 스케줄 저장
|
||||
</button>
|
||||
<button type="reset" class="btn btn-secondary">
|
||||
<i class="bi bi-x-circle"></i> 취소
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Schedule List -->
|
||||
<div class="content-card">
|
||||
<h2 class="mb-4" style="font-size: 20px; font-weight: 600; color: #333;">
|
||||
<i class="bi bi-list-check"></i> 활성 스케줄
|
||||
</h2>
|
||||
<div id="scheduleList">
|
||||
<div class="text-center py-4">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
<div class="mt-2">스케줄 로딩 중...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bootstrap 5 JS Bundle (로컬) -->
|
||||
<script th:src="@{/js/bootstrap.bundle.min.js}" src="/js/bootstrap.bundle.min.js"></script>
|
||||
|
||||
<script th:inline="javascript">
|
||||
// Context path for API calls
|
||||
const contextPath = /*[[@{/}]]*/ '/';
|
||||
|
||||
// Load jobs for dropdown
|
||||
async function loadJobs() {
|
||||
try {
|
||||
const response = await fetch(contextPath + 'api/batch/jobs');
|
||||
const jobs = await response.json();
|
||||
|
||||
const select = document.getElementById('jobName');
|
||||
select.innerHTML = '<option value="">작업을 선택하세요...</option>' +
|
||||
jobs.map(job => `<option value="${job}">${job}</option>`).join('');
|
||||
} catch (error) {
|
||||
console.error('작업 로드 오류:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Add event listener for job selection to detect existing schedules
|
||||
document.getElementById('jobName').addEventListener('change', async function(e) {
|
||||
const jobName = e.target.value;
|
||||
const scheduleStatus = document.getElementById('scheduleStatus');
|
||||
const scheduleInfo = document.getElementById('scheduleInfo');
|
||||
const scheduleInfoText = document.getElementById('scheduleInfoText');
|
||||
const cronInput = document.getElementById('cronExpression');
|
||||
const descInput = document.getElementById('description');
|
||||
|
||||
if (!jobName) {
|
||||
scheduleStatus.style.display = 'none';
|
||||
scheduleInfo.style.display = 'none';
|
||||
cronInput.value = '';
|
||||
descInput.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(contextPath + `api/batch/schedules/${jobName}`);
|
||||
|
||||
if (response.ok) {
|
||||
const schedule = await response.json();
|
||||
|
||||
// Existing schedule found
|
||||
cronInput.value = schedule.cronExpression || '';
|
||||
descInput.value = schedule.description || '';
|
||||
|
||||
scheduleStatus.textContent = '기존 스케줄';
|
||||
scheduleStatus.className = 'badge bg-warning ms-2';
|
||||
scheduleStatus.style.display = 'inline';
|
||||
|
||||
scheduleInfoText.textContent = '이 작업은 이미 스케줄이 등록되어 있습니다. 수정하시겠습니까?';
|
||||
scheduleInfo.style.display = 'block';
|
||||
} else {
|
||||
// New schedule
|
||||
cronInput.value = '';
|
||||
descInput.value = '';
|
||||
|
||||
scheduleStatus.textContent = '새 스케줄';
|
||||
scheduleStatus.className = 'badge bg-secondary ms-2';
|
||||
scheduleStatus.style.display = 'inline';
|
||||
|
||||
scheduleInfo.style.display = 'none';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('스케줄 조회 오류:', error);
|
||||
|
||||
// On error, treat as new schedule
|
||||
cronInput.value = '';
|
||||
descInput.value = '';
|
||||
scheduleStatus.textContent = '새 스케줄';
|
||||
scheduleStatus.className = 'badge bg-secondary ms-2';
|
||||
scheduleStatus.style.display = 'inline';
|
||||
scheduleInfo.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
// Load schedules
|
||||
async function loadSchedules() {
|
||||
try {
|
||||
const response = await fetch(contextPath + 'api/batch/schedules');
|
||||
const data = await response.json();
|
||||
const schedules = data.schedules || [];
|
||||
|
||||
const scheduleListDiv = document.getElementById('scheduleList');
|
||||
|
||||
if (schedules.length === 0) {
|
||||
scheduleListDiv.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<i class="bi bi-inbox"></i>
|
||||
<div>설정된 스케줄이 없습니다</div>
|
||||
<div class="mt-2 text-muted">위 양식을 사용하여 첫 스케줄을 추가하세요</div>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
scheduleListDiv.innerHTML = schedules.map(schedule => {
|
||||
const isActive = schedule.active;
|
||||
const statusText = isActive ? '활성' : '비활성';
|
||||
const statusClass = isActive ? 'success' : 'warning';
|
||||
const triggerState = schedule.triggerState || 'NONE';
|
||||
|
||||
return `
|
||||
<div class="schedule-card">
|
||||
<div class="schedule-header">
|
||||
<div class="schedule-title">
|
||||
<i class="bi bi-calendar-event text-primary"></i>
|
||||
${schedule.jobName}
|
||||
<span class="badge bg-${statusClass}">${statusText}</span>
|
||||
${triggerState !== 'NONE' ? `<span class="badge bg-${triggerState === 'NORMAL' ? 'success' : 'secondary'}">${triggerState}</span>` : ''}
|
||||
</div>
|
||||
<div class="btn-group" role="group">
|
||||
<button class="btn btn-sm ${isActive ? 'btn-warning' : 'btn-success'}"
|
||||
onclick="toggleSchedule('${schedule.jobName}', ${!isActive})">
|
||||
<i class="bi bi-${isActive ? 'pause' : 'play'}-circle"></i>
|
||||
${isActive ? '비활성화' : '활성화'}
|
||||
</button>
|
||||
<button class="btn btn-sm btn-danger" onclick="deleteSchedule('${schedule.jobName}')">
|
||||
<i class="bi bi-trash"></i> 삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="schedule-details">
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">Cron 표현식</div>
|
||||
<div class="detail-value">${schedule.cronExpression || '없음'}</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">설명</div>
|
||||
<div class="detail-value">${schedule.description || '-'}</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">다음 실행 시간</div>
|
||||
<div class="detail-value">
|
||||
${schedule.nextFireTime ? formatDateTime(schedule.nextFireTime) : '-'}
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">이전 실행 시간</div>
|
||||
<div class="detail-value">
|
||||
${schedule.previousFireTime ? formatDateTime(schedule.previousFireTime) : '-'}
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">생성 일시</div>
|
||||
<div class="detail-value">
|
||||
${schedule.createdAt ? formatDateTime(schedule.createdAt) : '-'}
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">수정 일시</div>
|
||||
<div class="detail-value">
|
||||
${schedule.updatedAt ? formatDateTime(schedule.updatedAt) : '-'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`}).join('');
|
||||
|
||||
} catch (error) {
|
||||
document.getElementById('scheduleList').innerHTML = `
|
||||
<div class="empty-state">
|
||||
<i class="bi bi-exclamation-circle text-danger"></i>
|
||||
<div>스케줄 로드 오류: ${error.message}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle form submission
|
||||
document.getElementById('scheduleForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const jobName = document.getElementById('jobName').value;
|
||||
const cronExpression = document.getElementById('cronExpression').value;
|
||||
const description = document.getElementById('description').value;
|
||||
|
||||
if (!jobName || !cronExpression) {
|
||||
alert('작업명과 Cron 표현식은 필수 입력 항목입니다');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if schedule already exists
|
||||
let method = 'POST';
|
||||
let url = contextPath + 'api/batch/schedules';
|
||||
let scheduleExists = false;
|
||||
|
||||
try {
|
||||
const checkResponse = await fetch(contextPath + `api/batch/schedules/${jobName}`);
|
||||
if (checkResponse.ok) {
|
||||
scheduleExists = true;
|
||||
}
|
||||
} catch (e) {
|
||||
// Schedule doesn't exist, continue with POST
|
||||
}
|
||||
|
||||
if (scheduleExists) {
|
||||
// Update: 기존 스케줄 수정
|
||||
const confirmUpdate = confirm(`'${jobName}' 스케줄이 이미 존재합니다.\n\nCron 표현식을 업데이트하시겠습니까?`);
|
||||
if (!confirmUpdate) {
|
||||
return;
|
||||
}
|
||||
method = 'POST';
|
||||
url = contextPath + `api/batch/schedules/${jobName}/update`;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(method === 'POST' ? {
|
||||
jobName: jobName,
|
||||
cronExpression: cronExpression,
|
||||
description: description || null
|
||||
} : {
|
||||
cronExpression: cronExpression,
|
||||
description: description || null
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
const action = scheduleExists ? '수정' : '추가';
|
||||
alert(`스케줄이 성공적으로 ${action}되었습니다!`);
|
||||
document.getElementById('scheduleForm').reset();
|
||||
await loadSchedules(); // await 추가하여 완료 대기
|
||||
} else {
|
||||
alert('스케줄 저장 실패: ' + result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('스케줄 저장 오류:', error);
|
||||
alert('스케줄 저장 오류: ' + error.message);
|
||||
}
|
||||
});
|
||||
|
||||
// Toggle schedule active status
|
||||
async function toggleSchedule(jobName, active) {
|
||||
const action = active ? '활성화' : '비활성화';
|
||||
|
||||
if (!confirm(`스케줄을 ${action}하시겠습니까?\n작업: ${jobName}`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(contextPath + `api/batch/schedules/${jobName}/toggle`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ active: active })
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
alert(`스케줄이 성공적으로 ${action}되었습니다!`);
|
||||
loadSchedules();
|
||||
} else {
|
||||
alert(`스케줄 ${action} 실패: ` + result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
alert(`스케줄 ${action} 오류: ` + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete schedule
|
||||
async function deleteSchedule(jobName) {
|
||||
if (!confirm(`스케줄을 삭제하시겠습니까?\n작업: ${jobName}\n\n이 작업은 되돌릴 수 없습니다.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(contextPath + `api/batch/schedules/${jobName}/delete`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
alert('스케줄이 성공적으로 삭제되었습니다!');
|
||||
loadSchedules();
|
||||
} else {
|
||||
alert('스케줄 삭제 실패: ' + result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
alert('스케줄 삭제 오류: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Utility: Format datetime
|
||||
function formatDateTime(dateTimeStr) {
|
||||
if (!dateTimeStr) return '-';
|
||||
|
||||
try {
|
||||
const date = new Date(dateTimeStr);
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(date.getSeconds()).padStart(2, '0');
|
||||
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||
} catch (error) {
|
||||
return dateTimeStr;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadJobs();
|
||||
loadSchedules();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
불러오는 중...
Reference in New Issue
Block a user