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

393 lines
14 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;
}
.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">
2025-10-22 13:50:04 +09:00
<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>
2025-10-22 13:50:04 +09:00
<script th:inline="javascript">
// Context path for API calls
const contextPath = /*[[@{/}]]*/ '/';
2025-10-22 13:50:04 +09:00
let currentJobName = null;
// Load jobs for filter 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 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`);
2025-10-22 13:50:04 +09:00
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`, {
2025-10-22 13:50:04 +09:00
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}`;
2025-10-22 13:50:04 +09:00
}
// 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>