- 실시간 선박 위치 조회 API Classtype 구분 파라미터 추가 (core20 테이블 imo 유무로 ClassA, ClassB 분류) - html PUT,DELETE, PATCH 메소드 제거 및 POST 메소드 사용 변경 (보안이슈)
529 lines
21 KiB
HTML
529 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>작업 스케줄 - 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>
|