393 lines
14 KiB
HTML
393 lines
14 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;
|
|
}
|
|
|
|
.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">
|
|
<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>
|
|
|
|
<script th:inline="javascript">
|
|
// Context path for API calls
|
|
const contextPath = /*[[@{/}]]*/ '/';
|
|
let currentJobName = null;
|
|
|
|
// Load jobs for filter dropdown
|
|
async function loadJobs() {
|
|
try {
|
|
const response = await fetch(contextPath + 'api/batch/jobs');
|
|
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`);
|
|
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`, {
|
|
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}`;
|
|
}
|
|
|
|
// 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>
|