594 lines
21 KiB
HTML
594 lines
21 KiB
HTML
<!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>
|