headscale-tailscale-replace.../farmq-admin/templates/vm_list.html
시골약사 fb00b0a5fd Add multi-host Proxmox support with SSL certificate handling
- Added support for multiple Proxmox hosts (pve7.0bin.in:443, Healthport PVE:8006)
- Enhanced VM management APIs to accept host parameter
- Fixed WebSocket URL generation bug (dynamic port handling)
- Added comprehensive SSL certificate trust help system
- Implemented host selection dropdown in UI
- Added VNC connection failure detection and automatic SSL help redirection
- Updated session management to store host_key information
- Enhanced error handling for different Proxmox configurations

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 00:03:25 +09:00

452 lines
18 KiB
HTML

<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Proxmox VM 관리 - 팜큐 관리자</title>
<!-- Bootstrap CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<style>
body {
background-color: #f8f9fa;
}
.vm-card {
transition: transform 0.2s;
border-left: 4px solid #dee2e6;
}
.vm-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
}
.vm-card.running {
border-left-color: #28a745;
}
.vm-card.stopped {
border-left-color: #dc3545;
}
.status-badge {
font-size: 0.875rem;
}
.vm-actions .btn {
margin: 2px;
}
.resource-info {
font-size: 0.875rem;
color: #6c757d;
}
.loading-spinner {
display: none;
}
.toast-container {
position: fixed;
top: 20px;
right: 20px;
z-index: 1050;
}
</style>
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container">
<a class="navbar-brand" href="/">
<i class="fas fa-hospital-alt"></i> 팜큐 관리자
</a>
<div class="navbar-nav">
<a class="nav-link" href="/">대시보드</a>
<a class="nav-link" href="/machines">머신 목록</a>
<a class="nav-link active" href="/vms">VM 관리</a>
</div>
</div>
</nav>
<div class="container mt-4">
<!-- 헤더 -->
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="fas fa-desktop"></i> Proxmox VM 관리</h2>
<div class="d-flex align-items-center gap-3">
<!-- 호스트 선택 드롭다운 -->
<div class="dropdown">
<button class="btn btn-outline-secondary dropdown-toggle" type="button" id="hostSelector" data-bs-toggle="dropdown" aria-expanded="false">
<i class="fas fa-server"></i> {{ current_host_name or '호스트 선택' }}
</button>
<ul class="dropdown-menu" aria-labelledby="hostSelector">
{% for host_key, host_info in available_hosts.items() %}
<li>
<a class="dropdown-item {% if host_key == current_host_key %}active{% endif %}"
href="/vms?host={{ host_key }}"
onclick="changeHost('{{ host_key }}')">
<i class="fas fa-server me-2"></i>
<strong>{{ host_info.name }}</strong><br>
<small class="text-muted">{{ host_info.host }}{% if host_info.port != 443 %}:{{ host_info.port }}{% endif %}</small>
</a>
</li>
{% endfor %}
</ul>
</div>
<button id="refresh-btn" class="btn btn-outline-primary">
<i class="fas fa-sync-alt"></i> 새로고침
</button>
<span class="text-muted">
<i class="fas fa-network-wired"></i>
<small>{{ current_host_info.host }}{% if current_host_info.port != 443 %}:{{ current_host_info.port }}{% endif %}</small>
</span>
</div>
</div>
</div>
</div>
<!-- 통계 카드 -->
<div class="row mb-4">
<div class="col-md-3">
<div class="card text-white bg-primary">
<div class="card-body">
<div class="d-flex justify-content-between">
<div>
<h4 class="card-title">{{ total_vms }}</h4>
<p class="card-text">총 VM 수</p>
</div>
<div class="align-self-center">
<i class="fas fa-desktop fa-2x"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card text-white bg-success">
<div class="card-body">
<div class="d-flex justify-content-between">
<div>
<h4 class="card-title">{{ running_vms }}</h4>
<p class="card-text">실행 중</p>
</div>
<div class="align-self-center">
<i class="fas fa-play-circle fa-2x"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card text-white bg-secondary">
<div class="card-body">
<div class="d-flex justify-content-between">
<div>
<h4 class="card-title">{{ stopped_vms }}</h4>
<p class="card-text">정지됨</p>
</div>
<div class="align-self-center">
<i class="fas fa-stop-circle fa-2x"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card text-white bg-info">
<div class="card-body">
<div class="d-flex justify-content-between">
<div>
<h4 class="card-title">{{ vnc_ready_vms }}</h4>
<p class="card-text">VNC 가능</p>
</div>
<div class="align-self-center">
<i class="fas fa-eye fa-2x"></i>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- VM 목록 -->
<div class="row">
{% for vm in vms %}
<div class="col-lg-6 col-xl-4 mb-4">
<div class="card vm-card {{ 'running' if vm.status == 'running' else 'stopped' }}">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0">
<strong>{{ vm.name or 'VM-' + vm.vmid|string }}</strong>
</h6>
{% if vm.status == 'running' %}
<span class="badge bg-success status-badge">
<i class="fas fa-play"></i> 실행 중
</span>
{% else %}
<span class="badge bg-secondary status-badge">
<i class="fas fa-stop"></i> 정지됨
</span>
{% endif %}
</div>
<div class="card-body">
<div class="resource-info mb-3">
<div class="row">
<div class="col-6">
<i class="fas fa-microchip"></i>
{{ vm.maxcpu or 'N/A' }}코어
</div>
<div class="col-6">
<i class="fas fa-memory"></i>
{{ (vm.maxmem/1024/1024/1024)|round(1) if vm.maxmem else 'N/A' }}GB
</div>
</div>
<div class="row mt-2">
<div class="col-6">
<i class="fas fa-server"></i>
{{ vm.node }}
</div>
<div class="col-6">
<i class="fas fa-tag"></i>
VM {{ vm.vmid }}
</div>
</div>
</div>
<div class="vm-actions">
{% if vm.status == 'running' %}
<button class="btn btn-primary btn-sm"
onclick="openVNC('{{ vm.node }}', {{ vm.vmid }}, '{{ vm.name or 'VM-' + vm.vmid|string }}')">
<i class="fas fa-desktop"></i> VNC 접속
</button>
<button class="btn btn-warning btn-sm"
onclick="stopVM('{{ vm.node }}', {{ vm.vmid }})">
<i class="fas fa-stop"></i> 정지
</button>
{% else %}
<button class="btn btn-success btn-sm"
onclick="startVM('{{ vm.node }}', {{ vm.vmid }})">
<i class="fas fa-play"></i> 시작
</button>
<button class="btn btn-secondary btn-sm" disabled>
<i class="fas fa-desktop"></i> VNC 불가
</button>
{% endif %}
<button class="btn btn-info btn-sm"
onclick="showVMDetails('{{ vm.node }}', {{ vm.vmid }})">
<i class="fas fa-info-circle"></i> 상세정보
</button>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{% if not vms %}
<div class="row">
<div class="col-12">
<div class="alert alert-warning text-center">
<i class="fas fa-exclamation-triangle"></i>
<strong>VM을 찾을 수 없습니다.</strong><br>
Proxmox 서버 연결을 확인해주세요.
</div>
</div>
</div>
{% endif %}
</div>
<!-- 로딩 스피너 -->
<div class="loading-spinner position-fixed top-50 start-50 translate-middle">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">처리 중...</span>
</div>
</div>
<!-- Toast 컨테이너 -->
<div class="toast-container" id="toast-container"></div>
<!-- Bootstrap JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
<script>
// VNC 연결 열기
async function openVNC(node, vmid, vmName) {
try {
showSpinner();
const response = await fetch('/api/vm/vnc', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
node: node,
vmid: vmid,
vm_name: vmName,
host: new URLSearchParams(window.location.search).get('host') || 'pve7.0bin.in'
})
});
const data = await response.json();
if (response.ok) {
// 새 창에서 VNC 콘솔 열기
const vncWindow = window.open(
`/vnc/${data.session_id}`,
`vnc_${vmid}`,
'width=1024,height=768,scrollbars=no,resizable=yes,toolbar=no,location=no'
);
if (!vncWindow) {
showToast('팝업 차단됨', '팝업 차단을 해제하고 다시 시도해주세요.', 'warning');
}
} else {
showToast('VNC 연결 실패', data.error || '알 수 없는 오류가 발생했습니다.', 'danger');
}
} catch (error) {
showToast('연결 오류', error.message, 'danger');
} finally {
hideSpinner();
}
}
// VM 시작
async function startVM(node, vmid) {
if (!confirm(`VM ${vmid}을(를) 시작하시겠습니까?`)) return;
try {
showSpinner();
const response = await fetch('/api/vm/start', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
node: node,
vmid: vmid,
host: new URLSearchParams(window.location.search).get('host') || 'pve7.0bin.in'
})
});
const data = await response.json();
if (response.ok) {
showToast('VM 시작', `VM ${vmid}이(가) 시작되었습니다.`, 'success');
setTimeout(() => location.reload(), 2000);
} else {
showToast('시작 실패', data.error || '알 수 없는 오류가 발생했습니다.', 'danger');
}
} catch (error) {
showToast('연결 오류', error.message, 'danger');
} finally {
hideSpinner();
}
}
// VM 정지
async function stopVM(node, vmid) {
if (!confirm(`VM ${vmid}을(를) 정지하시겠습니까?`)) return;
try {
showSpinner();
const response = await fetch('/api/vm/stop', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
node: node,
vmid: vmid,
host: new URLSearchParams(window.location.search).get('host') || 'pve7.0bin.in'
})
});
const data = await response.json();
if (response.ok) {
showToast('VM 정지', `VM ${vmid}이(가) 정지되었습니다.`, 'success');
setTimeout(() => location.reload(), 2000);
} else {
showToast('정지 실패', data.error || '알 수 없는 오류가 발생했습니다.', 'danger');
}
} catch (error) {
showToast('연결 오류', error.message, 'danger');
} finally {
hideSpinner();
}
}
// VM 상세정보
function showVMDetails(node, vmid) {
// 상세정보 모달이나 페이지로 이동
alert(`VM ${vmid} 상세정보 (노드: ${node})`);
}
// 새로고침
document.getElementById('refresh-btn').onclick = function() {
location.reload();
};
// 호스트 변경
function changeHost(hostKey) {
showSpinner();
showToast('호스트 변경', `${hostKey} 호스트로 연결 중...`, 'info');
// URL을 통해 페이지 이동 (이미 href에 설정되어 있음)
};
// 스피너 표시/숨김
function showSpinner() {
document.querySelector('.loading-spinner').style.display = 'block';
}
function hideSpinner() {
document.querySelector('.loading-spinner').style.display = 'none';
}
// Toast 메시지 표시
function showToast(title, message, type = 'info') {
const toastContainer = document.getElementById('toast-container');
const toastId = 'toast-' + Date.now();
const toast = document.createElement('div');
toast.className = `toast align-items-center text-white bg-${type} border-0`;
toast.setAttribute('role', 'alert');
toast.setAttribute('id', toastId);
toast.innerHTML = `
<div class="d-flex">
<div class="toast-body">
<strong>${title}</strong><br>
${message}
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto"
data-bs-dismiss="toast"></button>
</div>
`;
toastContainer.appendChild(toast);
const bsToast = new bootstrap.Toast(toast);
bsToast.show();
// 토스트가 숨겨진 후 DOM에서 제거
toast.addEventListener('hidden.bs.toast', function() {
toast.remove();
});
}
</script>
</body>
</html>