snp-batch-validation/src/main/resources/templates/schedules.html

529 lines
21 KiB
HTML
Raw Normal View 히스토리

2025-10-22 13:50:04 +09:00
<!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">
2025-10-22 13:50:04 +09:00
<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">
2025-10-22 13:50:04 +09:00
<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 = /*[[@{/}]]*/ '/';
2025-10-22 13:50:04 +09:00
// Load jobs for dropdown
async function loadJobs() {
try {
const response = await fetch(contextPath + 'api/batch/jobs');
2025-10-22 13:50:04 +09:00
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}`);
2025-10-22 13:50:04 +09:00
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');
2025-10-22 13:50:04 +09:00
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';
2025-10-22 13:50:04 +09:00
let scheduleExists = false;
try {
const checkResponse = await fetch(contextPath + `api/batch/schedules/${jobName}`);
2025-10-22 13:50:04 +09:00
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`;
2025-10-22 13:50:04 +09:00
}
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',
2025-10-22 13:50:04 +09:00
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'
2025-10-22 13:50:04 +09:00
});
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>