snp-batch-validation/src/main/resources/templates/index.html
HeungTak Lee 322ecb12a6 [수정]
- url 하드코딩 제거
- bootstrap 로컬 저장, 참조수정
2025-12-04 15:38:01 +09:00

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>