headscale-tailscale-replace.../farmq-admin/templates/pbs/monitoring.html
시골약사 7aa08682b8 Implement smart Magic DNS copy with automatic port detection
### 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>
2025-09-14 10:48:47 +09:00

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 %}