fix: ChnPrmShip 캐시 갱신 조건 완화 및 스케줄 이전 실행 시간 표시 #3
@ -16,8 +16,8 @@ else
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 컴파일 검증 (테스트 제외, 오프라인 가능)
|
# 컴파일 검증 (테스트 제외, 프론트엔드 빌드 제외)
|
||||||
$MVN compile -q -DskipTests 2>&1
|
$MVN compile -q -DskipTests -Dskip.npm -Dskip.installnodenpm 2>&1
|
||||||
RESULT=$?
|
RESULT=$?
|
||||||
|
|
||||||
if [ $RESULT -ne 0 ]; then
|
if [ $RESULT -ne 0 ]; then
|
||||||
|
|||||||
7
.gitignore
vendored
7
.gitignore
vendored
@ -95,6 +95,13 @@ application-local.yml
|
|||||||
logs/
|
logs/
|
||||||
*.log.*
|
*.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 Code (개인 파일만 무시, 팀 파일은 추적)
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
.claude/scripts/
|
.claude/scripts/
|
||||||
|
|||||||
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>
|
</excludes>
|
||||||
</configuration>
|
</configuration>
|
||||||
</plugin>
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<groupId>com.github.eirslett</groupId>
|
||||||
|
<artifactId>frontend-maven-plugin</artifactId>
|
||||||
|
<version>1.15.1</version>
|
||||||
|
<configuration>
|
||||||
|
<workingDirectory>frontend</workingDirectory>
|
||||||
|
<nodeVersion>v20.19.0</nodeVersion>
|
||||||
|
</configuration>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<id>install-node-and-npm</id>
|
||||||
|
<goals><goal>install-node-and-npm</goal></goals>
|
||||||
|
</execution>
|
||||||
|
<execution>
|
||||||
|
<id>npm-install</id>
|
||||||
|
<goals><goal>npm</goal></goals>
|
||||||
|
<configuration>
|
||||||
|
<arguments>install</arguments>
|
||||||
|
</configuration>
|
||||||
|
</execution>
|
||||||
|
<execution>
|
||||||
|
<id>npm-build</id>
|
||||||
|
<goals><goal>npm</goal></goals>
|
||||||
|
<configuration>
|
||||||
|
<arguments>run build</arguments>
|
||||||
|
</configuration>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
<plugin>
|
<plugin>
|
||||||
<groupId>org.apache.maven.plugins</groupId>
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
<artifactId>maven-compiler-plugin</artifactId>
|
<artifactId>maven-compiler-plugin</artifactId>
|
||||||
|
|||||||
@ -1,9 +1,6 @@
|
|||||||
package com.snp.batch.global.controller;
|
package com.snp.batch.global.controller;
|
||||||
|
|
||||||
import com.snp.batch.global.dto.JobExecutionDto;
|
import com.snp.batch.global.dto.*;
|
||||||
import com.snp.batch.global.dto.JobLaunchRequest;
|
|
||||||
import com.snp.batch.global.dto.ScheduleRequest;
|
|
||||||
import com.snp.batch.global.dto.ScheduleResponse;
|
|
||||||
import com.snp.batch.service.BatchService;
|
import com.snp.batch.service.BatchService;
|
||||||
import com.snp.batch.service.ScheduleService;
|
import com.snp.batch.service.ScheduleService;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
@ -20,6 +17,8 @@ import org.springdoc.core.annotations.ParameterObject;
|
|||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@ -101,7 +100,7 @@ public class BatchController {
|
|||||||
})
|
})
|
||||||
@GetMapping("/jobs")
|
@GetMapping("/jobs")
|
||||||
public ResponseEntity<List<String>> listJobs() {
|
public ResponseEntity<List<String>> listJobs() {
|
||||||
log.info("Received request to list all jobs");
|
log.debug("Received request to list all jobs");
|
||||||
List<String> jobs = batchService.listAllJobs();
|
List<String> jobs = batchService.listAllJobs();
|
||||||
return ResponseEntity.ok(jobs);
|
return ResponseEntity.ok(jobs);
|
||||||
}
|
}
|
||||||
@ -119,6 +118,16 @@ public class BatchController {
|
|||||||
return ResponseEntity.ok(executions);
|
return ResponseEntity.ok(executions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "최근 전체 실행 이력 조회", description = "Job 구분 없이 최근 실행 이력을 조회합니다")
|
||||||
|
@GetMapping("/executions/recent")
|
||||||
|
public ResponseEntity<List<JobExecutionDto>> getRecentExecutions(
|
||||||
|
@Parameter(description = "조회 건수", example = "50")
|
||||||
|
@RequestParam(defaultValue = "50") int limit) {
|
||||||
|
log.debug("Received request to get recent executions: limit={}", limit);
|
||||||
|
List<JobExecutionDto> executions = batchService.getRecentExecutions(limit);
|
||||||
|
return ResponseEntity.ok(executions);
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/executions/{executionId}")
|
@GetMapping("/executions/{executionId}")
|
||||||
public ResponseEntity<JobExecutionDto> getExecutionDetails(@PathVariable Long executionId) {
|
public ResponseEntity<JobExecutionDto> getExecutionDetails(@PathVariable Long executionId) {
|
||||||
log.info("Received request to get execution details for: {}", executionId);
|
log.info("Received request to get execution details for: {}", executionId);
|
||||||
@ -291,7 +300,7 @@ public class BatchController {
|
|||||||
public ResponseEntity<com.snp.batch.global.dto.TimelineResponse> getTimeline(
|
public ResponseEntity<com.snp.batch.global.dto.TimelineResponse> getTimeline(
|
||||||
@RequestParam String view,
|
@RequestParam String view,
|
||||||
@RequestParam String date) {
|
@RequestParam String date) {
|
||||||
log.info("Received request to get timeline: view={}, date={}", view, date);
|
log.debug("Received request to get timeline: view={}, date={}", view, date);
|
||||||
try {
|
try {
|
||||||
com.snp.batch.global.dto.TimelineResponse timeline = batchService.getTimeline(view, date);
|
com.snp.batch.global.dto.TimelineResponse timeline = batchService.getTimeline(view, date);
|
||||||
return ResponseEntity.ok(timeline);
|
return ResponseEntity.ok(timeline);
|
||||||
@ -303,7 +312,7 @@ public class BatchController {
|
|||||||
|
|
||||||
@GetMapping("/dashboard")
|
@GetMapping("/dashboard")
|
||||||
public ResponseEntity<com.snp.batch.global.dto.DashboardResponse> getDashboard() {
|
public ResponseEntity<com.snp.batch.global.dto.DashboardResponse> getDashboard() {
|
||||||
log.info("Received request to get dashboard data");
|
log.debug("Received request to get dashboard data");
|
||||||
try {
|
try {
|
||||||
com.snp.batch.global.dto.DashboardResponse dashboard = batchService.getDashboardData();
|
com.snp.batch.global.dto.DashboardResponse dashboard = batchService.getDashboardData();
|
||||||
return ResponseEntity.ok(dashboard);
|
return ResponseEntity.ok(dashboard);
|
||||||
@ -327,4 +336,121 @@ public class BatchController {
|
|||||||
return ResponseEntity.internalServerError().build();
|
return ResponseEntity.internalServerError().build();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── F1: 강제 종료(Abandon) API ─────────────────────────────
|
||||||
|
|
||||||
|
@Operation(summary = "오래된 실행 중 목록 조회", description = "지정된 시간(분) 이상 STARTED/STARTING 상태인 실행 목록을 조회합니다")
|
||||||
|
@GetMapping("/executions/stale")
|
||||||
|
public ResponseEntity<List<JobExecutionDto>> getStaleExecutions(
|
||||||
|
@Parameter(description = "임계 시간(분)", example = "60")
|
||||||
|
@RequestParam(defaultValue = "60") int thresholdMinutes) {
|
||||||
|
log.info("Received request to get stale executions: thresholdMinutes={}", thresholdMinutes);
|
||||||
|
List<JobExecutionDto> executions = batchService.getStaleExecutions(thresholdMinutes);
|
||||||
|
return ResponseEntity.ok(executions);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "실행 강제 종료", description = "특정 실행을 ABANDONED 상태로 강제 변경합니다")
|
||||||
|
@PostMapping("/executions/{executionId}/abandon")
|
||||||
|
public ResponseEntity<Map<String, Object>> abandonExecution(@PathVariable Long executionId) {
|
||||||
|
log.info("Received request to abandon execution: {}", executionId);
|
||||||
|
try {
|
||||||
|
batchService.abandonExecution(executionId);
|
||||||
|
return ResponseEntity.ok(Map.of(
|
||||||
|
"success", true,
|
||||||
|
"message", "Execution abandoned successfully"
|
||||||
|
));
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of(
|
||||||
|
"success", false,
|
||||||
|
"message", e.getMessage()
|
||||||
|
));
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Error abandoning execution: {}", executionId, e);
|
||||||
|
return ResponseEntity.internalServerError().body(Map.of(
|
||||||
|
"success", false,
|
||||||
|
"message", "Failed to abandon execution: " + e.getMessage()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "오래된 실행 전체 강제 종료", description = "지정된 시간(분) 이상 실행 중인 모든 Job을 ABANDONED로 변경합니다")
|
||||||
|
@PostMapping("/executions/stale/abandon-all")
|
||||||
|
public ResponseEntity<Map<String, Object>> abandonAllStaleExecutions(
|
||||||
|
@Parameter(description = "임계 시간(분)", example = "60")
|
||||||
|
@RequestParam(defaultValue = "60") int thresholdMinutes) {
|
||||||
|
log.info("Received request to abandon all stale executions: thresholdMinutes={}", thresholdMinutes);
|
||||||
|
try {
|
||||||
|
int count = batchService.abandonAllStaleExecutions(thresholdMinutes);
|
||||||
|
return ResponseEntity.ok(Map.of(
|
||||||
|
"success", true,
|
||||||
|
"message", count + "건의 실행이 강제 종료되었습니다",
|
||||||
|
"abandonedCount", count
|
||||||
|
));
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Error abandoning all stale executions", e);
|
||||||
|
return ResponseEntity.internalServerError().body(Map.of(
|
||||||
|
"success", false,
|
||||||
|
"message", "Failed to abandon stale executions: " + e.getMessage()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── F4: 실행 이력 검색 API ─────────────────────────────────
|
||||||
|
|
||||||
|
@Operation(summary = "실행 이력 검색", description = "조건별 실행 이력 검색 (페이지네이션 지원)")
|
||||||
|
@GetMapping("/executions/search")
|
||||||
|
public ResponseEntity<ExecutionSearchResponse> searchExecutions(
|
||||||
|
@Parameter(description = "Job 이름 (콤마 구분, 복수 가능)") @RequestParam(required = false) String jobNames,
|
||||||
|
@Parameter(description = "상태 (필터)", example = "COMPLETED") @RequestParam(required = false) String status,
|
||||||
|
@Parameter(description = "시작일 (yyyy-MM-dd'T'HH:mm:ss)") @RequestParam(required = false) String startDate,
|
||||||
|
@Parameter(description = "종료일 (yyyy-MM-dd'T'HH:mm:ss)") @RequestParam(required = false) String endDate,
|
||||||
|
@Parameter(description = "페이지 번호 (0부터)") @RequestParam(defaultValue = "0") int page,
|
||||||
|
@Parameter(description = "페이지 크기") @RequestParam(defaultValue = "50") int size) {
|
||||||
|
log.debug("Search executions: jobNames={}, status={}, startDate={}, endDate={}, page={}, size={}",
|
||||||
|
jobNames, status, startDate, endDate, page, size);
|
||||||
|
|
||||||
|
List<String> jobNameList = (jobNames != null && !jobNames.isBlank())
|
||||||
|
? java.util.Arrays.stream(jobNames.split(","))
|
||||||
|
.map(String::trim).filter(s -> !s.isEmpty()).toList()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
LocalDateTime start = startDate != null ? LocalDateTime.parse(startDate) : null;
|
||||||
|
LocalDateTime end = endDate != null ? LocalDateTime.parse(endDate) : null;
|
||||||
|
|
||||||
|
ExecutionSearchResponse response = batchService.searchExecutions(jobNameList, status, start, end, page, size);
|
||||||
|
return ResponseEntity.ok(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── F7: Job 상세 목록 API ──────────────────────────────────
|
||||||
|
|
||||||
|
@Operation(summary = "Job 상세 목록 조회", description = "모든 Job의 최근 실행 상태 및 스케줄 정보를 조회합니다")
|
||||||
|
@GetMapping("/jobs/detail")
|
||||||
|
public ResponseEntity<List<JobDetailDto>> getJobsDetail() {
|
||||||
|
log.debug("Received request to get jobs with detail");
|
||||||
|
List<JobDetailDto> jobs = batchService.getJobsWithDetail();
|
||||||
|
return ResponseEntity.ok(jobs);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── F8: 실행 통계 API ──────────────────────────────────────
|
||||||
|
|
||||||
|
@Operation(summary = "전체 실행 통계", description = "전체 배치 작업의 일별 실행 통계를 조회합니다")
|
||||||
|
@GetMapping("/statistics")
|
||||||
|
public ResponseEntity<ExecutionStatisticsDto> getStatistics(
|
||||||
|
@Parameter(description = "조회 기간(일)", example = "30")
|
||||||
|
@RequestParam(defaultValue = "30") int days) {
|
||||||
|
log.debug("Received request to get statistics: days={}", days);
|
||||||
|
ExecutionStatisticsDto stats = batchService.getStatistics(days);
|
||||||
|
return ResponseEntity.ok(stats);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "Job별 실행 통계", description = "특정 배치 작업의 일별 실행 통계를 조회합니다")
|
||||||
|
@GetMapping("/statistics/{jobName}")
|
||||||
|
public ResponseEntity<ExecutionStatisticsDto> getJobStatistics(
|
||||||
|
@Parameter(description = "Job 이름", required = true) @PathVariable String jobName,
|
||||||
|
@Parameter(description = "조회 기간(일)", example = "30")
|
||||||
|
@RequestParam(defaultValue = "30") int days) {
|
||||||
|
log.debug("Received request to get statistics for job: {}, days={}", jobName, days);
|
||||||
|
ExecutionStatisticsDto stats = batchService.getJobStatistics(jobName, days);
|
||||||
|
return ResponseEntity.ok(stats);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,41 +3,18 @@ package com.snp.batch.global.controller;
|
|||||||
import org.springframework.stereotype.Controller;
|
import org.springframework.stereotype.Controller;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SPA(React) fallback 라우터
|
||||||
|
*
|
||||||
|
* React Router가 클라이언트 사이드 라우팅을 처리하므로,
|
||||||
|
* 모든 프론트 경로를 index.html로 포워딩한다.
|
||||||
|
*/
|
||||||
@Controller
|
@Controller
|
||||||
public class WebViewController {
|
public class WebViewController {
|
||||||
|
|
||||||
@GetMapping("/")
|
@GetMapping({"/", "/jobs", "/executions", "/executions/{id:\\d+}",
|
||||||
public String index() {
|
"/execution-detail", "/schedules", "/schedule-timeline"})
|
||||||
return "index";
|
public String forward() {
|
||||||
}
|
return "forward:/index.html";
|
||||||
|
|
||||||
@GetMapping("/jobs")
|
|
||||||
public String jobs() {
|
|
||||||
return "jobs";
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/executions")
|
|
||||||
public String executions() {
|
|
||||||
return "executions";
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/schedules")
|
|
||||||
public String schedules() {
|
|
||||||
return "schedules";
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/execution-detail")
|
|
||||||
public String executionDetail() {
|
|
||||||
return "execution-detail";
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/executions/{id}")
|
|
||||||
public String executionDetailById() {
|
|
||||||
return "execution-detail";
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/schedule-timeline")
|
|
||||||
public String scheduleTimeline() {
|
|
||||||
return "schedule-timeline";
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,6 +16,9 @@ public class DashboardResponse {
|
|||||||
private Stats stats;
|
private Stats stats;
|
||||||
private List<RunningJob> runningJobs;
|
private List<RunningJob> runningJobs;
|
||||||
private List<RecentExecution> recentExecutions;
|
private List<RecentExecution> recentExecutions;
|
||||||
|
private List<RecentFailure> recentFailures;
|
||||||
|
private int staleExecutionCount;
|
||||||
|
private FailureStats failureStats;
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
@Builder
|
@Builder
|
||||||
@ -50,4 +53,26 @@ public class DashboardResponse {
|
|||||||
private LocalDateTime startTime;
|
private LocalDateTime startTime;
|
||||||
private LocalDateTime endTime;
|
private LocalDateTime endTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public static class RecentFailure {
|
||||||
|
private Long executionId;
|
||||||
|
private String jobName;
|
||||||
|
private String status;
|
||||||
|
private LocalDateTime startTime;
|
||||||
|
private LocalDateTime endTime;
|
||||||
|
private String exitMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public static class FailureStats {
|
||||||
|
private int last24h;
|
||||||
|
private int last7d;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,21 @@
|
|||||||
|
package com.snp.batch.global.dto;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class ExecutionSearchResponse {
|
||||||
|
|
||||||
|
private List<JobExecutionDto> executions;
|
||||||
|
private int totalCount;
|
||||||
|
private int page;
|
||||||
|
private int size;
|
||||||
|
private int totalPages;
|
||||||
|
}
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
package com.snp.batch.global.dto;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class ExecutionStatisticsDto {
|
||||||
|
|
||||||
|
private List<DailyStat> dailyStats;
|
||||||
|
private int totalExecutions;
|
||||||
|
private int totalSuccess;
|
||||||
|
private int totalFailed;
|
||||||
|
private double avgDurationMs;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public static class DailyStat {
|
||||||
|
private String date;
|
||||||
|
private int successCount;
|
||||||
|
private int failedCount;
|
||||||
|
private int otherCount;
|
||||||
|
private double avgDurationMs;
|
||||||
|
}
|
||||||
|
}
|
||||||
30
src/main/java/com/snp/batch/global/dto/JobDetailDto.java
Normal file
30
src/main/java/com/snp/batch/global/dto/JobDetailDto.java
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
package com.snp.batch.global.dto;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class JobDetailDto {
|
||||||
|
|
||||||
|
private String jobName;
|
||||||
|
private LastExecution lastExecution;
|
||||||
|
private String scheduleCron;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public static class LastExecution {
|
||||||
|
private Long executionId;
|
||||||
|
private String status;
|
||||||
|
private LocalDateTime startTime;
|
||||||
|
private LocalDateTime endTime;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,10 +3,13 @@ package com.snp.batch.global.repository;
|
|||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 타임라인 조회를 위한 경량 Repository
|
* 타임라인 조회를 위한 경량 Repository
|
||||||
@ -33,6 +36,10 @@ public class TimelineRepository {
|
|||||||
return tablePrefix + "JOB_INSTANCE";
|
return tablePrefix + "JOB_INSTANCE";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String getStepExecutionTable() {
|
||||||
|
return tablePrefix + "STEP_EXECUTION";
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 특정 Job의 특정 범위 내 실행 이력 조회 (경량)
|
* 특정 Job의 특정 범위 내 실행 이력 조회 (경량)
|
||||||
* Step Context를 조회하지 않아 성능이 매우 빠름
|
* Step Context를 조회하지 않아 성능이 매우 빠름
|
||||||
@ -112,7 +119,9 @@ public class TimelineRepository {
|
|||||||
je.JOB_EXECUTION_ID as executionId,
|
je.JOB_EXECUTION_ID as executionId,
|
||||||
je.STATUS as status,
|
je.STATUS as status,
|
||||||
je.START_TIME as startTime,
|
je.START_TIME as startTime,
|
||||||
je.END_TIME as endTime
|
je.END_TIME as endTime,
|
||||||
|
je.EXIT_CODE as exitCode,
|
||||||
|
je.EXIT_MESSAGE as exitMessage
|
||||||
FROM %s je
|
FROM %s je
|
||||||
INNER JOIN %s ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID
|
INNER JOIN %s ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID
|
||||||
ORDER BY je.START_TIME DESC
|
ORDER BY je.START_TIME DESC
|
||||||
@ -121,4 +130,263 @@ public class TimelineRepository {
|
|||||||
|
|
||||||
return jdbcTemplate.queryForList(sql, limit);
|
return jdbcTemplate.queryForList(sql, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── F1: 강제 종료(Abandon) 관련 ──────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 오래된 실행 중 Job 조회 (threshold 분 이상 STARTED/STARTING)
|
||||||
|
*/
|
||||||
|
public List<Map<String, Object>> findStaleExecutions(int thresholdMinutes) {
|
||||||
|
String sql = String.format("""
|
||||||
|
SELECT
|
||||||
|
ji.JOB_NAME as jobName,
|
||||||
|
je.JOB_EXECUTION_ID as executionId,
|
||||||
|
je.STATUS as status,
|
||||||
|
je.START_TIME as startTime
|
||||||
|
FROM %s je
|
||||||
|
INNER JOIN %s ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID
|
||||||
|
WHERE je.STATUS IN ('STARTED', 'STARTING')
|
||||||
|
AND je.START_TIME < NOW() - INTERVAL '%d minutes'
|
||||||
|
ORDER BY je.START_TIME ASC
|
||||||
|
""", getJobExecutionTable(), getJobInstanceTable(), thresholdMinutes);
|
||||||
|
|
||||||
|
return jdbcTemplate.queryForList(sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Job Execution 상태를 ABANDONED로 변경
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public int abandonJobExecution(long executionId) {
|
||||||
|
String sql = String.format("""
|
||||||
|
UPDATE %s
|
||||||
|
SET STATUS = 'ABANDONED',
|
||||||
|
EXIT_CODE = 'ABANDONED',
|
||||||
|
END_TIME = NOW(),
|
||||||
|
EXIT_MESSAGE = 'Force abandoned by admin'
|
||||||
|
WHERE JOB_EXECUTION_ID = ?
|
||||||
|
AND STATUS IN ('STARTED', 'STARTING')
|
||||||
|
""", getJobExecutionTable());
|
||||||
|
|
||||||
|
return jdbcTemplate.update(sql, executionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 해당 Job Execution의 Step Execution들도 ABANDONED로 변경
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public int abandonStepExecutions(long jobExecutionId) {
|
||||||
|
String sql = String.format("""
|
||||||
|
UPDATE %s
|
||||||
|
SET STATUS = 'ABANDONED',
|
||||||
|
EXIT_CODE = 'ABANDONED',
|
||||||
|
END_TIME = NOW(),
|
||||||
|
EXIT_MESSAGE = 'Force abandoned by admin'
|
||||||
|
WHERE JOB_EXECUTION_ID = ?
|
||||||
|
AND STATUS IN ('STARTED', 'STARTING')
|
||||||
|
""", getStepExecutionTable());
|
||||||
|
|
||||||
|
return jdbcTemplate.update(sql, jobExecutionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 오래된 실행 중 건수 조회
|
||||||
|
*/
|
||||||
|
public int countStaleExecutions(int thresholdMinutes) {
|
||||||
|
String sql = String.format("""
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM %s je
|
||||||
|
WHERE je.STATUS IN ('STARTED', 'STARTING')
|
||||||
|
AND je.START_TIME < NOW() - INTERVAL '%d minutes'
|
||||||
|
""", getJobExecutionTable(), thresholdMinutes);
|
||||||
|
|
||||||
|
Integer count = jdbcTemplate.queryForObject(sql, Integer.class);
|
||||||
|
return count != null ? count : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── F4: 실행 이력 검색 (페이지네이션) ─────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 실행 이력 검색 (동적 조건 + 페이지네이션)
|
||||||
|
*/
|
||||||
|
public List<Map<String, Object>> searchExecutions(
|
||||||
|
List<String> jobNames, String status,
|
||||||
|
LocalDateTime startDate, LocalDateTime endDate,
|
||||||
|
int offset, int limit) {
|
||||||
|
|
||||||
|
StringBuilder sql = new StringBuilder(String.format("""
|
||||||
|
SELECT
|
||||||
|
ji.JOB_NAME as jobName,
|
||||||
|
je.JOB_EXECUTION_ID as executionId,
|
||||||
|
je.STATUS as status,
|
||||||
|
je.START_TIME as startTime,
|
||||||
|
je.END_TIME as endTime,
|
||||||
|
je.EXIT_CODE as exitCode,
|
||||||
|
je.EXIT_MESSAGE as exitMessage
|
||||||
|
FROM %s je
|
||||||
|
INNER JOIN %s ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID
|
||||||
|
WHERE 1=1
|
||||||
|
""", getJobExecutionTable(), getJobInstanceTable()));
|
||||||
|
|
||||||
|
List<Object> params = new ArrayList<>();
|
||||||
|
appendSearchConditions(sql, params, jobNames, status, startDate, endDate);
|
||||||
|
|
||||||
|
sql.append(" ORDER BY je.START_TIME DESC LIMIT ? OFFSET ?");
|
||||||
|
params.add(limit);
|
||||||
|
params.add(offset);
|
||||||
|
|
||||||
|
return jdbcTemplate.queryForList(sql.toString(), params.toArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 실행 이력 검색 건수
|
||||||
|
*/
|
||||||
|
public int countExecutions(
|
||||||
|
List<String> jobNames, String status,
|
||||||
|
LocalDateTime startDate, LocalDateTime endDate) {
|
||||||
|
|
||||||
|
StringBuilder sql = new StringBuilder(String.format("""
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM %s je
|
||||||
|
INNER JOIN %s ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID
|
||||||
|
WHERE 1=1
|
||||||
|
""", getJobExecutionTable(), getJobInstanceTable()));
|
||||||
|
|
||||||
|
List<Object> params = new ArrayList<>();
|
||||||
|
appendSearchConditions(sql, params, jobNames, status, startDate, endDate);
|
||||||
|
|
||||||
|
Integer count = jdbcTemplate.queryForObject(sql.toString(), Integer.class, params.toArray());
|
||||||
|
return count != null ? count : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void appendSearchConditions(
|
||||||
|
StringBuilder sql, List<Object> params,
|
||||||
|
List<String> jobNames, String status,
|
||||||
|
LocalDateTime startDate, LocalDateTime endDate) {
|
||||||
|
|
||||||
|
if (jobNames != null && !jobNames.isEmpty()) {
|
||||||
|
String placeholders = jobNames.stream().map(n -> "?").collect(Collectors.joining(", "));
|
||||||
|
sql.append(" AND ji.JOB_NAME IN (").append(placeholders).append(")");
|
||||||
|
params.addAll(jobNames);
|
||||||
|
}
|
||||||
|
if (status != null && !status.isBlank()) {
|
||||||
|
sql.append(" AND je.STATUS = ?");
|
||||||
|
params.add(status);
|
||||||
|
}
|
||||||
|
if (startDate != null) {
|
||||||
|
sql.append(" AND je.START_TIME >= ?");
|
||||||
|
params.add(startDate);
|
||||||
|
}
|
||||||
|
if (endDate != null) {
|
||||||
|
sql.append(" AND je.START_TIME < ?");
|
||||||
|
params.add(endDate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── F6: 대시보드 실패 통계 ──────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 최근 실패 이력 조회
|
||||||
|
*/
|
||||||
|
public List<Map<String, Object>> findRecentFailures(int hours) {
|
||||||
|
String sql = String.format("""
|
||||||
|
SELECT
|
||||||
|
ji.JOB_NAME as jobName,
|
||||||
|
je.JOB_EXECUTION_ID as executionId,
|
||||||
|
je.STATUS as status,
|
||||||
|
je.START_TIME as startTime,
|
||||||
|
je.END_TIME as endTime,
|
||||||
|
je.EXIT_MESSAGE as exitMessage
|
||||||
|
FROM %s je
|
||||||
|
INNER JOIN %s ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID
|
||||||
|
WHERE je.STATUS = 'FAILED'
|
||||||
|
AND je.START_TIME >= NOW() - INTERVAL '%d hours'
|
||||||
|
ORDER BY je.START_TIME DESC
|
||||||
|
LIMIT 10
|
||||||
|
""", getJobExecutionTable(), getJobInstanceTable(), hours);
|
||||||
|
|
||||||
|
return jdbcTemplate.queryForList(sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 시점 이후 실패 건수
|
||||||
|
*/
|
||||||
|
public int countFailuresSince(LocalDateTime since) {
|
||||||
|
String sql = String.format("""
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM %s je
|
||||||
|
WHERE je.STATUS = 'FAILED'
|
||||||
|
AND je.START_TIME >= ?
|
||||||
|
""", getJobExecutionTable());
|
||||||
|
|
||||||
|
Integer count = jdbcTemplate.queryForObject(sql, Integer.class, since);
|
||||||
|
return count != null ? count : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── F7: Job별 최근 실행 정보 ────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Job별 가장 최근 실행 정보 조회 (DISTINCT ON 활용)
|
||||||
|
*/
|
||||||
|
public List<Map<String, Object>> findLastExecutionPerJob() {
|
||||||
|
String sql = String.format("""
|
||||||
|
SELECT DISTINCT ON (ji.JOB_NAME)
|
||||||
|
ji.JOB_NAME as jobName,
|
||||||
|
je.JOB_EXECUTION_ID as executionId,
|
||||||
|
je.STATUS as status,
|
||||||
|
je.START_TIME as startTime,
|
||||||
|
je.END_TIME as endTime
|
||||||
|
FROM %s je
|
||||||
|
INNER JOIN %s ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID
|
||||||
|
ORDER BY ji.JOB_NAME, je.START_TIME DESC
|
||||||
|
""", getJobExecutionTable(), getJobInstanceTable());
|
||||||
|
|
||||||
|
return jdbcTemplate.queryForList(sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── F8: 실행 통계 ──────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 일별 실행 통계 (전체)
|
||||||
|
*/
|
||||||
|
public List<Map<String, Object>> findDailyStatistics(int days) {
|
||||||
|
String sql = String.format("""
|
||||||
|
SELECT
|
||||||
|
CAST(je.START_TIME AS DATE) as execDate,
|
||||||
|
SUM(CASE WHEN je.STATUS = 'COMPLETED' THEN 1 ELSE 0 END) as successCount,
|
||||||
|
SUM(CASE WHEN je.STATUS = 'FAILED' THEN 1 ELSE 0 END) as failedCount,
|
||||||
|
SUM(CASE WHEN je.STATUS NOT IN ('COMPLETED', 'FAILED') THEN 1 ELSE 0 END) as otherCount,
|
||||||
|
AVG(EXTRACT(EPOCH FROM (je.END_TIME - je.START_TIME)) * 1000) as avgDurationMs
|
||||||
|
FROM %s je
|
||||||
|
WHERE je.START_TIME >= NOW() - INTERVAL '%d days'
|
||||||
|
AND je.START_TIME IS NOT NULL
|
||||||
|
GROUP BY CAST(je.START_TIME AS DATE)
|
||||||
|
ORDER BY execDate
|
||||||
|
""", getJobExecutionTable(), days);
|
||||||
|
|
||||||
|
return jdbcTemplate.queryForList(sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 일별 실행 통계 (특정 Job)
|
||||||
|
*/
|
||||||
|
public List<Map<String, Object>> findDailyStatisticsForJob(String jobName, int days) {
|
||||||
|
String sql = String.format("""
|
||||||
|
SELECT
|
||||||
|
CAST(je.START_TIME AS DATE) as execDate,
|
||||||
|
SUM(CASE WHEN je.STATUS = 'COMPLETED' THEN 1 ELSE 0 END) as successCount,
|
||||||
|
SUM(CASE WHEN je.STATUS = 'FAILED' THEN 1 ELSE 0 END) as failedCount,
|
||||||
|
SUM(CASE WHEN je.STATUS NOT IN ('COMPLETED', 'FAILED') THEN 1 ELSE 0 END) as otherCount,
|
||||||
|
AVG(EXTRACT(EPOCH FROM (je.END_TIME - je.START_TIME)) * 1000) as avgDurationMs
|
||||||
|
FROM %s je
|
||||||
|
INNER JOIN %s ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID
|
||||||
|
WHERE ji.JOB_NAME = ?
|
||||||
|
AND je.START_TIME >= NOW() - INTERVAL '%d days'
|
||||||
|
AND je.START_TIME IS NOT NULL
|
||||||
|
GROUP BY CAST(je.START_TIME AS DATE)
|
||||||
|
ORDER BY execDate
|
||||||
|
""", getJobExecutionTable(), getJobInstanceTable(), days);
|
||||||
|
|
||||||
|
return jdbcTemplate.queryForList(sql, jobName);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
package com.snp.batch.service;
|
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 com.snp.batch.global.repository.TimelineRepository;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.batch.core.Job;
|
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.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.context.annotation.Lazy;
|
import org.springframework.context.annotation.Lazy;
|
||||||
import org.springframework.stereotype.Service;
|
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.*;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@ -86,6 +89,13 @@ public class BatchService {
|
|||||||
.collect(Collectors.toList());
|
.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) {
|
public JobExecutionDto getExecutionDetails(Long executionId) {
|
||||||
JobExecution jobExecution = jobExplorer.getJobExecution(executionId);
|
JobExecution jobExecution = jobExplorer.getJobExecution(executionId);
|
||||||
if (jobExecution == null) {
|
if (jobExecution == null) {
|
||||||
@ -557,10 +567,206 @@ public class BatchService {
|
|||||||
})
|
})
|
||||||
.collect(Collectors.toList());
|
.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)
|
.stats(stats)
|
||||||
.runningJobs(runningJobs)
|
.runningJobs(runningJobs)
|
||||||
.recentExecutions(recentExecutions)
|
.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();
|
.build();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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