Merge branch 'feature/batch-web-ui-refactor' into develop

React SPA 전환 + 10대 기능 강화 + 다크모드
This commit is contained in:
htlee 2026-02-17 12:54:02 +09:00
커밋 d2c39009ac
59개의 변경된 파일8753개의 추가작업 그리고 5291개의 파일을 삭제

파일 보기

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

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

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

73
frontend/README.md Normal file
파일 보기

@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

23
frontend/eslint.config.js Normal file
파일 보기

@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

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

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

33
frontend/package.json Normal file
파일 보기

@ -0,0 +1,33 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.13.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@tailwindcss/vite": "^4.1.18",
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"tailwindcss": "^4.1.18",
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0",
"vite": "^7.3.1"
}
}

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

파일 보기

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

파일 보기

@ -0,0 +1,74 @@
interface BarValue {
color: string;
value: number;
}
interface BarData {
label: string;
values: BarValue[];
}
interface Props {
data: BarData[];
height?: number;
}
export default function BarChart({ data, height = 200 }: Props) {
const maxTotal = Math.max(...data.map((d) => d.values.reduce((sum, v) => sum + v.value, 0)), 1);
return (
<div className="w-full">
<div className="flex items-end gap-1" style={{ height }}>
{data.map((bar, i) => {
const total = bar.values.reduce((sum, v) => sum + v.value, 0);
const ratio = total / maxTotal;
return (
<div key={i} className="flex-1 flex flex-col justify-end h-full min-w-0">
<div
className="w-full rounded-t overflow-hidden"
style={{ height: `${ratio * 100}%` }}
title={bar.values.map((v) => `${v.color}: ${v.value}`).join(', ')}
>
{bar.values
.filter((v) => v.value > 0)
.map((v, j) => {
const segmentRatio = total > 0 ? (v.value / total) * 100 : 0;
return (
<div
key={j}
className={colorToClass(v.color)}
style={{ height: `${segmentRatio}%` }}
/>
);
})}
</div>
</div>
);
})}
</div>
<div className="flex gap-1 mt-1">
{data.map((bar, i) => (
<div key={i} className="flex-1 min-w-0">
<p className="text-[10px] text-gray-500 text-center truncate" title={bar.label}>
{bar.label}
</p>
</div>
))}
</div>
</div>
);
}
function colorToClass(color: string): string {
const map: Record<string, string> = {
green: 'bg-green-500',
red: 'bg-red-500',
gray: 'bg-gray-400',
blue: 'bg-blue-500',
yellow: 'bg-yellow-500',
orange: 'bg-orange-500',
indigo: 'bg-indigo-500',
};
return map[color] ?? color;
}

파일 보기

@ -0,0 +1,49 @@
interface Props {
open: boolean;
title?: string;
message: string;
confirmLabel?: string;
cancelLabel?: string;
confirmColor?: string;
onConfirm: () => void;
onCancel: () => void;
}
export default function ConfirmModal({
open,
title = '확인',
message,
confirmLabel = '확인',
cancelLabel = '취소',
confirmColor = 'bg-wing-accent hover:bg-wing-accent/80',
onConfirm,
onCancel,
}: Props) {
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-wing-overlay" onClick={onCancel}>
<div
className="bg-wing-surface rounded-xl shadow-2xl p-6 max-w-md w-full mx-4"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-lg font-semibold text-wing-text mb-2">{title}</h3>
<p className="text-wing-muted text-sm mb-6 whitespace-pre-line">{message}</p>
<div className="flex justify-end gap-3">
<button
onClick={onCancel}
className="px-4 py-2 text-sm font-medium text-wing-text bg-wing-card rounded-lg hover:bg-wing-hover transition-colors"
>
{cancelLabel}
</button>
<button
onClick={onConfirm}
className={`px-4 py-2 text-sm font-medium text-white rounded-lg transition-colors ${confirmColor}`}
>
{confirmLabel}
</button>
</div>
</div>
</div>
);
}

파일 보기

@ -0,0 +1,15 @@
interface Props {
icon?: string;
message: string;
sub?: string;
}
export default function EmptyState({ icon = '📭', message, sub }: Props) {
return (
<div className="flex flex-col items-center justify-center py-12 text-wing-muted">
<span className="text-4xl mb-3">{icon}</span>
<p className="text-sm font-medium">{message}</p>
{sub && <p className="text-xs mt-1">{sub}</p>}
</div>
);
}

파일 보기

@ -0,0 +1,35 @@
interface Props {
open: boolean;
title?: string;
children: React.ReactNode;
onClose: () => void;
}
export default function InfoModal({
open,
title = '정보',
children,
onClose,
}: Props) {
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-wing-overlay" onClick={onClose}>
<div
className="bg-wing-surface rounded-xl shadow-2xl p-6 max-w-lg w-full mx-4"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-lg font-semibold text-wing-text mb-4">{title}</h3>
<div className="text-wing-muted text-sm mb-6">{children}</div>
<div className="flex justify-end">
<button
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-wing-text bg-wing-card rounded-lg hover:bg-wing-hover transition-colors"
>
</button>
</div>
</div>
</div>
);
}

파일 보기

@ -0,0 +1,7 @@
export default function LoadingSpinner({ className = '' }: { className?: string }) {
return (
<div className={`flex items-center justify-center py-12 ${className}`}>
<div className="w-8 h-8 border-4 border-wing-accent/30 border-t-wing-accent rounded-full animate-spin" />
</div>
);
}

파일 보기

@ -0,0 +1,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>
);
}

파일 보기

@ -0,0 +1,40 @@
const STATUS_CONFIG: Record<string, { bg: string; text: string; label: string }> = {
COMPLETED: { bg: 'bg-emerald-100 text-emerald-700', text: '완료', label: '✓' },
FAILED: { bg: 'bg-red-100 text-red-700', text: '실패', label: '✕' },
STARTED: { bg: 'bg-blue-100 text-blue-700', text: '실행중', label: '↻' },
STARTING: { bg: 'bg-cyan-100 text-cyan-700', text: '시작중', label: '⏳' },
STOPPED: { bg: 'bg-amber-100 text-amber-700', text: '중지됨', label: '⏸' },
STOPPING: { bg: 'bg-orange-100 text-orange-700', text: '중지중', label: '⏸' },
ABANDONED: { bg: 'bg-gray-100 text-gray-700', text: '포기됨', label: '—' },
SCHEDULED: { bg: 'bg-violet-100 text-violet-700', text: '예정', label: '🕐' },
UNKNOWN: { bg: 'bg-gray-100 text-gray-500', text: '알수없음', label: '?' },
};
interface Props {
status: string;
className?: string;
}
export default function StatusBadge({ status, className = '' }: Props) {
const config = STATUS_CONFIG[status] || STATUS_CONFIG.UNKNOWN;
return (
<span className={`inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-semibold ${config.bg} ${className}`}>
<span>{config.label}</span>
{config.text}
</span>
);
}
// eslint-disable-next-line react-refresh/only-export-components
export function getStatusColor(status: string): string {
switch (status) {
case 'COMPLETED': return '#10b981';
case 'FAILED': return '#ef4444';
case 'STARTED': return '#3b82f6';
case 'STARTING': return '#06b6d4';
case 'STOPPED': return '#f59e0b';
case 'STOPPING': return '#f97316';
case 'SCHEDULED': return '#8b5cf6';
default: return '#6b7280';
}
}

파일 보기

@ -0,0 +1,37 @@
import type { Toast as ToastType } from '../hooks/useToast';
const TYPE_STYLES: Record<ToastType['type'], string> = {
success: 'bg-emerald-500',
error: 'bg-red-500',
warning: 'bg-amber-500',
info: 'bg-blue-500',
};
interface Props {
toasts: ToastType[];
onRemove: (id: number) => void;
}
export default function ToastContainer({ toasts, onRemove }: Props) {
if (toasts.length === 0) return null;
return (
<div className="fixed top-4 right-4 z-50 flex flex-col gap-2 max-w-sm">
{toasts.map((toast) => (
<div
key={toast.id}
className={`${TYPE_STYLES[toast.type]} text-white px-4 py-3 rounded-lg shadow-lg
flex items-center justify-between gap-3 animate-slide-in`}
>
<span className="text-sm">{toast.message}</span>
<button
onClick={() => onRemove(toast.id)}
className="text-white/80 hover:text-white text-lg leading-none"
>
×
</button>
</div>
))}
</div>
);
}

파일 보기

@ -0,0 +1,26 @@
import { createContext, useContext, type ReactNode } from 'react';
import { useTheme } from '../hooks/useTheme';
interface ThemeContextValue {
theme: 'dark' | 'light';
toggle: () => void;
}
const ThemeContext = createContext<ThemeContextValue>({
theme: 'dark',
toggle: () => {},
});
export function ThemeProvider({ children }: { children: ReactNode }) {
const value = useTheme();
return (
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
);
}
// eslint-disable-next-line react-refresh/only-export-components
export function useThemeContext() {
return useContext(ThemeContext);
}

파일 보기

@ -0,0 +1,29 @@
import { createContext, useContext, type ReactNode } from 'react';
import { useToast, type Toast } from '../hooks/useToast';
interface ToastContextValue {
toasts: Toast[];
showToast: (message: string, type?: Toast['type']) => void;
removeToast: (id: number) => void;
}
const ToastContext = createContext<ToastContextValue | null>(null);
export function ToastProvider({ children }: { children: ReactNode }) {
const { toasts, showToast, removeToast } = useToast();
return (
<ToastContext.Provider value={{ toasts, showToast, removeToast }}>
{children}
</ToastContext.Provider>
);
}
// eslint-disable-next-line react-refresh/only-export-components
export function useToastContext(): ToastContextValue {
const ctx = useContext(ToastContext);
if (!ctx) {
throw new Error('useToastContext must be used within a ToastProvider');
}
return ctx;
}

파일 보기

@ -0,0 +1,53 @@
import { useEffect, useRef } from 'react';
/**
*
* - 1 intervalMs
* - (document.hidden) ,
* - deps
*/
export function usePoller(
fn: () => Promise<void> | void,
intervalMs: number,
deps: unknown[] = [],
) {
const fnRef = useRef(fn);
fnRef.current = fn;
useEffect(() => {
let timer: ReturnType<typeof setInterval> | null = null;
const run = () => {
fnRef.current();
};
const start = () => {
run();
timer = setInterval(run, intervalMs);
};
const stop = () => {
if (timer) {
clearInterval(timer);
timer = null;
}
};
const handleVisibility = () => {
if (document.hidden) {
stop();
} else {
start();
}
};
start();
document.addEventListener('visibilitychange', handleVisibility);
return () => {
stop();
document.removeEventListener('visibilitychange', handleVisibility);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [intervalMs, ...deps]);
}

파일 보기

@ -0,0 +1,27 @@
import { useState, useEffect, useCallback } from 'react';
type Theme = 'dark' | 'light';
const STORAGE_KEY = 'snp-batch-theme';
function getInitialTheme(): Theme {
if (typeof window === 'undefined') return 'dark';
const stored = localStorage.getItem(STORAGE_KEY);
if (stored === 'light' || stored === 'dark') return stored;
return 'dark';
}
export function useTheme() {
const [theme, setTheme] = useState<Theme>(getInitialTheme);
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem(STORAGE_KEY, theme);
}, [theme]);
const toggle = useCallback(() => {
setTheme((prev) => (prev === 'dark' ? 'light' : 'dark'));
}, []);
return { theme, toggle } as const;
}

파일 보기

@ -0,0 +1,27 @@
import { useState, useCallback } from 'react';
export interface Toast {
id: number;
message: string;
type: 'success' | 'error' | 'warning' | 'info';
}
let nextId = 0;
export function useToast() {
const [toasts, setToasts] = useState<Toast[]>([]);
const showToast = useCallback((message: string, type: Toast['type'] = 'info') => {
const id = nextId++;
setToasts((prev) => [...prev, { id, message, type }]);
setTimeout(() => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, 5000);
}, []);
const removeToast = useCallback((id: number) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, []);
return { toasts, showToast, removeToast };
}

3
frontend/src/index.css Normal file
파일 보기

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

10
frontend/src/main.tsx Normal file
파일 보기

@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

파일 보기

@ -0,0 +1,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"
>
&rarr;
</Link>
</div>
{recentExecutions.length === 0 ? (
<EmptyState
icon="📋"
message="실행 이력이 없습니다."
sub="작업을 실행하면 여기에 표시됩니다."
/>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-wing-border text-left text-wing-muted">
<th className="pb-2 font-medium"> ID</th>
<th className="pb-2 font-medium"></th>
<th className="pb-2 font-medium"> </th>
<th className="pb-2 font-medium"> </th>
<th className="pb-2 font-medium"> </th>
<th className="pb-2 font-medium"></th>
</tr>
</thead>
<tbody>
{recentExecutions.slice(0, 5).map((exec) => (
<tr key={exec.executionId} className="border-b border-wing-border/50">
<td className="py-3">
<Link
to={`/executions/${exec.executionId}`}
className="text-wing-accent hover:text-wing-accent no-underline font-medium"
>
#{exec.executionId}
</Link>
</td>
<td className="py-3 text-wing-text">{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>
);
}

파일 보기

@ -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>&larr;</span>
</button>
<EmptyState
icon="&#x26A0;"
message={error || '실행 정보를 찾을 수 없습니다.'}
/>
</div>
);
}
const jobParams = Object.entries(detail.jobParameters);
return (
<div className="space-y-6">
{/* 상단 내비게이션 */}
<button
onClick={() => navigate(-1)}
className="inline-flex items-center gap-1 text-sm text-wing-muted hover:text-wing-text transition-colors"
>
<span>&larr;</span>
</button>
{/* Job 기본 정보 */}
<div className="bg-wing-surface rounded-xl shadow-md p-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<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="&#x1F4E5;"
/>
<StatCard
label="쓰기 (Write)"
value={detail.writeCount}
gradient="bg-gradient-to-br from-emerald-500 to-emerald-600"
icon="&#x1F4E4;"
/>
<StatCard
label="건너뜀 (Skip)"
value={detail.skipCount}
gradient="bg-gradient-to-br from-amber-500 to-amber-600"
icon="&#x23ED;"
/>
<StatCard
label="필터 (Filter)"
value={detail.filterCount}
gradient="bg-gradient-to-br from-purple-500 to-purple-600"
icon="&#x1F50D;"
/>
</div>
{/* Job Parameters */}
{jobParams.length > 0 && (
<div className="bg-wing-surface rounded-xl shadow-md p-6">
<h2 className="text-lg font-semibold text-wing-text mb-4">
Job Parameters
</h2>
<div className="overflow-x-auto">
<table className="w-full text-sm text-left">
<thead className="bg-wing-card text-xs uppercase text-wing-muted">
<tr>
<th className="px-6 py-3 font-medium">Key</th>
<th className="px-6 py-3 font-medium">Value</th>
</tr>
</thead>
<tbody className="divide-y divide-wing-border/50">
{jobParams.map(([key, value]) => (
<tr
key={key}
className="hover:bg-wing-hover transition-colors"
>
<td className="px-6 py-3 font-mono text-wing-text">
{key}
</td>
<td className="px-6 py-3 text-wing-muted break-all">
{value}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Step 실행 정보 */}
<div>
<h2 className="text-lg font-semibold text-wing-text mb-4">
Step
<span className="ml-2 text-sm font-normal text-wing-muted">
({detail.stepExecutions.length})
</span>
</h2>
{detail.stepExecutions.length === 0 ? (
<EmptyState message="Step 실행 정보가 없습니다." />
) : (
<div className="space-y-4">
{detail.stepExecutions.map((step) => (
<StepCard
key={step.stepExecutionId}
step={step}
/>
))}
</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>
);
}

파일 보기

@ -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"
>
&times;
</button>
</span>
))}
</div>
)}
</div>
{/* 상태 필터 버튼 그룹 */}
<div className="flex flex-wrap gap-1">
{STATUS_FILTERS.map(({ value, label }) => (
<button
key={value}
onClick={() => {
setStatusFilter(value);
if (useSearch) {
setPage(0);
}
}}
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${
statusFilter === value
? 'bg-wing-accent text-white shadow-sm'
: 'bg-wing-card text-wing-muted hover:bg-wing-hover'
}`}
>
{label}
</button>
))}
</div>
</div>
{/* F4: 날짜 범위 필터 */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center mt-4 pt-4 border-t border-wing-border/50">
<label className="text-sm font-medium text-wing-text shrink-0">
</label>
<div className="flex items-center gap-2">
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="block rounded-lg border border-wing-border bg-wing-surface px-3 py-2 text-sm shadow-sm focus:border-wing-accent focus:ring-1 focus:ring-wing-accent"
/>
<span className="text-wing-muted text-sm">~</span>
<input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
className="block rounded-lg border border-wing-border bg-wing-surface px-3 py-2 text-sm shadow-sm focus:border-wing-accent focus:ring-1 focus:ring-wing-accent"
/>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleSearch}
className="px-4 py-2 text-sm font-medium text-white bg-wing-accent rounded-lg hover:bg-wing-accent/80 transition-colors shadow-sm"
>
</button>
{useSearch && (
<button
onClick={handleResetSearch}
className="px-4 py-2 text-sm font-medium text-wing-text bg-wing-card rounded-lg hover:bg-wing-hover transition-colors"
>
</button>
)}
</div>
</div>
</div>
{/* 실행 이력 테이블 */}
<div className="bg-wing-surface rounded-xl shadow-md overflow-hidden">
{loading ? (
<LoadingSpinner />
) : filteredExecutions.length === 0 ? (
<EmptyState
message="실행 이력이 없습니다."
sub={
statusFilter !== 'ALL'
? '다른 상태 필터를 선택해 보세요.'
: selectedJobs.length > 0
? '선택한 작업의 실행 이력이 없습니다.'
: undefined
}
/>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm text-left">
<thead className="bg-wing-card text-xs uppercase text-wing-muted">
<tr>
<th className="px-6 py-3 font-medium"> ID</th>
<th className="px-6 py-3 font-medium"></th>
<th className="px-6 py-3 font-medium"></th>
<th className="px-6 py-3 font-medium"></th>
<th className="px-6 py-3 font-medium"></th>
<th className="px-6 py-3 font-medium"></th>
<th className="px-6 py-3 font-medium text-right">
</th>
</tr>
</thead>
<tbody className="divide-y divide-wing-border/50">
{filteredExecutions.map((exec) => (
<tr
key={exec.executionId}
className="hover:bg-wing-hover transition-colors"
>
<td className="px-6 py-4 font-mono text-wing-text">
#{exec.executionId}
</td>
<td className="px-6 py-4 text-wing-text">
{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
파일 보기

@ -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">
&quot;{targetJob}&quot; ?
</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>
);
}

파일 보기

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

파일 보기

@ -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"
>
&larr;
</button>
<button
onClick={handleToday}
className="px-3 py-1.5 text-sm font-medium text-wing-accent bg-wing-accent/10 rounded-lg hover:bg-wing-accent/15 transition-colors"
>
</button>
<button
onClick={handleNext}
className="px-3 py-1.5 text-sm text-wing-muted bg-wing-card rounded-lg hover:bg-wing-hover transition-colors"
>
&rarr;
</button>
</div>
{/* Period Label */}
<span className="text-sm font-semibold text-wing-text">
{periodLabel}
</span>
{/* Refresh */}
<button
onClick={handleRefresh}
className="ml-auto px-3 py-1.5 text-sm text-wing-muted bg-wing-card rounded-lg hover:bg-wing-hover transition-colors"
>
</button>
</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>
);
}

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

@ -0,0 +1,58 @@
export function formatDateTime(dateTimeStr: string | null | undefined): string {
if (!dateTimeStr) return '-';
try {
const date = new Date(dateTimeStr);
if (isNaN(date.getTime())) return '-';
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
const h = String(date.getHours()).padStart(2, '0');
const min = String(date.getMinutes()).padStart(2, '0');
const s = String(date.getSeconds()).padStart(2, '0');
return `${y}-${m}-${d} ${h}:${min}:${s}`;
} catch {
return '-';
}
}
export function formatDateTimeShort(dateTimeStr: string | null | undefined): string {
if (!dateTimeStr) return '-';
try {
const date = new Date(dateTimeStr);
if (isNaN(date.getTime())) return '-';
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
const h = String(date.getHours()).padStart(2, '0');
const min = String(date.getMinutes()).padStart(2, '0');
return `${m}/${d} ${h}:${min}`;
} catch {
return '-';
}
}
export function formatDuration(ms: number | null | undefined): string {
if (ms == null || ms < 0) return '-';
const totalSeconds = Math.floor(ms / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
if (hours > 0) return `${hours}시간 ${minutes}${seconds}`;
if (minutes > 0) return `${minutes}${seconds}`;
return `${seconds}`;
}
export function calculateDuration(
startTime: string | null | undefined,
endTime: string | null | undefined,
): string {
if (!startTime) return '-';
const start = new Date(startTime).getTime();
if (isNaN(start)) return '-';
if (!endTime) return '실행 중...';
const end = new Date(endTime).getTime();
if (isNaN(end)) return '-';
return formatDuration(end - start);
}

파일 보기

@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
frontend/tsconfig.json Normal file
파일 보기

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

파일 보기

@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

21
frontend/vite.config.ts Normal file
파일 보기

@ -0,0 +1,21 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
port: 5173,
proxy: {
'/snp-api/api': {
target: 'http://localhost:8041',
changeOrigin: true,
},
},
},
base: '/snp-api/',
build: {
outDir: '../src/main/resources/static',
emptyOutDir: true,
},
})

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

파일 보기

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

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

File diff suppressed because one or more lines are too long

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()">&times;</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>