### Magic DNS Smart Copy Features: - **PBS servers**: Automatically append `:8007` port when copying - **PVE servers**: Automatically append `:8006` port when copying - **Other machines**: Copy Magic DNS address without port (existing behavior) ### UI Improvements: - PBS servers: Blue button with `:8007` port hint - PVE servers: Orange button with `:8006` port hint - Enhanced tooltips with service-specific port information - Visual distinction between different server types ### PBS Backup Server Monitoring: - Complete PBS API integration with authentication - Real-time backup/restore task monitoring with detailed logs - Namespace-separated backup visualization with color coding - Datastore usage monitoring and status tracking - Task history with success/failure status and error details ### Technical Implementation: - Smart port detection via JavaScript `addSmartPort()` function - Jinja2 template logic for conditional button styling - PBS API endpoints for comprehensive backup monitoring - Enhanced clipboard functionality with user feedback 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1330 lines
51 KiB
HTML
1330 lines
51 KiB
HTML
{% extends "base.html" %}
|
|
|
|
{% block title %}PBS 백업 서버 모니터링 - 팜큐 약국 관리 시스템{% endblock %}
|
|
|
|
{% block extra_css %}
|
|
<style>
|
|
.pbs-status-card {
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
color: white;
|
|
border-radius: 15px;
|
|
padding: 2rem;
|
|
margin-bottom: 2rem;
|
|
}
|
|
|
|
.pbs-status-card .status-indicator {
|
|
font-size: 1.2rem;
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.pbs-status-card .status-indicator.online {
|
|
color: #4ade80;
|
|
}
|
|
|
|
.pbs-status-card .status-indicator.offline {
|
|
color: #f87171;
|
|
}
|
|
|
|
.datastore-card {
|
|
border: none;
|
|
border-radius: 12px;
|
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
|
margin-bottom: 1.5rem;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.datastore-header {
|
|
background: linear-gradient(135deg, #1e40af 0%, #3b82f6 100%);
|
|
color: white;
|
|
padding: 1rem 1.5rem;
|
|
}
|
|
|
|
.usage-progress {
|
|
height: 8px;
|
|
border-radius: 4px;
|
|
overflow: hidden;
|
|
background-color: rgba(0, 0, 0, 0.1);
|
|
margin-top: 0.5rem;
|
|
}
|
|
|
|
.usage-progress .progress-bar {
|
|
height: 100%;
|
|
border-radius: 4px;
|
|
transition: width 0.3s ease;
|
|
}
|
|
|
|
.usage-progress .progress-bar.low {
|
|
background-color: #10b981;
|
|
}
|
|
|
|
.usage-progress .progress-bar.medium {
|
|
background-color: #f59e0b;
|
|
}
|
|
|
|
.usage-progress .progress-bar.high {
|
|
background-color: #ef4444;
|
|
}
|
|
|
|
.task-item {
|
|
padding: 1rem;
|
|
border-bottom: 1px solid #e5e7eb;
|
|
transition: background-color 0.2s ease;
|
|
}
|
|
|
|
.task-item:hover {
|
|
background-color: #f8fafc;
|
|
}
|
|
|
|
.task-item:last-child {
|
|
border-bottom: none;
|
|
}
|
|
|
|
.task-status {
|
|
padding: 0.25rem 0.75rem;
|
|
border-radius: 20px;
|
|
font-size: 0.8rem;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.task-status.running {
|
|
background-color: #dbeafe;
|
|
color: #1d4ed8;
|
|
}
|
|
|
|
.task-status.ok {
|
|
background-color: #d1fae5;
|
|
color: #065f46;
|
|
}
|
|
|
|
.task-status.error {
|
|
background-color: #fee2e2;
|
|
color: #991b1b;
|
|
}
|
|
|
|
.refresh-btn {
|
|
position: fixed;
|
|
bottom: 2rem;
|
|
right: 2rem;
|
|
z-index: 1000;
|
|
border-radius: 50%;
|
|
width: 60px;
|
|
height: 60px;
|
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
|
animation: pulse 2s infinite;
|
|
}
|
|
|
|
/* 네임스페이스별 구분 스타일 */
|
|
.namespace-section {
|
|
border-radius: 12px;
|
|
overflow: hidden;
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
|
margin-bottom: 2rem;
|
|
}
|
|
|
|
.namespace-header {
|
|
background: linear-gradient(135deg, var(--bs-primary) 0%, var(--bs-primary-rgb, 13, 110, 253) 100%);
|
|
color: white;
|
|
padding: 1rem 1.5rem;
|
|
border-bottom: 3px solid rgba(255, 255, 255, 0.2);
|
|
}
|
|
|
|
.namespace-stats {
|
|
background: rgba(255, 255, 255, 0.1);
|
|
border-radius: 8px;
|
|
padding: 0.75rem;
|
|
margin-top: 0.5rem;
|
|
}
|
|
|
|
.backup-row-namespace {
|
|
transition: all 0.2s ease;
|
|
}
|
|
|
|
.backup-row-namespace:hover {
|
|
background-color: rgba(var(--bs-primary-rgb), 0.05) !important;
|
|
transform: translateX(3px);
|
|
}
|
|
|
|
.snapshot-card {
|
|
transition: transform 0.2s ease;
|
|
border: 2px solid transparent;
|
|
}
|
|
|
|
.snapshot-card:hover {
|
|
transform: translateY(-2px);
|
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
|
}
|
|
|
|
.namespace-badge {
|
|
font-size: 0.9rem;
|
|
padding: 0.5rem 1rem;
|
|
border-radius: 25px;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
}
|
|
|
|
/* 네임스페이스별 색상 테마 */
|
|
.ns-primary {
|
|
--ns-color: var(--bs-primary);
|
|
--ns-bg: var(--bs-primary-rgb);
|
|
}
|
|
|
|
.ns-success {
|
|
--ns-color: var(--bs-success);
|
|
--ns-bg: var(--bs-success-rgb);
|
|
}
|
|
|
|
.ns-warning {
|
|
--ns-color: var(--bs-warning);
|
|
--ns-bg: var(--bs-warning-rgb);
|
|
}
|
|
|
|
.ns-info {
|
|
--ns-color: var(--bs-info);
|
|
--ns-bg: var(--bs-info-rgb);
|
|
}
|
|
|
|
.ns-danger {
|
|
--ns-color: var(--bs-danger);
|
|
--ns-bg: var(--bs-danger-rgb);
|
|
}
|
|
|
|
@keyframes pulse {
|
|
0% {
|
|
transform: scale(1);
|
|
}
|
|
50% {
|
|
transform: scale(1.05);
|
|
}
|
|
100% {
|
|
transform: scale(1);
|
|
}
|
|
}
|
|
|
|
.stats-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
gap: 1rem;
|
|
margin-bottom: 2rem;
|
|
}
|
|
|
|
.stat-box {
|
|
background: white;
|
|
padding: 1.5rem;
|
|
border-radius: 12px;
|
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
|
text-align: center;
|
|
}
|
|
|
|
.stat-box .stat-icon {
|
|
font-size: 2rem;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
|
|
.stat-box .stat-value {
|
|
font-size: 1.5rem;
|
|
font-weight: bold;
|
|
margin-bottom: 0.25rem;
|
|
}
|
|
|
|
.stat-box .stat-label {
|
|
font-size: 0.9rem;
|
|
color: #6b7280;
|
|
}
|
|
</style>
|
|
{% endblock %}
|
|
|
|
{% block breadcrumb %}
|
|
<nav aria-label="breadcrumb">
|
|
<ol class="breadcrumb">
|
|
<li class="breadcrumb-item"><a href="{{ url_for('dashboard') }}">대시보드</a></li>
|
|
<li class="breadcrumb-item active">PBS 백업 서버</li>
|
|
</ol>
|
|
</nav>
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
|
<h2><i class="fas fa-server text-primary"></i> PBS 백업 서버 모니터링</h2>
|
|
<div>
|
|
<button class="btn btn-outline-primary" onclick="refreshPBSData()">
|
|
<i class="fas fa-sync-alt"></i> 새로고침
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- PBS 서버 상태 카드 -->
|
|
<div class="pbs-status-card" id="pbs-status-card">
|
|
<div class="row align-items-center">
|
|
<div class="col-md-8">
|
|
<div class="status-indicator" id="pbs-status">
|
|
<i class="fas fa-circle"></i> <span id="status-text">연결 확인 중...</span>
|
|
</div>
|
|
<h4 id="pbs-server-info">Proxmox Backup Server</h4>
|
|
<p class="mb-0" id="pbs-version">Version: 확인 중...</p>
|
|
</div>
|
|
<div class="col-md-4 text-end">
|
|
<div class="text-white-50">
|
|
<small id="last-updated">마지막 업데이트: -</small>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 통계 요약 -->
|
|
<div class="stats-grid" id="pbs-stats">
|
|
<!-- JavaScript로 동적 생성 -->
|
|
</div>
|
|
|
|
<!-- 데이터스토어 정보 -->
|
|
<div class="row">
|
|
<div class="col-md-8">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h5 class="card-title mb-0">
|
|
<i class="fas fa-database text-primary"></i> 데이터스토어 현황
|
|
</h5>
|
|
</div>
|
|
<div class="card-body p-0" id="datastores-container">
|
|
<!-- JavaScript로 동적 생성 -->
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 작업 상태 -->
|
|
<div class="col-md-4">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h5 class="card-title mb-0">
|
|
<i class="fas fa-tasks text-warning"></i> 작업 상태
|
|
</h5>
|
|
</div>
|
|
<div class="card-body p-0" id="tasks-container">
|
|
<!-- JavaScript로 동적 생성 -->
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 복구/백업 작업 현황 -->
|
|
<div class="row mt-4">
|
|
<div class="col-md-6">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h5 class="card-title mb-0">
|
|
<i class="fas fa-undo text-info"></i> 복구 작업 이력
|
|
</h5>
|
|
</div>
|
|
<div class="card-body p-0" id="restore-tasks-container">
|
|
<!-- JavaScript로 동적 생성 -->
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-6">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h5 class="card-title mb-0">
|
|
<i class="fas fa-tasks text-warning"></i> 백업 작업 이력
|
|
</h5>
|
|
</div>
|
|
<div class="card-body p-0" id="backup-tasks-container">
|
|
<!-- JavaScript로 동적 생성 -->
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 백업 현황 -->
|
|
<div class="row mt-4">
|
|
<div class="col-12">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h5 class="card-title mb-0">
|
|
<i class="fas fa-archive text-success"></i> 최근 백업 현황
|
|
</h5>
|
|
</div>
|
|
<div class="card-body" id="backups-container">
|
|
<!-- JavaScript로 동적 생성 -->
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 작업 로그 모달 -->
|
|
<div class="modal fade" id="taskLogModal" tabindex="-1">
|
|
<div class="modal-dialog modal-lg">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title">
|
|
<i class="fas fa-file-alt"></i> 작업 로그
|
|
</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div id="task-log-content">
|
|
<div class="text-center py-3">
|
|
<div class="spinner-border text-primary" role="status">
|
|
<span class="visually-hidden">로딩 중...</span>
|
|
</div>
|
|
<div class="mt-2">로그 로딩 중...</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">닫기</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 새로고침 버튼 -->
|
|
<button class="btn btn-primary refresh-btn" onclick="refreshPBSData()" title="데이터 새로고침">
|
|
<i class="fas fa-sync-alt"></i>
|
|
</button>
|
|
|
|
<!-- 토스트 컨테이너 -->
|
|
<div id="toast-container" class="toast-container position-fixed top-0 end-0 p-3" style="z-index: 1200;"></div>
|
|
{% endblock %}
|
|
|
|
{% block extra_js %}
|
|
<script>
|
|
let refreshInterval;
|
|
|
|
// 페이지 로드시 데이터 로드
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
loadPBSData();
|
|
|
|
// 30초마다 자동 새로고침
|
|
refreshInterval = setInterval(refreshPBSData, 30000);
|
|
});
|
|
|
|
// 페이지 언로드시 인터벌 정리
|
|
window.addEventListener('beforeunload', function() {
|
|
if (refreshInterval) {
|
|
clearInterval(refreshInterval);
|
|
}
|
|
});
|
|
|
|
function loadPBSData() {
|
|
showLoading();
|
|
|
|
// PBS 상태 조회
|
|
fetch('/api/pbs/status')
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
if (data.success) {
|
|
updatePBSStatus(data.data);
|
|
} else {
|
|
showError('PBS 상태 조회 실패: ' + data.error);
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('PBS 상태 조회 오류:', error);
|
|
showError('PBS 서버 연결 실패');
|
|
updatePBSStatus({status: 'offline', error: error.message});
|
|
});
|
|
|
|
// 데이터스토어 정보 조회
|
|
fetch('/api/pbs/datastores')
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
if (data.success) {
|
|
updateDatastores(data.data);
|
|
updateStats(data.data);
|
|
} else {
|
|
showError('데이터스토어 조회 실패: ' + data.error);
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('데이터스토어 조회 오류:', error);
|
|
});
|
|
|
|
// 작업 상태 조회
|
|
fetch('/api/pbs/tasks')
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
if (data.success) {
|
|
updateTasks(data.data);
|
|
} else {
|
|
showError('작업 상태 조회 실패: ' + data.error);
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('작업 상태 조회 오류:', error);
|
|
});
|
|
|
|
// 복구/백업 작업 이력 조회
|
|
fetch('/api/pbs/restore-tasks')
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
if (data.success) {
|
|
updateRestoreTasks(data.data.restore_tasks);
|
|
updateBackupTasks(data.data.backup_tasks);
|
|
} else {
|
|
console.warn('작업 이력 조회 실패:', data.error);
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('작업 이력 조회 오류:', error);
|
|
});
|
|
|
|
// 백업 현황 조회 (기본 데이터스토어: zfs-pbs)
|
|
fetch('/api/pbs/backups/zfs-pbs')
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
if (data.success) {
|
|
updateBackups(data.data);
|
|
} else {
|
|
console.warn('백업 현황 조회 실패:', data.error);
|
|
document.getElementById('backups-container').innerHTML =
|
|
'<p class="text-muted text-center py-4">백업 데이터를 불러올 수 없습니다.</p>';
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('백업 현황 조회 오류:', error);
|
|
});
|
|
}
|
|
|
|
function updatePBSStatus(data) {
|
|
const statusElement = document.getElementById('pbs-status');
|
|
const statusText = document.getElementById('status-text');
|
|
const versionElement = document.getElementById('pbs-version');
|
|
const lastUpdated = document.getElementById('last-updated');
|
|
|
|
if (data.status === 'online') {
|
|
statusElement.className = 'status-indicator online';
|
|
statusText.textContent = '온라인';
|
|
versionElement.textContent = `Version: ${data.version}-${data.release}`;
|
|
} else {
|
|
statusElement.className = 'status-indicator offline';
|
|
statusText.textContent = '오프라인';
|
|
versionElement.textContent = 'Version: 연결 불가';
|
|
}
|
|
|
|
lastUpdated.textContent = `마지막 업데이트: ${new Date().toLocaleTimeString()}`;
|
|
}
|
|
|
|
function updateStats(datastores) {
|
|
const statsContainer = document.getElementById('pbs-stats');
|
|
|
|
let totalStorage = 0;
|
|
let usedStorage = 0;
|
|
let availStorage = 0;
|
|
|
|
datastores.forEach(store => {
|
|
totalStorage += store.total_gb;
|
|
usedStorage += store.used_gb;
|
|
availStorage += store.avail_gb;
|
|
});
|
|
|
|
const usagePercent = totalStorage > 0 ? Math.round((usedStorage / totalStorage) * 100) : 0;
|
|
|
|
statsContainer.innerHTML = `
|
|
<div class="stat-box">
|
|
<div class="stat-icon text-primary">
|
|
<i class="fas fa-hdd"></i>
|
|
</div>
|
|
<div class="stat-value text-primary">${totalStorage.toFixed(1)} GB</div>
|
|
<div class="stat-label">총 저장공간</div>
|
|
</div>
|
|
<div class="stat-box">
|
|
<div class="stat-icon text-warning">
|
|
<i class="fas fa-chart-pie"></i>
|
|
</div>
|
|
<div class="stat-value text-warning">${usedStorage.toFixed(1)} GB</div>
|
|
<div class="stat-label">사용 중 (${usagePercent}%)</div>
|
|
</div>
|
|
<div class="stat-box">
|
|
<div class="stat-icon text-success">
|
|
<i class="fas fa-check-circle"></i>
|
|
</div>
|
|
<div class="stat-value text-success">${availStorage.toFixed(1)} GB</div>
|
|
<div class="stat-label">여유 공간</div>
|
|
</div>
|
|
<div class="stat-box">
|
|
<div class="stat-icon text-info">
|
|
<i class="fas fa-database"></i>
|
|
</div>
|
|
<div class="stat-value text-info">${datastores.length}</div>
|
|
<div class="stat-label">데이터스토어</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function updateDatastores(datastores) {
|
|
const container = document.getElementById('datastores-container');
|
|
|
|
if (datastores.length === 0) {
|
|
container.innerHTML = '<p class="text-muted text-center py-4">데이터스토어가 없습니다.</p>';
|
|
return;
|
|
}
|
|
|
|
let html = '';
|
|
datastores.forEach(store => {
|
|
const usageClass = store.usage_percent > 80 ? 'high' : store.usage_percent > 60 ? 'medium' : 'low';
|
|
|
|
html += `
|
|
<div class="datastore-card">
|
|
<div class="datastore-header">
|
|
<div class="d-flex justify-content-between align-items-center">
|
|
<div>
|
|
<h6 class="mb-1">${store.name}</h6>
|
|
<small class="opacity-75">${store.comment || store.path}</small>
|
|
</div>
|
|
<div class="text-end">
|
|
<div class="h5 mb-0">${store.usage_percent}%</div>
|
|
<small>사용률</small>
|
|
</div>
|
|
</div>
|
|
<div class="usage-progress">
|
|
<div class="progress-bar ${usageClass}" style="width: ${store.usage_percent}%"></div>
|
|
</div>
|
|
</div>
|
|
<div class="p-3">
|
|
<div class="row text-center">
|
|
<div class="col-4">
|
|
<div class="fw-bold text-primary">${store.total_gb} GB</div>
|
|
<small class="text-muted">총 용량</small>
|
|
</div>
|
|
<div class="col-4">
|
|
<div class="fw-bold text-warning">${store.used_gb} GB</div>
|
|
<small class="text-muted">사용 중</small>
|
|
</div>
|
|
<div class="col-4">
|
|
<div class="fw-bold text-success">${store.avail_gb} GB</div>
|
|
<small class="text-muted">여유 공간</small>
|
|
</div>
|
|
</div>
|
|
${store.error ? `<div class="alert alert-warning mt-2 mb-0"><small>${store.error}</small></div>` : ''}
|
|
</div>
|
|
</div>
|
|
`;
|
|
});
|
|
|
|
container.innerHTML = html;
|
|
}
|
|
|
|
function updateTasks(data) {
|
|
const container = document.getElementById('tasks-container');
|
|
const runningTasks = data.running_tasks || [];
|
|
const recentTasks = data.recent_tasks || [];
|
|
|
|
if (runningTasks.length === 0 && recentTasks.length === 0) {
|
|
container.innerHTML = '<p class="text-muted text-center py-4">작업이 없습니다.</p>';
|
|
return;
|
|
}
|
|
|
|
let html = '';
|
|
|
|
// 실행 중인 작업
|
|
runningTasks.forEach(task => {
|
|
html += createTaskHTML(task, 'running');
|
|
});
|
|
|
|
// 최근 작업 (실행 중이 아닌 것만)
|
|
recentTasks.filter(task => task.status !== 'running').slice(0, 5).forEach(task => {
|
|
html += createTaskHTML(task, task.status || 'unknown');
|
|
});
|
|
|
|
container.innerHTML = html;
|
|
}
|
|
|
|
function createTaskHTML(task, status) {
|
|
const statusClass = status === 'running' ? 'running' : status === 'OK' ? 'ok' : 'error';
|
|
const statusText = status === 'running' ? '실행 중' : status === 'OK' ? '완료' : '오류';
|
|
const icon = status === 'running' ? 'fa-spinner fa-spin' : status === 'OK' ? 'fa-check' : 'fa-times';
|
|
|
|
return `
|
|
<div class="task-item">
|
|
<div class="d-flex justify-content-between align-items-start">
|
|
<div class="flex-grow-1">
|
|
<div class="fw-bold">${task.type || '알 수 없음'}</div>
|
|
<div class="small text-muted">${task.node || 'localhost'}</div>
|
|
</div>
|
|
<div class="text-end">
|
|
<div class="task-status ${statusClass}">
|
|
<i class="fas ${icon}"></i> ${statusText}
|
|
</div>
|
|
${task.starttime ? `<div class="small text-muted mt-1">${new Date(task.starttime * 1000).toLocaleString()}</div>` : ''}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function updateBackups(data) {
|
|
const container = document.getElementById('backups-container');
|
|
|
|
if (!data || !data.backups || data.backups.length === 0) {
|
|
container.innerHTML = '<p class="text-muted text-center py-4">백업 데이터가 없습니다.</p>';
|
|
return;
|
|
}
|
|
|
|
const { namespaces, backups, total_groups, datastore } = data;
|
|
|
|
// 네임스페이스별로 백업 그룹화
|
|
const backupsByNamespace = {};
|
|
backups.forEach(backup => {
|
|
const ns = backup.namespace === 'root' ? 'ROOT' : backup.namespace;
|
|
if (!backupsByNamespace[ns]) {
|
|
backupsByNamespace[ns] = [];
|
|
}
|
|
backupsByNamespace[ns].push(backup);
|
|
});
|
|
|
|
// 네임스페이스 색상 매핑
|
|
const namespaceColors = {
|
|
'ROOT': 'primary',
|
|
'p00001': 'success',
|
|
'p00002': 'warning',
|
|
'p00003': 'info',
|
|
'p00004': 'danger'
|
|
};
|
|
|
|
let html = `
|
|
<div class="mb-3">
|
|
<div class="row">
|
|
<div class="col-md-6">
|
|
<h6><i class="fas fa-folder-open text-primary"></i> 네임스페이스:
|
|
<span class="badge bg-info">${namespaces.length}</span>
|
|
</h6>
|
|
<div class="small">
|
|
${namespaces.slice(0, 5).map(ns => {
|
|
const label = ns || 'ROOT';
|
|
const color = namespaceColors[label] || 'secondary';
|
|
return `<span class="badge bg-${color} me-1">${label}</span>`;
|
|
}).join('')}
|
|
${namespaces.length > 5 ? '<span class="text-muted">...</span>' : ''}
|
|
</div>
|
|
</div>
|
|
<div class="col-md-6 text-end">
|
|
<h6><i class="fas fa-boxes text-success"></i> 총 백업 그룹: <span class="text-primary">${total_groups}</span></h6>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
// 네임스페이스별로 섹션 나누어 표시
|
|
Object.keys(backupsByNamespace).forEach((namespace, nsIndex) => {
|
|
const nsBackups = backupsByNamespace[namespace];
|
|
const nsColor = namespaceColors[namespace] || 'secondary';
|
|
const nsIcon = namespace === 'ROOT' ? 'fa-home' : 'fa-folder';
|
|
|
|
html += `
|
|
<div class="namespace-section ns-${nsColor}">
|
|
<div class="namespace-header">
|
|
<div class="d-flex justify-content-between align-items-center">
|
|
<div>
|
|
<h5 class="mb-0">
|
|
<i class="fas ${nsIcon} me-2"></i>
|
|
${namespace} 네임스페이스
|
|
</h5>
|
|
<small class="opacity-75">${nsBackups.length}개 백업 그룹</small>
|
|
</div>
|
|
<div class="namespace-stats">
|
|
<div class="row text-center">
|
|
<div class="col-3">
|
|
<div class="fw-bold">${nsBackups.reduce((sum, b) => sum + b.snapshot_count, 0)}</div>
|
|
<small style="font-size: 0.7rem;">스냅샷</small>
|
|
</div>
|
|
<div class="col-3">
|
|
<div class="fw-bold">${formatBytes(nsBackups.reduce((sum, b) => sum + b.total_size, 0))}</div>
|
|
<small style="font-size: 0.7rem;">총 크기</small>
|
|
</div>
|
|
<div class="col-3">
|
|
<div class="fw-bold">${nsBackups.filter(b => b.last_backup && (Date.now()/1000 - b.last_backup) < 86400).length}</div>
|
|
<small style="font-size: 0.7rem;">오늘</small>
|
|
</div>
|
|
<div class="col-3">
|
|
<div class="fw-bold">${nsBackups.filter(b => b.last_verification?.state).length}</div>
|
|
<small style="font-size: 0.7rem;">검증됨</small>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card border-0 rounded-0">
|
|
<div class="card-body p-0">
|
|
<div class="table-responsive">
|
|
<table class="table table-hover mb-0">
|
|
<thead class="table-light">
|
|
<tr>
|
|
<th>타입/ID</th>
|
|
<th>마지막 백업</th>
|
|
<th>스냅샷</th>
|
|
<th>총 크기</th>
|
|
<th>최신 크기</th>
|
|
<th>상태</th>
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
`;
|
|
|
|
nsBackups.forEach((backup, index) => {
|
|
const globalIndex = `${nsIndex}-${index}`;
|
|
const lastBackup = backup.last_backup ? new Date(backup.last_backup * 1000).toLocaleString() : '없음';
|
|
const totalSize = formatBytes(backup.total_size);
|
|
const latestSize = formatBytes(backup.latest_size);
|
|
const backupAge = backup.last_backup ? Math.floor((Date.now() / 1000 - backup.last_backup) / 86400) : null;
|
|
const ageColor = backupAge === null ? 'secondary' : backupAge < 1 ? 'success' : backupAge < 7 ? 'warning' : 'danger';
|
|
const ageText = backupAge === null ? '알 수 없음' : backupAge === 0 ? '오늘' : `${backupAge}일 전`;
|
|
|
|
html += `
|
|
<tr onclick="toggleBackupDetails('${globalIndex}')" style="cursor: pointer;" class="backup-row-namespace border-start border-${nsColor} border-3">
|
|
<td>
|
|
<div class="d-flex align-items-center">
|
|
<span class="badge bg-${nsColor} me-2">${backup.type}</span>
|
|
<span class="fw-bold">${backup.id}</span>
|
|
</div>
|
|
</td>
|
|
<td>
|
|
<div>${lastBackup}</div>
|
|
<small class="badge bg-${ageColor}">${ageText}</small>
|
|
</td>
|
|
<td>
|
|
<span class="badge bg-info">${backup.snapshot_count}</span>
|
|
<div class="small text-muted">${backup.snapshots.length}개 표시</div>
|
|
</td>
|
|
<td class="fw-bold text-primary">${totalSize}</td>
|
|
<td class="text-success">${latestSize}</td>
|
|
<td>
|
|
${backup.last_verification && backup.last_verification.state ?
|
|
`<span class="badge bg-success"><i class="fas fa-check"></i> 검증됨</span>` :
|
|
`<span class="badge bg-warning"><i class="fas fa-question"></i> 미검증</span>`
|
|
}
|
|
</td>
|
|
<td>
|
|
<i class="fas fa-chevron-down" id="toggle-icon-${globalIndex}"></i>
|
|
</td>
|
|
</tr>
|
|
<tr id="backup-details-${globalIndex}" style="display: none;">
|
|
<td colspan="7">
|
|
<div class="p-3 bg-${nsColor} bg-opacity-5 border-start border-${nsColor} border-4">
|
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
<h6 class="mb-0">
|
|
<i class="fas fa-history text-${nsColor}"></i>
|
|
${backup.type} ${backup.id} 스냅샷 히스토리
|
|
</h6>
|
|
<span class="badge bg-${nsColor}">${namespace}</span>
|
|
</div>
|
|
<div class="row">
|
|
`;
|
|
|
|
// 스냅샷 세부 정보
|
|
backup.snapshots.forEach((snapshot, snapIndex) => {
|
|
const snapTime = snapshot.backup_time ? new Date(snapshot.backup_time * 1000).toLocaleString() : '알 수 없음';
|
|
const snapSize = formatBytes(snapshot.size);
|
|
|
|
html += `
|
|
<div class="col-md-4 mb-3">
|
|
<div class="card h-100 border-${nsColor} snapshot-card">
|
|
<div class="card-header py-2 bg-${nsColor} bg-opacity-10">
|
|
<div class="d-flex justify-content-between align-items-center">
|
|
<small class="fw-bold text-${nsColor}">스냅샷 #${snapIndex + 1}</small>
|
|
<div>
|
|
${snapshot.protected ? '<i class="fas fa-lock text-warning" title="보호됨"></i>' : ''}
|
|
${snapshot.verification && snapshot.verification.state === 'ok' ?
|
|
'<i class="fas fa-check-circle text-success" title="검증됨"></i>' : ''}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="card-body py-2">
|
|
<div class="small">
|
|
<div class="fw-bold mb-1">${snapTime}</div>
|
|
<div class="text-success mb-1">${snapSize}</div>
|
|
${snapshot.comment ?
|
|
`<div class="text-info"><i class="fas fa-comment"></i> ${snapshot.comment}</div>` :
|
|
'<div class="text-muted">코멘트 없음</div>'
|
|
}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
});
|
|
|
|
html += `
|
|
</div>
|
|
${backup.group_comment ?
|
|
`<div class="mt-3 p-2 bg-${nsColor} bg-opacity-10 rounded">
|
|
<strong class="text-${nsColor}">그룹 설명:</strong> ${backup.group_comment}
|
|
</div>` : ''
|
|
}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
`;
|
|
});
|
|
|
|
html += `
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
});
|
|
|
|
html += `
|
|
${total_groups > backups.length ?
|
|
`<div class="text-center mt-3">
|
|
<div class="alert alert-info">
|
|
<i class="fas fa-info-circle"></i>
|
|
총 ${total_groups}개 그룹 중 ${backups.length}개 표시
|
|
${total_groups > 50 ? ' (성능을 위해 최대 50개만 표시)' : ''}
|
|
</div>
|
|
</div>` : ''
|
|
}
|
|
`;
|
|
|
|
container.innerHTML = html;
|
|
}
|
|
|
|
// 백업 세부 정보 토글
|
|
function toggleBackupDetails(index) {
|
|
const detailsRow = document.getElementById(`backup-details-${index}`);
|
|
const icon = document.getElementById(`toggle-icon-${index}`);
|
|
|
|
if (detailsRow.style.display === 'none') {
|
|
detailsRow.style.display = '';
|
|
icon.className = 'fas fa-chevron-up';
|
|
} else {
|
|
detailsRow.style.display = 'none';
|
|
icon.className = 'fas fa-chevron-down';
|
|
}
|
|
}
|
|
|
|
function formatBytes(bytes) {
|
|
if (bytes === 0) return '0 Bytes';
|
|
const k = 1024;
|
|
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
|
}
|
|
|
|
function refreshPBSData() {
|
|
showToast('PBS 데이터를 새로고침하고 있습니다...', 'info');
|
|
loadPBSData();
|
|
}
|
|
|
|
function showLoading() {
|
|
// 로딩 상태 표시 (필요시 구현)
|
|
}
|
|
|
|
function showError(message) {
|
|
showToast(message, 'danger');
|
|
}
|
|
|
|
function updateRestoreTasks(tasks) {
|
|
const container = document.getElementById('restore-tasks-container');
|
|
|
|
if (!tasks || tasks.length === 0) {
|
|
container.innerHTML = '<p class="text-muted text-center py-4">복구 작업이 없습니다.</p>';
|
|
return;
|
|
}
|
|
|
|
let html = '';
|
|
tasks.slice(0, 10).forEach(task => {
|
|
const startTime = task.starttime ? new Date(task.starttime * 1000).toLocaleString() : '알 수 없음';
|
|
const endTime = task.endtime ? new Date(task.endtime * 1000).toLocaleString() : '진행 중';
|
|
const duration = task.starttime && task.endtime ?
|
|
Math.round((task.endtime - task.starttime) / 60) + '분' : '-';
|
|
|
|
// PBS 상태 판단 로직 개선
|
|
const isRunning = task.status === 'running';
|
|
const isSuccess = task.status === 'OK' || task.status === 'ok' ||
|
|
(task.exitstatus && task.exitstatus.toLowerCase() === 'ok');
|
|
const isError = task.status && task.status.toLowerCase().includes('error');
|
|
|
|
const statusClass = isRunning ? 'info' :
|
|
isSuccess ? 'success' :
|
|
isError ? 'danger' : 'secondary';
|
|
const statusText = isRunning ? '실행 중' :
|
|
isSuccess ? '성공' :
|
|
isError ? '실패' : '완료';
|
|
|
|
// Worker ID에서 상세 정보 추출
|
|
const workerId = task.worker_id || '';
|
|
const { datastore, vmInfo, snapshotId, namespace } = parseWorkerId(workerId);
|
|
|
|
html += `
|
|
<div class="border-bottom p-3 task-item" style="cursor: pointer;" onclick="showTaskDetails('${task.id}', '${task.type}', '${escapeQuotes(JSON.stringify(task))}')">
|
|
<div class="d-flex justify-content-between align-items-start">
|
|
<div class="flex-grow-1">
|
|
<div class="d-flex align-items-center mb-2">
|
|
<span class="badge bg-info me-2">${task.type}</span>
|
|
<span class="badge bg-${statusClass}">${statusText}</span>
|
|
${vmInfo ? `<span class="badge bg-primary ms-2"><i class="fas fa-desktop"></i> ${vmInfo}</span>` : ''}
|
|
</div>
|
|
${datastore ? `<div class="small text-info mb-1"><i class="fas fa-database"></i> 데이터스토어: ${datastore}</div>` : ''}
|
|
${namespace ? `<div class="small text-warning mb-1"><i class="fas fa-folder"></i> 네임스페이스: ${namespace}</div>` : ''}
|
|
${snapshotId ? `<div class="small text-success mb-1"><i class="fas fa-camera"></i> 스냅샷: ${snapshotId}</div>` : ''}
|
|
<div class="small text-muted">
|
|
<div><i class="fas fa-play"></i> 시작: ${startTime}</div>
|
|
${task.endtime ? `<div><i class="fas fa-stop"></i> 종료: ${endTime}</div>` : ''}
|
|
<div><i class="fas fa-clock"></i> 소요: ${duration}</div>
|
|
<div><i class="fas fa-user"></i> 사용자: ${task.user || 'system'}</div>
|
|
</div>
|
|
${isError ? `<div class="small text-danger mt-1"><i class="fas fa-exclamation-triangle"></i> 오류: ${task.status}</div>` : ''}
|
|
</div>
|
|
<div class="text-end">
|
|
<i class="fas fa-info-circle text-muted"></i>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
});
|
|
|
|
container.innerHTML = html;
|
|
}
|
|
|
|
function updateBackupTasks(tasks) {
|
|
const container = document.getElementById('backup-tasks-container');
|
|
|
|
if (!tasks || tasks.length === 0) {
|
|
container.innerHTML = '<p class="text-muted text-center py-4">백업 작업이 없습니다.</p>';
|
|
return;
|
|
}
|
|
|
|
let html = '';
|
|
tasks.slice(0, 10).forEach(task => {
|
|
const startTime = task.starttime ? new Date(task.starttime * 1000).toLocaleString() : '알 수 없음';
|
|
const endTime = task.endtime ? new Date(task.endtime * 1000).toLocaleString() : '진행 중';
|
|
const duration = task.starttime && task.endtime ?
|
|
Math.round((task.endtime - task.starttime) / 60) + '분' : '-';
|
|
|
|
// PBS 상태 판단 로직 개선
|
|
const isRunning = task.status === 'running';
|
|
const isSuccess = task.status === 'OK' || task.status === 'ok' ||
|
|
(task.exitstatus && task.exitstatus.toLowerCase() === 'ok');
|
|
const isError = task.status && task.status.toLowerCase().includes('error');
|
|
|
|
const statusClass = isRunning ? 'warning' :
|
|
isSuccess ? 'success' :
|
|
isError ? 'danger' : 'secondary';
|
|
const statusText = isRunning ? '실행 중' :
|
|
isSuccess ? '성공' :
|
|
isError ? '실패' : '완료';
|
|
|
|
// Worker ID에서 상세 정보 추출
|
|
const workerId = task.worker_id || '';
|
|
const { datastore, vmInfo, snapshotId, namespace } = parseWorkerId(workerId);
|
|
|
|
html += `
|
|
<div class="border-bottom p-3 task-item" style="cursor: pointer;" onclick="showTaskDetails('${task.id}', '${task.type}', '${escapeQuotes(JSON.stringify(task))}')">
|
|
<div class="d-flex justify-content-between align-items-start">
|
|
<div class="flex-grow-1">
|
|
<div class="d-flex align-items-center mb-2">
|
|
<span class="badge bg-warning me-2">${task.type}</span>
|
|
<span class="badge bg-${statusClass}">${statusText}</span>
|
|
${vmInfo ? `<span class="badge bg-primary ms-2"><i class="fas fa-desktop"></i> ${vmInfo}</span>` : ''}
|
|
</div>
|
|
${datastore ? `<div class="small text-info mb-1"><i class="fas fa-database"></i> 데이터스토어: ${datastore}</div>` : ''}
|
|
${namespace ? `<div class="small text-warning mb-1"><i class="fas fa-folder"></i> 네임스페이스: ${namespace}</div>` : ''}
|
|
${snapshotId ? `<div class="small text-success mb-1"><i class="fas fa-camera"></i> 스냅샷: ${snapshotId}</div>` : ''}
|
|
<div class="small text-muted">
|
|
<div><i class="fas fa-play"></i> 시작: ${startTime}</div>
|
|
${task.endtime ? `<div><i class="fas fa-stop"></i> 종료: ${endTime}</div>` : ''}
|
|
<div><i class="fas fa-clock"></i> 소요: ${duration}</div>
|
|
<div><i class="fas fa-user"></i> 사용자: ${task.user || 'system'}</div>
|
|
</div>
|
|
${isError ? `<div class="small text-danger mt-1"><i class="fas fa-exclamation-triangle"></i> 오류: ${task.status}</div>` : ''}
|
|
</div>
|
|
<div class="text-end">
|
|
<i class="fas fa-info-circle text-muted"></i>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
});
|
|
|
|
container.innerHTML = html;
|
|
}
|
|
|
|
function showTaskLog(taskId, taskType) {
|
|
const modal = new bootstrap.Modal(document.getElementById('taskLogModal'));
|
|
const modalTitle = document.querySelector('#taskLogModal .modal-title');
|
|
const logContent = document.getElementById('task-log-content');
|
|
|
|
modalTitle.innerHTML = `<i class="fas fa-file-alt"></i> ${taskType} 로그 (${taskId})`;
|
|
|
|
// 로딩 상태 표시
|
|
logContent.innerHTML = `
|
|
<div class="text-center py-3">
|
|
<div class="spinner-border text-primary" role="status">
|
|
<span class="visually-hidden">로딩 중...</span>
|
|
</div>
|
|
<div class="mt-2">로그 로딩 중...</div>
|
|
</div>
|
|
`;
|
|
|
|
modal.show();
|
|
|
|
// 작업 로그 가져오기
|
|
fetch(`/api/pbs/task-log/${encodeURIComponent(taskId)}`)
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
if (data.success) {
|
|
const logLines = data.data.log_lines;
|
|
if (logLines && logLines.length > 0) {
|
|
let logHtml = '<pre class="bg-dark text-light p-3 rounded" style="max-height: 500px; overflow-y: auto;">';
|
|
logLines.forEach(line => {
|
|
logHtml += escapeHtml(line.line) + '\n';
|
|
});
|
|
logHtml += '</pre>';
|
|
logContent.innerHTML = logHtml;
|
|
} else {
|
|
logContent.innerHTML = '<p class="text-muted text-center py-4">로그가 없습니다.</p>';
|
|
}
|
|
} else {
|
|
logContent.innerHTML = `<div class="alert alert-danger">로그 조회 실패: ${data.error}</div>`;
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('로그 조회 오류:', error);
|
|
logContent.innerHTML = '<div class="alert alert-danger">로그를 불러오는 중 오류가 발생했습니다.</div>';
|
|
});
|
|
}
|
|
|
|
function parseWorkerId(workerId) {
|
|
// Worker ID 예시: "zfs-pbs:vm/100", "zfs-pbs:vm/101/68C527FB"
|
|
const result = {
|
|
datastore: null,
|
|
vmInfo: null,
|
|
snapshotId: null,
|
|
namespace: null
|
|
};
|
|
|
|
if (!workerId) return result;
|
|
|
|
try {
|
|
// 기본 형식: datastore:type/id/snapshot
|
|
const parts = workerId.split(':');
|
|
if (parts.length >= 2) {
|
|
result.datastore = parts[0];
|
|
|
|
const resourcePart = parts[1];
|
|
const resourceParts = resourcePart.split('/');
|
|
|
|
if (resourceParts.length >= 2) {
|
|
const resourceType = resourceParts[0]; // vm, ct, etc.
|
|
const resourceId = resourceParts[1]; // 100, 101, etc.
|
|
|
|
result.vmInfo = `${resourceType.toUpperCase()} ${resourceId}`;
|
|
|
|
if (resourceParts.length >= 3) {
|
|
result.snapshotId = resourceParts[2];
|
|
}
|
|
}
|
|
}
|
|
|
|
// 네임스페이스 정보가 있는 경우 추출
|
|
if (workerId.includes('@')) {
|
|
const namespacePart = workerId.split('@')[1];
|
|
if (namespacePart) {
|
|
result.namespace = namespacePart;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.warn('Worker ID 파싱 오류:', error, workerId);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
function escapeQuotes(str) {
|
|
return str.replace(/'/g, "\\'").replace(/"/g, '\\"');
|
|
}
|
|
|
|
function showTaskDetails(taskId, taskType, taskDataStr) {
|
|
let taskData;
|
|
try {
|
|
taskData = JSON.parse(taskDataStr);
|
|
} catch (error) {
|
|
console.error('작업 데이터 파싱 오류:', error);
|
|
showTaskLog(taskId, taskType);
|
|
return;
|
|
}
|
|
|
|
const modal = new bootstrap.Modal(document.getElementById('taskLogModal'));
|
|
const modalTitle = document.querySelector('#taskLogModal .modal-title');
|
|
const logContent = document.getElementById('task-log-content');
|
|
|
|
// Worker ID에서 상세 정보 추출
|
|
const { datastore, vmInfo, snapshotId, namespace } = parseWorkerId(taskData.worker_id || '');
|
|
|
|
modalTitle.innerHTML = `<i class="fas fa-info-circle"></i> 작업 상세 정보`;
|
|
|
|
// 작업 상세 정보 표시
|
|
const startTime = taskData.starttime ? new Date(taskData.starttime * 1000).toLocaleString() : '알 수 없음';
|
|
const endTime = taskData.endtime ? new Date(taskData.endtime * 1000).toLocaleString() : '진행 중';
|
|
const duration = taskData.starttime && taskData.endtime ?
|
|
Math.round((taskData.endtime - taskData.starttime) / 60) + '분' : '-';
|
|
|
|
const isRunning = taskData.status === 'running';
|
|
const isSuccess = taskData.status === 'OK' || taskData.status === 'ok';
|
|
const isError = taskData.status && taskData.status.toLowerCase().includes('error');
|
|
|
|
const statusClass = isRunning ? 'info' :
|
|
isSuccess ? 'success' :
|
|
isError ? 'danger' : 'secondary';
|
|
const statusText = isRunning ? '실행 중' :
|
|
isSuccess ? '성공' :
|
|
isError ? '실패' : '완료';
|
|
|
|
logContent.innerHTML = `
|
|
<div class="card">
|
|
<div class="card-header bg-light">
|
|
<h6 class="mb-0">
|
|
<span class="badge bg-primary me-2">${taskData.type || taskData.worker_type}</span>
|
|
<span class="badge bg-${statusClass}">${statusText}</span>
|
|
${vmInfo ? `<span class="badge bg-info ms-2"><i class="fas fa-desktop"></i> ${vmInfo}</span>` : ''}
|
|
</h6>
|
|
</div>
|
|
<div class="card-body">
|
|
<div class="row">
|
|
<div class="col-md-6">
|
|
<h6><i class="fas fa-info"></i> 기본 정보</h6>
|
|
<table class="table table-sm">
|
|
<tr><td><strong>작업 ID:</strong></td><td><code class="small">${taskData.id}</code></td></tr>
|
|
<tr><td><strong>작업 타입:</strong></td><td>${taskData.type || taskData.worker_type}</td></tr>
|
|
<tr><td><strong>상태:</strong></td><td><span class="badge bg-${statusClass}">${statusText}</span></td></tr>
|
|
<tr><td><strong>사용자:</strong></td><td>${taskData.user || 'system'}</td></tr>
|
|
<tr><td><strong>노드:</strong></td><td>${taskData.node}</td></tr>
|
|
</table>
|
|
</div>
|
|
<div class="col-md-6">
|
|
<h6><i class="fas fa-clock"></i> 시간 정보</h6>
|
|
<table class="table table-sm">
|
|
<tr><td><strong>시작 시간:</strong></td><td>${startTime}</td></tr>
|
|
<tr><td><strong>종료 시간:</strong></td><td>${endTime}</td></tr>
|
|
<tr><td><strong>소요 시간:</strong></td><td>${duration}</td></tr>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
${datastore || vmInfo || namespace || snapshotId ? `
|
|
<div class="row mt-3">
|
|
<div class="col-12">
|
|
<h6><i class="fas fa-database"></i> 리소스 정보</h6>
|
|
<table class="table table-sm">
|
|
${datastore ? `<tr><td><strong>데이터스토어:</strong></td><td><span class="badge bg-info">${datastore}</span></td></tr>` : ''}
|
|
${namespace ? `<tr><td><strong>네임스페이스:</strong></td><td><span class="badge bg-warning">${namespace}</span></td></tr>` : ''}
|
|
${vmInfo ? `<tr><td><strong>VM/컨테이너:</strong></td><td><span class="badge bg-primary">${vmInfo}</span></td></tr>` : ''}
|
|
${snapshotId ? `<tr><td><strong>스냅샷 ID:</strong></td><td><code>${snapshotId}</code></td></tr>` : ''}
|
|
${taskData.worker_id ? `<tr><td><strong>Worker ID:</strong></td><td><code class="small">${taskData.worker_id}</code></td></tr>` : ''}
|
|
</table>
|
|
</div>
|
|
</div>
|
|
` : ''}
|
|
|
|
${isError ? `
|
|
<div class="row mt-3">
|
|
<div class="col-12">
|
|
<h6><i class="fas fa-exclamation-triangle text-danger"></i> 오류 정보</h6>
|
|
<div class="alert alert-danger">
|
|
<strong>오류 메시지:</strong><br>
|
|
<code>${taskData.status}</code>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
` : ''}
|
|
|
|
<div class="row mt-3">
|
|
<div class="col-12">
|
|
<button class="btn btn-primary" onclick="loadTaskLog('${taskData.id}', '${taskData.type}')">
|
|
<i class="fas fa-file-alt"></i> 작업 로그 보기
|
|
</button>
|
|
${taskData.worker_id && vmInfo ? `
|
|
<button class="btn btn-info ms-2" onclick="showRelatedBackups('${vmInfo}')">
|
|
<i class="fas fa-archive"></i> 관련 백업 보기
|
|
</button>
|
|
` : ''}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
modal.show();
|
|
}
|
|
|
|
function loadTaskLog(taskId, taskType) {
|
|
const logContent = document.getElementById('task-log-content');
|
|
|
|
// 로딩 상태 표시
|
|
logContent.innerHTML = `
|
|
<div class="text-center py-3">
|
|
<div class="spinner-border text-primary" role="status">
|
|
<span class="visually-hidden">로딩 중...</span>
|
|
</div>
|
|
<div class="mt-2">로그 로딩 중...</div>
|
|
</div>
|
|
`;
|
|
|
|
// 작업 로그 가져오기
|
|
fetch(`/api/pbs/task-log/${encodeURIComponent(taskId)}`)
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
if (data.success) {
|
|
const logLines = data.data.log_lines;
|
|
if (logLines && logLines.length > 0) {
|
|
let logHtml = '<pre class="bg-dark text-light p-3 rounded" style="max-height: 500px; overflow-y: auto;">';
|
|
logLines.forEach(line => {
|
|
logHtml += escapeHtml(line.line) + '\n';
|
|
});
|
|
logHtml += '</pre>';
|
|
|
|
logContent.innerHTML = `
|
|
<div class="mb-3">
|
|
<button class="btn btn-secondary" onclick="showTaskDetails('${taskId}', '${taskType}', '${escapeQuotes(JSON.stringify({id: taskId, type: taskType}))}')">
|
|
<i class="fas fa-arrow-left"></i> 상세 정보로 돌아가기
|
|
</button>
|
|
</div>
|
|
${logHtml}
|
|
`;
|
|
} else {
|
|
logContent.innerHTML = '<p class="text-muted text-center py-4">로그가 없습니다.</p>';
|
|
}
|
|
} else {
|
|
logContent.innerHTML = `<div class="alert alert-danger">로그 조회 실패: ${data.error}</div>`;
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('로그 조회 오류:', error);
|
|
logContent.innerHTML = '<div class="alert alert-danger">로그를 불러오는 중 오류가 발생했습니다.</div>';
|
|
});
|
|
}
|
|
|
|
function showRelatedBackups(vmInfo) {
|
|
showToast(`${vmInfo} 관련 백업을 조회하고 있습니다...`, 'info');
|
|
// 여기에 관련 백업 조회 로직 추가 가능
|
|
}
|
|
|
|
function escapeHtml(text) {
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
function showToast(message, type = 'info') {
|
|
const toastHtml = `
|
|
<div class="toast align-items-center text-bg-${type} border-0" role="alert">
|
|
<div class="d-flex">
|
|
<div class="toast-body">${message}</div>
|
|
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
const toastContainer = document.getElementById('toast-container');
|
|
if (toastContainer) {
|
|
toastContainer.insertAdjacentHTML('beforeend', toastHtml);
|
|
const toast = new bootstrap.Toast(toastContainer.lastElementChild);
|
|
toast.show();
|
|
}
|
|
}
|
|
</script>
|
|
{% endblock %} |