Files
pvemanager/backend/app/templates/virtual_machines.html
markmorado 91dbf0b81c feat: IPAM improvements - server/node subnet binding, theme color fixes
- IPAM link-allocations: filter networks by proxmox_server_id and proxmox_node
  to prevent cross-server/cross-node IP assignment
- IPAM link-allocations: add manual server+network selection modal before linking
- IPAM add-network forms: add Proxmox server and node selectors with dynamic
  cluster-info loading (ipam_dashboard.html, ipam_networks.html)
- models.py / schemas.py: add proxmox_node field to IPAMNetwork
- migrations: migration #16 adds proxmox_node column to ipam_networks table
- Theme fixes: replace all hardcoded rgba/color:#fff/color:white with CSS vars
  across base.html, virtual_machines.html, instance_detail.html,
  os_templates.html, proxmox_vms.html, ipam_network_detail.html,
  ipam_allocations.html
- i18n: add proxmox_node, node selector, link-allocations modal keys (ru/en)
- Version bump: 1.0.4 -> 1.0.5
2026-03-08 14:17:54 +01:00

4338 lines
166 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "base.html" %}
{% set page_title = t('nav_vms') %}
{% block content %}
<style>
.vm-table-container {
background: var(--bg-surface);
border: 1px solid var(--border-color);
border-radius: 12px;
overflow: hidden;
}
.vm-tabs {
display: flex;
gap: 0;
border-bottom: 1px solid var(--border-color);
background: var(--bg-surface-elevated);
}
.vm-tab {
padding: 1rem 1.5rem;
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
font-weight: 500;
transition: all 0.2s ease;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
}
.vm-tab:hover {
color: var(--text-primary);
background: var(--bg-hover);
}
.vm-tab.active {
color: var(--primary);
border-bottom-color: var(--primary);
}
.vm-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--border-color);
flex-wrap: wrap;
gap: 1rem;
}
.vm-toolbar-left {
display: flex;
align-items: center;
gap: 1rem;
}
.vm-toolbar-right {
display: flex;
align-items: center;
gap: 1rem;
}
.vm-filter-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 6px;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s ease;
}
.vm-filter-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.vm-pagination {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--text-secondary);
font-size: 0.875rem;
}
.vm-pagination select {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 0.25rem 0.5rem;
color: var(--text-primary);
}
.vm-pagination-nav {
display: flex;
align-items: center;
gap: 0.25rem;
}
.vm-pagination-btn {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 4px;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s ease;
}
.vm-pagination-btn:hover:not(:disabled) {
background: var(--bg-hover);
color: var(--text-primary);
}
.vm-pagination-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.vm-table {
width: 100%;
border-collapse: collapse;
}
.vm-table th {
text-align: left;
padding: 0.75rem 1rem;
background: var(--bg-surface-elevated);
color: var(--text-secondary);
font-weight: 500;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.5px;
border-bottom: 1px solid var(--border-color);
white-space: nowrap;
}
.vm-table th.sortable {
cursor: pointer;
user-select: none;
}
.vm-table th.sortable:hover {
color: var(--text-primary);
}
.vm-table th .sort-icon {
margin-left: 0.25rem;
opacity: 0.5;
}
.vm-table th.sorted .sort-icon {
opacity: 1;
color: var(--primary);
}
.vm-table td {
padding: 0.875rem 1rem;
border-bottom: 1px solid var(--border-color);
color: var(--text-primary);
font-size: 0.9rem;
}
.vm-table tbody tr {
transition: background 0.15s ease;
cursor: pointer;
}
.vm-table tbody tr:hover {
background: var(--bg-hover);
}
.vm-table tbody tr:last-child td {
border-bottom: none;
}
.vm-name-cell {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.vm-name {
color: var(--primary);
font-weight: 500;
text-decoration: none;
}
.vm-name:hover {
text-decoration: underline;
}
.vm-hostname {
font-size: 0.75rem;
color: var(--text-muted);
}
.vm-config {
font-size: 0.85rem;
color: var(--text-secondary);
}
.vm-status-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.75rem;
border-radius: 4px;
font-size: 0.8rem;
font-weight: 500;
}
.vm-status-badge.running {
background: rgba(16, 185, 129, 0.15);
color: var(--success);
}
.vm-status-badge.stopped {
background: rgba(239, 68, 68, 0.15);
color: var(--danger);
}
.vm-status-badge.pending {
background: rgba(245, 158, 11, 0.15);
color: var(--warning);
}
.status-spinner {
display: inline-block;
width: 12px;
height: 12px;
border: 2px solid currentColor;
border-top-color: transparent;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.vm-type-badge {
display: inline-flex;
align-items: center;
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
}
.vm-type-badge.qemu {
background: var(--info);
color: var(--text-on-info);
}
.vm-type-badge.lxc {
background: var(--purple);
color: var(--text-on-primary);
}
.vm-cluster-link {
color: var(--primary);
text-decoration: none;
font-size: 0.85rem;
}
.vm-cluster-link:hover {
text-decoration: underline;
}
.vm-node-link {
color: var(--text-secondary);
text-decoration: none;
font-size: 0.85rem;
}
.vm-node-link:hover {
color: var(--primary);
}
.vm-actions-cell {
text-align: right;
}
.vm-action-menu {
position: relative;
display: inline-block;
}
.vm-action-btn {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 0.25rem;
font-size: 1.25rem;
line-height: 1;
}
.vm-action-btn:hover {
color: var(--text-primary);
}
/* Context menu styles */
.vm-context-menu {
position: fixed;
background: var(--bg-surface);
border: 1px solid var(--border-color);
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
z-index: 1000;
min-width: 180px;
padding: 0.5rem 0;
}
.vm-context-menu-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.6rem 1rem;
color: var(--text-primary);
cursor: pointer;
transition: background 0.15s ease;
font-size: 0.9rem;
}
.vm-context-menu-item:hover {
background: var(--bg-hover);
}
.vm-context-menu-item.danger {
color: var(--danger);
}
.vm-context-menu-item.danger:hover {
background: rgba(239, 68, 68, 0.1);
}
.vm-context-menu-item svg {
width: 18px;
height: 18px;
flex-shrink: 0;
stroke: currentColor;
fill: none;
}
.vm-context-menu-divider {
height: 1px;
background: var(--border-color);
margin: 0.5rem 0;
}
.vm-empty {
text-align: center;
padding: 4rem 2rem;
color: var(--text-muted);
}
.vm-empty-icon {
font-size: 3rem;
margin-bottom: 1rem;
opacity: 0.5;
}
.vm-checkbox {
width: 18px;
height: 18px;
cursor: pointer;
}
.settings-gear {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 0.5rem;
font-size: 1rem;
}
.settings-gear:hover {
color: var(--text-primary);
}
.vm-filter-select {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 0.5rem 0.75rem;
color: var(--text-primary);
font-size: 0.875rem;
cursor: pointer;
min-width: 120px;
}
.vm-filter-select:hover {
border-color: var(--primary);
}
.vm-filter-select:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 2px rgba(var(--primary-rgb), 0.2);
}
.vm-search-input {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 0.5rem 0.75rem;
color: var(--text-primary);
font-size: 0.875rem;
min-width: 200px;
}
.vm-search-input:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 2px rgba(var(--primary-rgb), 0.2);
}
.vm-search-input::placeholder {
color: var(--text-muted);
}
/* Bulk actions bar */
.bulk-actions-bar {
display: none;
align-items: center;
gap: 1rem;
padding: 0.75rem 1.5rem;
background: var(--primary);
color: var(--text-on-primary);
animation: slideDown 0.2s ease;
}
.bulk-actions-bar.active {
display: flex;
}
@keyframes slideDown {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
.bulk-actions-bar .selected-count {
font-weight: 600;
}
.bulk-actions-bar .bulk-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.4rem 0.75rem;
border: 1px solid rgba(255,255,255,0.3);
border-radius: 6px;
background: rgba(255,255,255,0.1);
color: var(--text-on-primary);
cursor: pointer;
font-size: 0.85rem;
transition: all 0.15s ease;
}
.bulk-actions-bar .bulk-btn:hover {
background: rgba(255,255,255,0.2);
border-color: rgba(255,255,255,0.5);
}
.bulk-actions-bar .bulk-btn.danger {
background: rgba(239, 68, 68, 0.8);
border-color: rgba(239, 68, 68, 0.9);
}
.bulk-actions-bar .bulk-btn.danger:hover {
background: var(--danger);
}
.bulk-actions-bar .cancel-btn {
background: none;
border: none;
color: var(--text-on-primary);
cursor: pointer;
padding: 0.25rem;
opacity: 0.8;
margin-left: auto;
}
.bulk-actions-bar .cancel-btn:hover {
opacity: 1;
}
/* Task progress toast */
.task-progress-toast {
position: fixed;
bottom: 1.5rem;
right: 1.5rem;
background: var(--bg-surface);
border: 1px solid var(--border-color);
border-radius: 12px;
box-shadow: 0 8px 24px rgba(0,0,0,0.3);
padding: 1rem 1.5rem;
min-width: 320px;
z-index: 9999;
animation: slideUp 0.3s ease;
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.task-progress-toast.hidden {
display: none;
}
.task-progress-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.75rem;
}
.task-progress-title {
font-weight: 600;
color: var(--text-primary);
}
.task-progress-close {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 0.25rem;
font-size: 1.25rem;
}
.task-progress-bar-container {
height: 8px;
background: var(--bg-tertiary);
border-radius: 4px;
overflow: hidden;
margin-bottom: 0.5rem;
}
.task-progress-bar {
height: 100%;
background: var(--primary);
border-radius: 4px;
transition: width 0.3s ease;
}
.task-progress-bar.success {
background: var(--success);
}
.task-progress-bar.error {
background: var(--danger);
}
.task-progress-text {
font-size: 0.85rem;
color: var(--text-secondary);
}
.task-progress-results {
max-height: 150px;
overflow-y: auto;
margin-top: 0.75rem;
font-size: 0.8rem;
}
.task-result-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0;
color: var(--text-secondary);
}
.task-result-item.success {
color: var(--success);
}
.task-result-item.error {
color: var(--danger);
}
/* Мобильная адаптация */
@media (max-width: 768px) {
.vm-tabs {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.vm-tab {
padding: 0.75rem 1rem;
white-space: nowrap;
font-size: 0.875rem;
}
.vm-toolbar {
flex-direction: column;
align-items: stretch;
padding: 0.75rem;
}
.vm-toolbar-left,
.vm-toolbar-right {
width: 100%;
justify-content: space-between;
}
.vm-toolbar-right {
flex-wrap: wrap;
}
.vm-pagination {
width: 100%;
justify-content: center;
}
.vm-table th,
.vm-table td {
padding: 0.5rem 0.25rem;
font-size: 0.8rem;
}
/* Скрываем менее важные колонки на мобильных */
.vm-table th:nth-child(4),
.vm-table td:nth-child(4),
.vm-table th:nth-child(8),
.vm-table td:nth-child(8),
.vm-table th:nth-child(9),
.vm-table td:nth-child(9) {
display: none;
}
.bulk-actions-panel {
flex-wrap: wrap;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem;
}
.bulk-btn {
padding: 0.5rem 0.75rem;
font-size: 0.8rem;
}
.vm-row-actions {
gap: 0.25rem;
}
.vm-action-btn {
padding: 0.35rem;
}
.task-progress-toast {
left: 0.5rem;
right: 0.5rem;
bottom: 0.5rem;
max-width: none;
}
}
@media (max-width: 480px) {
.vm-toolbar-left {
flex-direction: column;
gap: 0.5rem;
}
.vm-search {
width: 100%;
}
.vm-search input {
width: 100%;
}
/* Скрываем ещё больше колонок */
.vm-table th:nth-child(5),
.vm-table td:nth-child(5),
.vm-table th:nth-child(6),
.vm-table td:nth-child(6),
.vm-table th:nth-child(7),
.vm-table td:nth-child(7) {
display: none;
}
}
</style>
<div class="vm-table-container">
<!-- Tabs -->
<div class="vm-tabs">
<button class="vm-tab active" data-tab="vms" onclick="switchVMTab('vms')">{{ t('virtual_machines') }}</button>
<button class="vm-tab" data-tab="snapshots" onclick="switchVMTab('snapshots')">{{ t('vm_snapshots') }}</button>
</div>
<!-- Bulk Actions Bar -->
<div class="bulk-actions-bar" id="bulkActionsBar">
<span class="selected-count"><span id="selectedCount">0</span> {{ t('selected') }}</span>
<button class="bulk-btn" onclick="bulkAction('start')" title="{{ t('start_all') }}">
<i class="fa-solid fa-play"></i> {{ t('start') }}
</button>
<button class="bulk-btn" onclick="bulkAction('stop')" title="{{ t('stop_all') }}">
<i class="fa-solid fa-stop"></i> {{ t('stop') }}
</button>
<button class="bulk-btn" onclick="bulkAction('restart')" title="{{ t('restart_all') }}">
<i class="fa-solid fa-rotate"></i> {{ t('restart') }}
</button>
<button class="bulk-btn" onclick="bulkAction('shutdown')" title="{{ t('shutdown_all') }}">
<i class="fa-solid fa-power-off"></i> {{ t('shutdown') }}
</button>
<button class="bulk-btn danger" onclick="bulkAction('delete')" title="{{ t('delete_all') }}">
<i class="fa-solid fa-trash"></i> {{ t('delete') }}
</button>
<button class="cancel-btn" onclick="clearSelection()" title="{{ t('cancel') }}"><i class="fa-solid fa-xmark"></i></button>
</div>
<!-- Toolbar -->
<div class="vm-toolbar">
<div class="vm-toolbar-left">
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;" title="{{ t('select_all') }}">
<input type="checkbox" id="selectAll" class="vm-checkbox" onclick="toggleSelectAll(event)">
<span style="color: var(--text-secondary); font-size: 0.875rem;">
<span id="selectedCountLabel">0</span> {{ t('selected') }}
</span>
</label>
<select id="typeFilter" class="vm-filter-select" onchange="filterByType(this.value)">
<option value="">{{ t('all_types') }}</option>
<option value="qemu">KVM (QEMU)</option>
<option value="lxc">LXC ({{ t('containers') }})</option>
</select>
<select id="statusFilter" class="vm-filter-select" onchange="filterByStatus(this.value)">
<option value="">{{ t('all_statuses') }}</option>
<option value="running">{{ t('active') }}</option>
<option value="stopped">{{ t('inactive') }}</option>
</select>
<input type="text" id="searchInput" class="vm-search-input" placeholder="{{ t('search') }}..." oninput="searchVMs(this.value)">
<div style="display: flex; align-items: center; gap: 0.75rem; color: var(--text-muted); font-size: 0.875rem;">
<span>{{ t('total') }}: <strong id="totalCount">0</strong></span>
<span style="color: var(--success);"><i class="fa-solid fa-play"></i> <span id="runningCount">0</span></span>
<span style="color: var(--text-muted);"><i class="fa-solid fa-stop"></i> <span id="stoppedCount">0</span></span>
</div>
</div>
<div class="vm-toolbar-right">
<div class="vm-pagination">
<span>{{ t('per_page') }}:</span>
<select id="pageSize" onchange="changePageSize(this.value)">
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</div>
<div class="vm-pagination-nav">
<button class="vm-pagination-btn" id="prevPage" onclick="prevPage()" disabled>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="15 18 9 12 15 6"></polyline>
</svg>
</button>
<input type="number" id="currentPage" value="1" min="1" style="width: 50px; text-align: center; background: var(--bg-tertiary); border: 1px solid var(--border-color); border-radius: 4px; padding: 0.25rem; color: var(--text-primary);" onchange="goToPage(this.value)">
<span style="color: var(--text-muted);">{{ t('of') }} <span id="totalPages">1</span></span>
<button class="vm-pagination-btn" id="nextPage" onclick="nextPage()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6"></polyline>
</svg>
</button>
</div>
<button class="settings-gear" title="{{ t('settings') }}"><i class="fa-solid fa-gear"></i></button>
<div style="display: flex; gap: 0.5rem;">
<button class="btn btn-secondary" onclick="syncVMs()" id="syncBtn" title="{{ t('sync_from_proxmox') }}">
<span id="syncIcon"><i class="fa-solid fa-rotate"></i></span>
</button>
<button class="btn btn-primary" onclick="showCreateVMModal()" data-permission="vms.create">{{ t('create_vm') }}</button>
<button class="btn btn-success" onclick="showCreateLXCModal()" data-permission="vms.create"><i class="fa-solid fa-cube"></i> {{ t('create_lxc') }}</button>
</div>
</div>
</div>
<!-- Table -->
<div class="vm-table-wrapper" style="overflow-x: auto;">
<table class="vm-table">
<thead>
<tr>
<th style="width: 40px;"></th>
<th class="sortable" onclick="sortBy('name')">
{{ t('name') }} <span class="sort-icon">↕</span>
</th>
<th class="sortable" onclick="sortBy('vmid')">
ID <span class="sort-icon">↕</span>
</th>
<th class="sortable" onclick="sortBy('owner')">
{{ t('owner') }} <span class="sort-icon">↕</span>
</th>
<th class="sortable" onclick="sortBy('ip')">
IP-{{ t('address') }} <span class="sort-icon">↕</span>
</th>
<th class="sortable" onclick="sortBy('name')">
{{ t('os_config') }} <span class="sort-icon">↕</span>
</th>
<th class="sortable" onclick="sortBy('cluster')">
{{ t('cluster') }} <span class="sort-icon">↕</span>
</th>
<th>{{ t('type') }}</th>
<th class="sortable" onclick="sortBy('node')">
{{ t('node_storage') }} <span class="sort-icon">↕</span>
</th>
<th class="sortable" onclick="sortBy('status')">
{{ t('status') }} <span class="sort-icon">↕</span>
</th>
<th style="width: 50px;"></th>
</tr>
</thead>
<tbody id="vmTableBody">
<tr>
<td colspan="11" class="vm-empty">
<div class="vm-empty-icon"><i class="fa-solid fa-spinner fa-spin"></i></div>
<div>{{ t('loading') }}...</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Snapshots Tab Content -->
<div id="snapshotsTabContent" style="display: none;">
<div class="vm-toolbar" style="margin-bottom: 1rem;">
<div class="vm-toolbar-left">
<select id="snapshotServerFilter" class="vm-filter-select" onchange="loadSnapshotsForServer(this.value)">
<option value="">{{ t('select_server') }}</option>
</select>
<select id="snapshotVMFilter" class="vm-filter-select" onchange="loadSnapshotsForVM(this.value)" disabled>
<option value="">{{ t('select_vm') }}</option>
</select>
<div class="search-input-wrapper" style="position: relative; flex: 1; min-width: 150px;">
<i class="fa-solid fa-search" style="position: absolute; left: 0.75rem; top: 50%; transform: translateY(-50%); color: var(--text-muted);"></i>
<input type="text" id="snapshotSearchInput" class="vm-search-input" placeholder="{{ t('search') }}..." oninput="filterSnapshots(this.value)" style="padding-left: 2.25rem;">
</div>
</div>
<div class="vm-toolbar-right">
<button class="btn btn-secondary btn-icon" onclick="refreshSnapshots()" title="{{ t('refresh') }}">
<i class="fa-solid fa-rotate"></i>
</button>
<button class="btn btn-primary" onclick="showCreateSnapshotModal()" id="createSnapshotBtn" disabled data-permission="vms.snapshots">
<i class="fa-solid fa-camera"></i> <span class="btn-text">{{ t('create_snapshot') }}</span>
</button>
</div>
</div>
<div id="snapshotsContainer" class="snapshots-grid">
<div class="snapshots-empty-state" style="grid-column: 1 / -1; text-align: center; padding: 3rem; color: var(--text-muted);">
<i class="fa-solid fa-camera" style="font-size: 3rem; margin-bottom: 1rem; opacity: 0.3;"></i>
<div>{{ t('select_vm_for_snapshots') }}</div>
</div>
</div>
</div>
</div>
<!-- Task Progress Toast -->
<div class="task-progress-toast hidden" id="taskProgressToast">
<div class="task-progress-header">
<span class="task-progress-title" id="taskProgressTitle">{{ t('processing') }}...</span>
<button class="task-progress-close" onclick="hideTaskProgress()">×</button>
</div>
<div class="task-progress-bar-container">
<div class="task-progress-bar" id="taskProgressBar" style="width: 0%"></div>
</div>
<div class="task-progress-text" id="taskProgressText">0 / 0</div>
<div class="task-progress-results" id="taskProgressResults"></div>
</div>
<script>
const API_TOKEN = localStorage.getItem('access_token');
// State
let allVMs = [];
let filteredVMs = [];
let currentPageNum = 1;
let pageSizeNum = 25;
let sortField = 'name';
let sortDirection = 'asc';
let currentTypeFilter = '';
let currentStatusFilter = '';
let currentSearchQuery = '';
// URL params helper functions
function getUrlParams() {
return new URLSearchParams(window.location.search);
}
function updateUrlParams(params) {
const url = new URL(window.location);
Object.entries(params).forEach(([key, value]) => {
if (value) {
url.searchParams.set(key, value);
} else {
url.searchParams.delete(key);
}
});
window.history.replaceState({}, '', url);
}
function restoreFiltersFromUrl() {
const params = getUrlParams();
// Restore type filter
const type = params.get('type') || '';
currentTypeFilter = type;
const typeSelect = document.getElementById('typeFilter');
if (typeSelect) typeSelect.value = type;
// Restore status filter
const status = params.get('status') || '';
currentStatusFilter = status;
const statusSelect = document.getElementById('statusFilter');
if (statusSelect) statusSelect.value = status;
// Restore search query
const search = params.get('search') || '';
currentSearchQuery = search;
const searchInput = document.getElementById('searchInput');
if (searchInput) searchInput.value = search;
// Restore page size
const pageSize = params.get('pageSize');
if (pageSize && ['25', '50', '100'].includes(pageSize)) {
pageSizeNum = parseInt(pageSize);
const pageSizeSelect = document.getElementById('pageSize');
if (pageSizeSelect) pageSizeSelect.value = pageSize;
}
// Restore current page
const page = params.get('page');
if (page && parseInt(page) > 0) {
currentPageNum = parseInt(page);
const currentPageInput = document.getElementById('currentPage');
if (currentPageInput) currentPageInput.value = page;
}
// Restore sort
const sort = params.get('sort');
if (sort) {
sortField = sort;
}
const dir = params.get('dir');
if (dir && ['asc', 'desc'].includes(dir)) {
sortDirection = dir;
}
}
// Bulk selection state
let selectedVMs = new Set(); // Set of "server_id-vmid" keys
let currentTaskId = null;
let taskPollInterval = null;
// Bulk action functions
function getVMKey(vm) {
return `${vm.server_id}-${vm.vmid}`;
}
function toggleVMSelection(event, checkbox, serverId, vmid) {
event.stopPropagation();
const key = `${serverId}-${vmid}`;
if (checkbox.checked) {
selectedVMs.add(key);
} else {
selectedVMs.delete(key);
}
// Update row highlight
const row = checkbox.closest('tr');
if (row) {
row.classList.toggle('selected', checkbox.checked);
}
updateBulkActionsBar();
updateSelectAllCheckbox();
}
function toggleSelectAll(event) {
// Останавливаем всплытие события, но НЕ preventDefault - иначе чекбокс не изменит состояние
event?.stopPropagation();
const checkbox = event.target;
// checked уже изменён браузером к моменту вызова onclick
const isChecked = checkbox.checked;
console.log('toggleSelectAll:', { isChecked, selectedVMsSize: selectedVMs.size });
const checkboxes = document.querySelectorAll('.vm-row-checkbox');
if (isChecked) {
// Выделяем все элементы на текущей странице
checkboxes.forEach(cb => {
const key = `${cb.dataset.serverId}-${cb.dataset.vmid}`;
selectedVMs.add(key);
cb.checked = true;
const row = cb.closest('tr');
if (row) row.classList.add('selected');
});
} else {
// Снимаем выделение со всех элементов
selectedVMs.clear();
checkboxes.forEach(cb => {
cb.checked = false;
const row = cb.closest('tr');
if (row) row.classList.remove('selected');
});
}
console.log('After toggleSelectAll:', { selectedVMsSize: selectedVMs.size, isChecked });
updateBulkActionsBar();
}
function clearSelection() {
selectedVMs.clear();
updateBulkActionsBar();
// Just update checkboxes without full re-render
document.querySelectorAll('.vm-checkbox').forEach(cb => {
cb.checked = false;
});
document.getElementById('selectAll').checked = false;
document.getElementById('selectedCountLabel').textContent = '0';
}
function updateBulkActionsBar() {
const bar = document.getElementById('bulkActionsBar');
const count = selectedVMs.size;
// Update bar visibility
if (bar) {
if (count > 0) {
bar.classList.add('active');
} else {
bar.classList.remove('active');
}
}
// Update count display
const selectedCountEl = document.getElementById('selectedCount');
if (selectedCountEl) {
selectedCountEl.textContent = count;
}
// Update counter next to checkbox
const counterSpan = document.getElementById('selectedCountLabel');
if (counterSpan) {
counterSpan.textContent = count;
counterSpan.style.color = count > 0 ? 'var(--primary)' : 'var(--text-secondary)';
counterSpan.style.fontWeight = count > 0 ? '600' : '400';
}
// Update select all checkbox state
updateSelectAllCheckbox();
}
function updateSelectAllCheckbox() {
const currentPageVMs = getCurrentPageVMs();
const allSelected = currentPageVMs.length > 0 &&
currentPageVMs.every(vm => selectedVMs.has(getVMKey(vm)));
const hasSelectedOnPage = currentPageVMs.length > 0 &&
currentPageVMs.some(vm => selectedVMs.has(getVMKey(vm)));
// Update select all checkbox in toolbar
const selectAllCb = document.getElementById('selectAll');
if (selectAllCb) {
// Если все элементы на странице выбраны - чекбокс отмечен
// Если есть выбранные на странице, но не все - чекбокс в промежуточном состоянии (indeterminate)
// Если ничего не выбрано на странице - чекбокс не отмечен
selectAllCb.checked = allSelected;
selectAllCb.indeterminate = hasSelectedOnPage && !allSelected;
}
}
function getCurrentPageVMs() {
const start = (currentPageNum - 1) * pageSizeNum;
const end = start + pageSizeNum;
return filteredVMs.slice(start, end);
}
function getSelectedVMsData() {
return allVMs.filter(vm => selectedVMs.has(getVMKey(vm))).map(vm => ({
server_id: vm.server_id,
vmid: vm.vmid,
vm_type: vm.type,
name: vm.name,
node: vm.node
}));
}
// Track bulk operation items for status animation
let bulkOperationItems = [];
let bulkOperationAction = null;
async function bulkAction(action) {
const items = getSelectedVMsData();
if (items.length === 0) {
showToast('{{ t("no_items_selected") }}', 'warning');
return;
}
// Confirm for dangerous actions
const actionNames = {
start: '{{ t("start") }}',
stop: '{{ t("stop") }}',
restart: '{{ t("restart") }}',
shutdown: '{{ t("shutdown") }}',
delete: '{{ t("delete") }}'
};
if (action === 'delete') {
const confirmed = await showConfirm(
`{{ t("confirm_bulk_delete") }}`,
`{{ t("confirm_bulk_delete_msg") }} ${items.length} {{ t("items") }}?`,
{ isDanger: true }
);
if (!confirmed) return;
} else {
const confirmed = await showConfirm(
`${actionNames[action]} ${items.length} {{ t("items") }}?`,
`{{ t("confirm_bulk_operation_msg") }}`,
{ isDanger: action === 'stop' }
);
if (!confirmed) return;
}
try {
const response = await fetchWithAuth('/proxmox/api/bulk-operation', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action, items })
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to create bulk operation');
}
const result = await response.json();
currentTaskId = result.task_id;
// Store items and action for status animation
bulkOperationItems = items;
bulkOperationAction = action;
// Show pending status with spinner for all selected items
console.log('[bulkAction] Setting pending status for items:', items);
items.forEach(item => {
console.log(`[bulkAction] Calling updateVMStatusUI for server_id=${item.server_id}, vmid=${item.vmid}, action=${action}`);
updateVMStatusUI(item.server_id, item.vmid, 'pending', action);
});
// Show progress toast
showTaskProgress(action, items.length);
// Start polling for progress
startTaskPolling(currentTaskId);
// Clear selection
clearSelection();
} catch (error) {
console.error('Bulk operation error:', error);
showToast(error.message, 'error');
}
}
function showTaskProgress(action, totalItems) {
const toast = document.getElementById('taskProgressToast');
const title = document.getElementById('taskProgressTitle');
const bar = document.getElementById('taskProgressBar');
const text = document.getElementById('taskProgressText');
const results = document.getElementById('taskProgressResults');
const actionNames = {
start: '{{ t("starting") }}',
stop: '{{ t("stopping") }}',
restart: '{{ t("restarting") }}',
shutdown: '{{ t("shutting_down") }}',
delete: '{{ t("deleting") }}'
};
title.textContent = `${actionNames[action] || action}...`;
bar.style.width = '0%';
bar.className = 'task-progress-bar';
text.textContent = `0 / ${totalItems}`;
results.innerHTML = '';
toast.classList.remove('hidden');
}
// Track which VMs have already been updated in UI
let processedBulkResults = new Set();
function updateTaskProgress(task) {
const bar = document.getElementById('taskProgressBar');
const text = document.getElementById('taskProgressText');
const results = document.getElementById('taskProgressResults');
const percent = task.progress_percent;
bar.style.width = `${percent}%`;
const completed = task.completed_items + task.failed_items;
text.textContent = `${completed} / ${task.total_items} (${task.completed_items} ✓, ${task.failed_items} ✗)`;
// Update VM statuses in real-time as results come in
if (task.results && task.results.length > 0) {
task.results.forEach(r => {
const resultKey = `${r.server_id}-${r.vmid}`;
if (!processedBulkResults.has(resultKey)) {
processedBulkResults.add(resultKey);
// Determine final status based on action and success
if (r.success) {
let newStatus;
if (bulkOperationAction === 'start') {
newStatus = 'running';
} else if (bulkOperationAction === 'stop' || bulkOperationAction === 'shutdown') {
newStatus = 'stopped';
} else if (bulkOperationAction === 'restart') {
newStatus = 'running';
} else if (bulkOperationAction === 'delete') {
// VM deleted, will be removed on refresh
newStatus = 'stopped';
}
if (newStatus && r.server_id) {
updateVMStatusUI(r.server_id, r.vmid, newStatus);
// Also update action button
const vm = allVMs.find(v => v.server_id === r.server_id && v.vmid === r.vmid);
if (vm) {
updateVMActionButton(r.server_id, r.vmid, vm.type, vm.node, newStatus);
}
}
} else {
// Failed - revert to previous status
if (r.server_id) {
revertVMStatus(r.server_id, r.vmid);
}
}
}
});
// Update results list
results.innerHTML = task.results.map(r => `
<div class="task-result-item ${r.success ? 'success' : 'error'}">
${r.success ? '✓' : '✗'} ${r.name} (${r.vmid}): ${r.message}
</div>
`).join('');
}
if (task.status === 'completed') {
bar.classList.add(task.failed_items > 0 ? 'error' : 'success');
document.getElementById('taskProgressTitle').textContent =
task.failed_items > 0 ? '{{ t("completed_with_errors") }}' : '{{ t("completed") }}';
stopTaskPolling();
// Clear bulk operation tracking
bulkOperationItems = [];
bulkOperationAction = null;
processedBulkResults.clear();
// Auto-hide progress toast after 3 seconds
setTimeout(() => {
hideTaskProgress();
}, 3000);
// Only reload for delete operations to remove deleted VMs from list
if (task.task_type && task.task_type.includes('delete')) {
loadAllVMs();
}
} else if (task.status === 'failed') {
bar.classList.add('error');
document.getElementById('taskProgressTitle').textContent = '{{ t("failed") }}';
stopTaskPolling();
// Revert all pending statuses
bulkOperationItems.forEach(item => {
revertVMStatus(item.server_id, item.vmid);
});
bulkOperationItems = [];
bulkOperationAction = null;
processedBulkResults.clear();
// Auto-hide progress toast after 3 seconds
setTimeout(() => {
hideTaskProgress();
}, 3000);
}
}
function hideTaskProgress() {
document.getElementById('taskProgressToast').classList.add('hidden');
stopTaskPolling();
}
function startTaskPolling(taskId) {
stopTaskPolling();
taskPollInterval = setInterval(async () => {
try {
const response = await fetchWithAuth(`/proxmox/api/tasks/${taskId}`);
if (response.ok) {
const task = await response.json();
updateTaskProgress(task);
}
} catch (error) {
console.error('Error polling task:', error);
}
}, 1000);
}
function stopTaskPolling() {
if (taskPollInterval) {
clearInterval(taskPollInterval);
taskPollInterval = null;
}
}
// Sync VMs from Proxmox servers
async function syncVMs() {
const btn = document.getElementById('syncBtn');
const icon = document.getElementById('syncIcon');
// Disable button and show loading
btn.disabled = true;
icon.style.animation = 'spin 1s linear infinite';
try {
const response = await fetchWithAuth('/proxmox/api/sync-vms', {
method: 'POST'
});
if (!response.ok) {
throw new Error('Sync failed');
}
const result = await response.json();
showToast('success', '{{ t("success") }}', '{{ t("sync_completed") }}');
// Reload VM list
await loadAllVMs();
} catch (error) {
console.error('Sync error:', error);
showToast('error', '{{ t("error") }}', '{{ t("sync_failed") }}');
} finally {
btn.disabled = false;
icon.style.animation = '';
}
}
// Load all VMs from all servers
async function loadAllVMs() {
const tbody = document.getElementById('vmTableBody');
tbody.innerHTML = `
<tr>
<td colspan="11" class="vm-empty">
<div class="vm-empty-icon"><i class="fa-solid fa-spinner fa-spin"></i></div>
<div>{{ t('loading') }}...</div>
</td>
</tr>
`;
try {
const response = await fetchWithAuth('/api/virtual-machines');
if (!response.ok) {
throw new Error('Failed to load VMs');
}
allVMs = await response.json();
// Apply filters without resetting page (page is restored from URL)
applyFilters(false);
} catch (error) {
console.error('Error loading VMs:', error);
tbody.innerHTML = `
<tr>
<td colspan="11" class="vm-empty">
<div class="vm-empty-icon"><i class="fa-solid fa-circle-xmark"></i></div>
<div>${t('error_loading_data')}</div>
</td>
</tr>
`;
}
}
function updateTotalCount() {
document.getElementById('totalCount').textContent = filteredVMs.length;
const totalPages = Math.ceil(filteredVMs.length / pageSizeNum) || 1;
document.getElementById('totalPages').textContent = totalPages;
// Update pagination buttons
document.getElementById('prevPage').disabled = currentPageNum <= 1;
document.getElementById('nextPage').disabled = currentPageNum >= totalPages;
// Update stats
updateStats();
}
// Update running/stopped stats
function updateStats() {
const running = allVMs.filter(vm => vm.status === 'running').length;
const stopped = allVMs.filter(vm => vm.status !== 'running').length;
document.getElementById('runningCount').textContent = running;
document.getElementById('stoppedCount').textContent = stopped;
}
function renderTable() {
const tbody = document.getElementById('vmTableBody');
// Sort
const sorted = [...filteredVMs].sort((a, b) => {
let aVal = a[sortField] || '';
let bVal = b[sortField] || '';
if (typeof aVal === 'string') aVal = aVal.toLowerCase();
if (typeof bVal === 'string') bVal = bVal.toLowerCase();
if (sortDirection === 'asc') {
return aVal > bVal ? 1 : -1;
} else {
return aVal < bVal ? 1 : -1;
}
});
// Paginate
const start = (currentPageNum - 1) * pageSizeNum;
const end = start + pageSizeNum;
const pageItems = sorted.slice(start, end);
if (pageItems.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="11" class="vm-empty">
<div class="vm-empty-icon">📭</div>
<div>${t('no_vms_found')}</div>
</td>
</tr>
`;
return;
}
tbody.innerHTML = pageItems.map(vm => {
const vmKey = `${vm.server_id}-${vm.vmid}`;
const isSelected = selectedVMs.has(vmKey);
return `
<tr id="vm-row-${vm.server_id}-${vm.vmid}" onclick="openVM(${vm.server_id}, ${vm.vmid}, '${vm.type}', '${vm.node}')" style="cursor: pointer;" class="${isSelected ? 'selected' : ''}">
<td onclick="event.stopPropagation();">
<input type="checkbox" class="vm-checkbox vm-row-checkbox"
data-vmid="${vm.vmid}"
data-server-id="${vm.server_id}"
${isSelected ? 'checked' : ''}
onclick="toggleVMSelection(event, this, ${vm.server_id}, ${vm.vmid})">
</td>
<td>
<a href="/virtual-machines/${vm.server_id}/${vm.vmid}?type=${vm.type}&node=${vm.node}" class="vm-name" onclick="event.stopPropagation();">
${vm.name || 'VM-' + vm.vmid}
</a>
</td>
<td>${vm.vmid}</td>
<td>
<div class="vm-name-cell">
<span>${vm.owner || '-'}</span>
<span class="vm-hostname">${vm.owner_hostname || ''}</span>
</div>
</td>
<td>
<div class="vm-name-cell">
<span>${vm.ip || '-'}</span>
<span class="vm-hostname">${vm.ip_hostname || ''}</span>
</div>
</td>
<td>
<div class="vm-config">
${vm.os_template || vm.os || '-'}<br>
<span style="color: var(--text-muted);">${vm.cores || 0} vCPU / ${formatMemory(vm.memory)} RAM / ${formatDisk(vm.disk)} (${t('total')})</span>
</div>
</td>
<td>
<a href="#" class="vm-cluster-link">${vm.server_name || vm.cluster || '-'}</a>
</td>
<td>
<span class="vm-type-badge ${vm.type}">${vm.type === 'qemu' ? 'KVM' : 'LXC'}</span>
</td>
<td>
<div class="vm-name-cell">
<a href="#" class="vm-node-link">${vm.node}</a>
<span class="vm-hostname">${vm.storage || 'Storage1 (DIR)'}</span>
</div>
</td>
<td>
<span id="vm-status-${vm.server_id}-${vm.vmid}" class="vm-status-badge ${vm.status}" data-status="${vm.status}">${vm.status === 'running' ? t('active') : t('inactive')}</span>
</td>
<td class="vm-actions-cell" onclick="event.stopPropagation();">
<button id="vm-action-btn-${vm.server_id}-${vm.vmid}" class="vm-action-btn" onclick="showVMActions(event, ${vm.server_id}, ${vm.vmid}, '${vm.type}', '${vm.node}', '${(vm.name || 'VM-' + vm.vmid).replace(/'/g, "\\'")}', '${vm.status}')">⋯</button>
</td>
</tr>
`}).join('');
updateTotalCount();
updateSelectAllCheckbox();
updateBulkActionsBar();
}
function formatMemory(bytes) {
if (!bytes) return '0 MB';
const mb = bytes / (1024 * 1024);
if (mb >= 1024) {
return (mb / 1024).toFixed(0) + 'GB';
}
return mb.toFixed(0) + 'MB';
}
function formatDisk(bytes) {
if (!bytes) return '0 GB';
const gb = bytes / (1024 * 1024 * 1024);
return gb.toFixed(0) + 'GB';
}
function openVM(serverId, vmid, type, node) {
window.location.href = `/virtual-machines/${serverId}/${vmid}?type=${type}&node=${node}`;
}
function sortBy(field) {
if (sortField === field) {
sortDirection = sortDirection === 'asc' ? 'desc' : 'asc';
} else {
sortField = field;
sortDirection = 'asc';
}
// Save to URL
updateUrlParams({ sort: sortField, dir: sortDirection });
// Update sort indicators
document.querySelectorAll('.vm-table th').forEach(th => {
th.classList.remove('sorted');
});
renderTable();
}
function changePageSize(size) {
pageSizeNum = parseInt(size);
currentPageNum = 1;
document.getElementById('currentPage').value = 1;
updateUrlParams({ pageSize: size, page: '1' });
renderTable();
}
function prevPage() {
if (currentPageNum > 1) {
currentPageNum--;
document.getElementById('currentPage').value = currentPageNum;
updateUrlParams({ page: String(currentPageNum) });
renderTable();
}
}
function nextPage() {
const totalPages = Math.ceil(filteredVMs.length / pageSizeNum);
if (currentPageNum < totalPages) {
currentPageNum++;
document.getElementById('currentPage').value = currentPageNum;
updateUrlParams({ page: String(currentPageNum) });
renderTable();
}
}
function goToPage(page) {
const totalPages = Math.ceil(filteredVMs.length / pageSizeNum);
page = parseInt(page);
if (page >= 1 && page <= totalPages) {
currentPageNum = page;
updateUrlParams({ page: String(currentPageNum) });
renderTable();
}
}
function applyFilters(resetPage = true) {
filteredVMs = allVMs.filter(vm => {
// Type filter
if (currentTypeFilter && vm.type !== currentTypeFilter) {
return false;
}
// Status filter
if (currentStatusFilter && vm.status !== currentStatusFilter) {
return false;
}
// Search filter
if (currentSearchQuery) {
const query = currentSearchQuery.toLowerCase();
const searchFields = [
vm.name,
vm.hostname,
String(vm.vmid),
vm.ip,
vm.owner,
vm.node,
vm.cluster,
vm.server_name
].filter(Boolean).map(f => f.toLowerCase());
if (!searchFields.some(f => f.includes(query))) {
return false;
}
}
return true;
});
if (resetPage) {
currentPageNum = 1;
document.getElementById('currentPage').value = 1;
} else {
// Ensure current page is valid after filtering
const totalPages = Math.ceil(filteredVMs.length / pageSizeNum) || 1;
if (currentPageNum > totalPages) {
currentPageNum = totalPages;
document.getElementById('currentPage').value = currentPageNum;
}
}
updateTotalCount();
renderTable();
}
function filterByType(type) {
currentTypeFilter = type;
updateUrlParams({ type: type, page: '1' });
applyFilters();
}
function filterByStatus(status) {
currentStatusFilter = status;
updateUrlParams({ status: status, page: '1' });
applyFilters();
}
function searchVMs(query) {
currentSearchQuery = query;
updateUrlParams({ search: query, page: '1' });
applyFilters();
}
function toggleFilters() {
// Legacy - now using inline filters
}
function switchVMTab(tab) {
document.querySelectorAll('.vm-tab').forEach(t => t.classList.remove('active'));
document.querySelector(`.vm-tab[data-tab="${tab}"]`).classList.add('active');
const vmContent = document.querySelector('.vm-toolbar');
const vmTable = document.querySelector('.vm-table-wrapper');
const bulkBar = document.getElementById('bulkActionsBar');
const snapshotsContent = document.getElementById('snapshotsTabContent');
if (tab === 'vms') {
// Show VMs content
if (vmContent) vmContent.style.display = '';
if (vmTable) vmTable.style.display = '';
if (bulkBar) bulkBar.style.display = '';
if (snapshotsContent) snapshotsContent.style.display = 'none';
loadAllVMs();
} else if (tab === 'snapshots') {
// Show Snapshots content
if (vmContent) vmContent.style.display = 'none';
if (vmTable) vmTable.style.display = 'none';
if (bulkBar) bulkBar.style.display = 'none';
if (snapshotsContent) snapshotsContent.style.display = 'block';
initSnapshotsTab();
}
}
function showVMActions(event, serverId, vmid, type, node, vmName, status) {
event.stopPropagation();
// Close any existing menu
const existingMenu = document.getElementById('vmContextMenu');
if (existingMenu) {
existingMenu.remove();
}
// Create context menu
const menu = document.createElement('div');
menu.id = 'vmContextMenu';
menu.className = 'vm-context-menu';
const isRunning = status === 'running';
menu.innerHTML = `
${isRunning ? `
<div class="vm-context-menu-item" onclick="vmAction('stop', ${serverId}, ${vmid}, '${type}', '${node}', '${vmName}')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="6" y="6" width="12" height="12"></rect></svg>
<span>{{ t('stop') }}</span>
</div>
<div class="vm-context-menu-item" onclick="vmAction('restart', ${serverId}, ${vmid}, '${type}', '${node}', '${vmName}')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"></polyline><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"></path></svg>
<span>{{ t('restart') }}</span>
</div>
` : `
<div class="vm-context-menu-item" onclick="vmAction('start', ${serverId}, ${vmid}, '${type}', '${node}', '${vmName}')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"></polygon></svg>
<span>{{ t('start') }}</span>
</div>
`}
<div class="vm-context-menu-divider"></div>
<div class="vm-context-menu-item" onclick="openVNCConsole(${serverId}, ${vmid}, '${type}', '${node}', '${vmName}')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect><line x1="8" y1="21" x2="16" y2="21"></line><line x1="12" y1="17" x2="12" y2="21"></line></svg>
<span>VNC {{ t('console') }}</span>
</div>
<div class="vm-context-menu-item" onclick="openVMSettings(${serverId}, ${vmid}, '${type}', '${node}')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
<span>{{ t('settings') }}</span>
</div>
<div class="vm-context-menu-divider"></div>
<div class="vm-context-menu-item" onclick="reinstallVM(${serverId}, ${vmid}, '${type}', '${node}', '${vmName}')" data-permission="vms.reinstall">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"></polyline><polyline points="23 20 23 14 17 14"></polyline><path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"></path></svg>
<span>{{ t('reinstall') }}</span>
</div>
<div class="vm-context-menu-item danger" onclick="confirmDeleteVM(${serverId}, ${vmid}, '${type}', '${node}', '${vmName}')" data-permission="vms.delete">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path><line x1="10" y1="11" x2="10" y2="17"></line><line x1="14" y1="11" x2="14" y2="17"></line></svg>
<span>{{ t('delete') }}</span>
</div>
`;
document.body.appendChild(menu);
// Apply permissions to menu items
if (window.applyPermissions) {
window.applyPermissions();
}
// Position menu
const rect = event.target.getBoundingClientRect();
menu.style.top = `${rect.bottom + 5}px`;
menu.style.left = `${rect.left - menu.offsetWidth + rect.width}px`;
// Adjust if menu goes off screen
const menuRect = menu.getBoundingClientRect();
if (menuRect.right > window.innerWidth) {
menu.style.left = `${window.innerWidth - menuRect.width - 10}px`;
}
if (menuRect.bottom > window.innerHeight) {
menu.style.top = `${rect.top - menuRect.height - 5}px`;
}
// Close menu when clicking outside
setTimeout(() => {
document.addEventListener('click', closeVMContextMenu);
}, 10);
}
function closeVMContextMenu() {
const menu = document.getElementById('vmContextMenu');
if (menu) {
menu.remove();
}
document.removeEventListener('click', closeVMContextMenu);
}
async function vmAction(action, serverId, vmid, type, node, vmName) {
closeVMContextMenu();
const actionLabels = {
'start': '{{ t("starting") }}',
'stop': '{{ t("stopping") }}',
'restart': '{{ t("restarting") }}'
};
// Show pending status
updateVMStatusUI(serverId, vmid, 'pending', action);
showToast('info', '{{ t("info") }}', `${actionLabels[action] || action} ${vmName}...`);
try {
// Use correct API path based on type (qemu->vm, lxc->container)
const apiType = type === 'lxc' ? 'container' : 'vm';
const response = await fetchWithAuth(`/proxmox/api/${serverId}/${apiType}/${vmid}/${action}?node=${node}`, {
method: 'POST'
});
if (response.ok) {
showToast('success', '{{ t("success") }}', `${vmName} - ${action} {{ t("completed") }}`);
// Poll for actual status update instead of reloading whole table
pollVMStatus(serverId, vmid, type, node, action);
} else {
const data = await response.json();
showToast('error', '{{ t("error") }}', data.detail || '{{ t("operation_failed") }}');
// Revert to previous status on error
revertVMStatus(serverId, vmid);
}
} catch (error) {
console.error('VM action error:', error);
showToast('error', '{{ t("error") }}', '{{ t("operation_failed") }}');
revertVMStatus(serverId, vmid);
}
}
// Update VM status in UI without reloading table
function updateVMStatusUI(serverId, vmid, status, action = null) {
const elementId = `vm-status-${serverId}-${vmid}`;
const statusEl = document.getElementById(elementId);
console.log(`[updateVMStatusUI] Looking for element: ${elementId}, found: ${!!statusEl}, status: ${status}, action: ${action}`);
if (!statusEl) {
console.warn(`[updateVMStatusUI] Element not found: ${elementId}`);
return;
}
// Store previous status for revert
if (!statusEl.dataset.prevStatus && statusEl.dataset.status) {
statusEl.dataset.prevStatus = statusEl.dataset.status;
}
// Update status badge
statusEl.className = `vm-status-badge ${status}`;
statusEl.dataset.status = status;
if (status === 'pending') {
const actionLabel = action === 'start' ? '{{ t("starting") }}' :
action === 'stop' ? '{{ t("stopping") }}' :
action === 'restart' ? '{{ t("restarting") }}' :
action === 'shutdown' ? '{{ t("shutting_down") }}' : '...';
statusEl.innerHTML = `<span class="status-spinner"></span> ${actionLabel}`;
console.log(`[updateVMStatusUI] Set pending with spinner: ${statusEl.innerHTML}`);
} else if (status === 'running') {
statusEl.textContent = t('active');
} else {
statusEl.textContent = t('inactive');
}
// Also update in allVMs array
const vm = allVMs.find(v => v.server_id === serverId && v.vmid === vmid);
if (vm && status !== 'pending') {
vm.status = status;
// Re-apply filters to update filtered list and stats
applyFiltersWithoutReset();
}
}
// Apply filters without resetting page
function applyFiltersWithoutReset() {
filteredVMs = allVMs.filter(vm => {
// Type filter
if (currentTypeFilter && vm.type !== currentTypeFilter) {
return false;
}
// Status filter
if (currentStatusFilter && vm.status !== currentStatusFilter) {
return false;
}
// Search filter
if (currentSearchQuery) {
const query = currentSearchQuery.toLowerCase();
const searchFields = [
vm.name,
vm.hostname,
String(vm.vmid),
vm.ip,
vm.owner,
vm.node,
vm.cluster,
vm.server_name
].filter(Boolean).map(f => f.toLowerCase());
if (!searchFields.some(f => f.includes(query))) {
return false;
}
}
return true;
});
// Just update counts and stats, keep current page
document.getElementById('totalCount').textContent = filteredVMs.length;
updateStats();
// Re-render table to update visible rows
renderTable();
}
// Revert VM status on error
function revertVMStatus(serverId, vmid) {
const statusEl = document.getElementById(`vm-status-${serverId}-${vmid}`);
if (!statusEl || !statusEl.dataset.prevStatus) return;
updateVMStatusUI(serverId, vmid, statusEl.dataset.prevStatus);
delete statusEl.dataset.prevStatus;
}
// Poll VM status until it changes
async function pollVMStatus(serverId, vmid, type, node, action, attempts = 0, phase = 'initial') {
const maxAttempts = 30;
const pollInterval = 1500;
if (attempts >= maxAttempts) {
loadAllVMs();
return;
}
try {
const apiType = type === 'lxc' ? 'container' : 'vm';
const response = await fetchWithAuth(`/proxmox/api/${serverId}/${apiType}/${vmid}/status?node=${node}`);
if (response && response.ok) {
const data = await response.json();
const newStatus = data.status || 'stopped';
// For restart, we need to wait for stopped first, then running
if (action === 'restart') {
if (phase === 'initial' && newStatus === 'stopped') {
updateVMStatusUI(serverId, vmid, 'pending', 'restart');
setTimeout(() => pollVMStatus(serverId, vmid, type, node, action, 0, 'restarting'), pollInterval);
return;
} else if (phase === 'restarting' && newStatus === 'running') {
updateVMStatusUI(serverId, vmid, newStatus);
updateVMActionButton(serverId, vmid, type, node, newStatus);
return;
}
} else {
const expectedStatus = action === 'start' ? 'running' :
action === 'stop' ? 'stopped' : null;
if (expectedStatus && newStatus === expectedStatus) {
updateVMStatusUI(serverId, vmid, newStatus);
updateVMActionButton(serverId, vmid, type, node, newStatus);
return;
}
}
// Continue polling
setTimeout(() => pollVMStatus(serverId, vmid, type, node, action, attempts + 1, phase), pollInterval);
} else {
setTimeout(() => pollVMStatus(serverId, vmid, type, node, action, attempts + 1, phase), pollInterval);
}
} catch (error) {
setTimeout(() => pollVMStatus(serverId, vmid, type, node, action, attempts + 1, phase), pollInterval);
}
}
// Update action button with new status for context menu
function updateVMActionButton(serverId, vmid, type, node, newStatus) {
const btn = document.getElementById(`vm-action-btn-${serverId}-${vmid}`);
if (btn) {
const vm = allVMs.find(v => v.server_id === serverId && v.vmid === vmid);
const vmName = vm ? (vm.name || 'VM-' + vmid).replace(/'/g, "\\'") : 'VM-' + vmid;
btn.onclick = function(event) {
showVMActions(event, serverId, vmid, type, node, vmName, newStatus);
};
}
}
async function openVNCConsole(serverId, vmid, type, node, vmName) {
closeVMContextMenu();
const modal = document.getElementById('vncModal');
const vncContent = document.getElementById('vncContent');
modal.style.display = 'flex';
document.getElementById('vncTitle').innerHTML = `<i class="fa-solid fa-display"></i> VNC: ${vmName}`;
vncContent.innerHTML = `<div style="display: flex; align-items: center; justify-content: center; height: 100%; color: var(--text-muted);"><i class="fa-solid fa-spinner fa-spin"></i>&nbsp; {{ t('connecting') }}...</div>`;
try {
// Wait for RFB to be loaded
const RFBClass = await window.waitForRFB();
if (!RFBClass) {
throw new Error('noVNC not loaded');
}
// Use correct API path based on type (qemu->vm, lxc->container)
const apiType = type === 'lxc' ? 'container' : 'vm';
const response = await fetchWithAuth(`/proxmox/api/${serverId}/${apiType}/${vmid}/vnc?node=${node}`);
if (!response) {
throw new Error('Not authenticated');
}
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || `HTTP ${response.status}`);
}
const vncData = await response.json();
vncContent.innerHTML = '';
// Create WebSocket URL
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsHost = window.location.host;
const resourceType = vncData.type === 'qemu' ? 'vm' : 'container';
const wsUrl = `${protocol}//${wsHost}/proxmox/ws/vnc/${serverId}/${vncData.node}/${resourceType}/${vmid}?port=${vncData.port}&vncticket=${encodeURIComponent(vncData.ticket)}&auth_ticket=${encodeURIComponent(vncData.auth_ticket)}&vnc_password=${encodeURIComponent(vncData.password || '')}`;
const ws = new WebSocket(wsUrl);
ws.addEventListener('message', function onFirstMessage(event) {
const vncPassword = event.data;
ws.removeEventListener('message', onFirstMessage);
window.vncConnection = new RFBClass(vncContent, ws, {
credentials: { password: vncPassword }
});
window.vncConnection.scaleViewport = true;
window.vncConnection.resizeSession = true;
window.vncConnection.addEventListener('connect', () => {
setTimeout(() => {
if (window.vncConnection && window.vncConnection._display) {
window.vncConnection._display.autoscale(vncContent.clientWidth, vncContent.clientHeight);
}
}, 500);
});
window.vncConnection.addEventListener('credentialsrequired', (e) => {
window.vncConnection.sendCredentials({ password: vncPassword });
});
window.vncConnection.addEventListener('disconnect', (e) => {
vncContent.innerHTML = '<div style="display: flex; align-items: center; justify-content: center; height: 100%; color: var(--text-muted);">{{ t("connection_closed") }}</div>';
});
});
ws.addEventListener('error', (err) => {
vncContent.innerHTML = `<div style="display: flex; align-items: center; justify-content: center; height: 100%; color: var(--danger);">{{ t("connection_error") }}</div>`;
});
} catch (error) {
console.error('VNC error:', error);
vncContent.innerHTML = `<div style="display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; color: var(--text-muted); gap: 1rem;">
<div style="color: var(--danger);"><i class="fa-solid fa-triangle-exclamation"></i> ${error.message}</div>
<button onclick="closeVNCModal()" class="btn btn-secondary">{{ t('close') }}</button>
</div>`;
}
}
function closeVNCModal() {
const modal = document.getElementById('vncModal');
modal.style.display = 'none';
if (window.vncConnection) {
window.vncConnection.disconnect();
window.vncConnection = null;
}
}
function openVMSettings(serverId, vmid, type, node) {
closeVMContextMenu();
// Store context for modal
window.currentVMContext = { serverId, vmid, type, node };
showSettingsModal(serverId, vmid, type, node);
}
function reinstallVM(serverId, vmid, type, node, vmName) {
closeVMContextMenu();
// Store context for modal
window.currentVMContext = { serverId, vmid, type, node, vmName };
showReinstallModal(serverId, vmid, type, node, vmName);
}
function confirmDeleteVM(serverId, vmid, type, node, vmName) {
closeVMContextMenu();
// Check permission before showing modal
if (!window.hasPermission('vms.delete')) {
showToast('error', '{{ t("error") }}', '{{ t("no_permission") }}');
return;
}
// Store context for modal
window.currentVMContext = { serverId, vmid, type, node, vmName };
showDeleteConfirmModal(vmName, vmid);
}
function showDeleteConfirmModal(vmName, vmid) {
document.getElementById('deleteConfirmVMName').textContent = vmName;
document.getElementById('deleteConfirmVMID').textContent = vmid;
document.getElementById('deleteConfirmModal').style.display = 'flex';
}
function closeDeleteConfirmModal() {
document.getElementById('deleteConfirmModal').style.display = 'none';
}
async function executeDelete() {
const ctx = window.currentVMContext;
if (!ctx) {
showToast('error', '{{ t("error") }}', 'No VM context');
return;
}
// Double-check permission
if (!window.hasPermission('vms.delete')) {
closeDeleteConfirmModal();
showToast('error', '{{ t("error") }}', '{{ t("no_permission") }}');
return;
}
closeDeleteConfirmModal();
showToast('info', '{{ t("info") }}', `{{ t("deleting") }} ${ctx.vmName}...`);
try {
const apiType = ctx.type === 'lxc' ? 'container' : 'vm';
const url = `/proxmox/api/${ctx.serverId}/${apiType}/${ctx.vmid}?node=${ctx.node}`;
const response = await fetchWithAuth(url, {
method: 'DELETE'
});
if (response && response.ok) {
showToast('success', '{{ t("success") }}', `${ctx.vmName} {{ t("deleted") }}`);
// Remove from local arrays and re-render (use == for type coercion)
allVMs = allVMs.filter(vm => !(vm.server_id == ctx.serverId && vm.vmid == ctx.vmid));
filteredVMs = filteredVMs.filter(vm => !(vm.server_id == ctx.serverId && vm.vmid == ctx.vmid));
renderTable();
} else if (response) {
const data = await response.json().catch(() => ({}));
let errorMsg = data.detail || '{{ t("delete_failed") }}';
// Check for permission error from server
if (response.status === 403) {
errorMsg = '{{ t("no_permission") }}';
}
showToast('error', '{{ t("error") }}', errorMsg);
} else {
showToast('error', '{{ t("error") }}', '{{ t("delete_failed") }}');
}
} catch (error) {
showToast('error', '{{ t("error") }}', error.message || '{{ t("delete_failed") }}');
}
}
// Get unique servers from loaded VMs
function getUniqueServers() {
const serversMap = new Map();
allVMs.forEach(vm => {
if (!serversMap.has(vm.server_id)) {
serversMap.set(vm.server_id, {
id: vm.server_id,
name: vm.server_name || vm.cluster
});
}
});
return Array.from(serversMap.values());
}
// ============================================
// IPAM NETWORKS (shared for both VM and LXC)
// ============================================
let ipamNetworks = [];
async function loadIPAMNetworks() {
try {
const response = await fetchWithAuth('/ipam/api/networks?is_active=true', { hideOnForbidden: true });
if (response && response.ok) {
ipamNetworks = await response.json();
} else {
ipamNetworks = [];
}
} catch (error) {
console.warn('Failed to load IPAM networks:', error);
ipamNetworks = [];
}
return ipamNetworks;
}
function buildIPAMNetworkOptions() {
if (!ipamNetworks || ipamNetworks.length === 0) {
return '<option value="">{{ t("no_ipam") }}</option>';
}
let options = '<option value="">{{ t("no_ipam") }}</option>';
for (const net of ipamNetworks) {
const available = net.available_ips || 0;
const utilization = net.utilization_percent || 0;
const cidr = net.network || net.cidr || '';
options += `<option value="${net.id}" data-gateway="${net.gateway || ''}" data-cidr="${cidr}">${net.name} (${cidr}) - ${available} {{ t("available") }} (${utilization}%)</option>`;
}
return options;
}
// Check if server is in cluster mode (HA available)
async function checkServerClusterMode(serverId) {
try {
const response = await fetchWithAuth(`/proxmox/api/servers/${serverId}/cluster-info`);
if (response.ok) {
const data = await response.json();
return data.is_cluster === true;
}
} catch (e) {
console.warn('Could not check cluster mode:', e);
}
return false;
}
// Update HA option visibility based on cluster mode
function updateHAOptionVisibility(isCluster, prefix = 'vm') {
const haOption = document.getElementById(`${prefix}HAOption`);
if (haOption) {
haOption.style.display = isCluster ? 'flex' : 'none';
}
// Uncheck HA if not in cluster
if (!isCluster) {
const haCheckbox = document.getElementById(`${prefix}EnableHA`);
if (haCheckbox) haCheckbox.checked = false;
}
}
// ============================================
// CREATE VM MODAL
// ============================================
let createVMServerId = null;
let createVMStorages = [];
let createVMNodes = [];
function showCreateVMModal() {
const modal = document.getElementById('createVMModal');
document.getElementById('createVMModalTitle').innerHTML = '<i class="fa-solid fa-plus"></i> {{ t("create_vm") }}';
document.getElementById('createVMContent').innerHTML = '<div style="text-align: center; padding: 2rem; color: var(--text-muted);"><i class="fa-solid fa-spinner fa-spin"></i> {{ t("loading") }}...</div>';
modal.style.display = 'flex';
loadCreateVMForm();
}
async function loadCreateVMForm() {
const content = document.getElementById('createVMContent');
try {
// Load templates for all servers FIRST
const templatesResponse = await fetchWithAuth('/templates/api/templates');
const allTemplates = templatesResponse.ok ? await templatesResponse.json() : [];
// Load template groups
const groupsResponse = await fetchWithAuth('/templates/api/groups');
const groups = groupsResponse.ok ? await groupsResponse.json() : [];
if (allTemplates.length === 0) {
content.innerHTML = `
<div style="text-align: center; padding: 2rem;">
<div style="font-size: 3rem; margin-bottom: 1rem; color: var(--text-muted);"><i class="fa-solid fa-box-open"></i></div>
<p style="color: var(--text-secondary);">{{ t('no_templates') }}</p>
<p style="color: var(--text-muted); font-size: 0.875rem; margin-top: 0.5rem;">
{{ t('go_to') }} <a href="/templates" style="color: var(--accent);">{{ t('nav_templates') }}</a> {{ t('and_add_templates') }}
</p>
<button onclick="closeCreateVMModal()" class="btn" style="margin-top: 1rem;">{{ t('close') }}</button>
</div>`;
return;
}
// Build servers map from templates (only servers with templates)
const serversMap = new Map();
allTemplates.forEach(t => {
if (!serversMap.has(t.server_id)) {
serversMap.set(t.server_id, {
id: t.server_id,
name: t.server_name || `Server ${t.server_id}`
});
}
});
const allServers = Array.from(serversMap.values());
// Use first server with templates
createVMServerId = allServers[0].id;
// Filter templates for selected server
const vmTemplates = allTemplates.filter(t => t.server_id === createVMServerId);
// Build server options
let serverOptions = allServers.map(s =>
`<option value="${s.id}" ${s.id === createVMServerId ? 'selected' : ''}>${s.name}</option>`
).join('');
// Build template options grouped by category
let templateOptions = buildTemplateOptions(groups, vmTemplates);
// Load nodes for first server
await loadNodesForServer(createVMServerId);
// Determine the node where templates are located
const templateNodes = [...new Set(vmTemplates.map(t => t.node || t.source_node).filter(Boolean))];
const preferredNode = templateNodes.length > 0 ? templateNodes[0] : null;
let nodeOptions = createVMNodes.map((n) => {
const isPreferred = preferredNode && n.node === preferredNode;
return `<option value="${n.node}" ${isPreferred ? 'selected' : ''}>${n.node} ${n.status === 'online' ? '' : '(offline)'}</option>`;
}).join('');
if (!nodeOptions) nodeOptions = '<option value="">{{ t("auto") }}</option>';
// Store preferred node for later use
window.templatePreferredNode = preferredNode;
// Load storages for the preferred node
await loadStoragesForServer(createVMServerId, preferredNode);
let storageOptions = createVMStorages.map((s, idx) =>
`<option value="${s.storage}" ${idx === 0 ? 'selected' : ''}>${s.storage}</option>`
).join('');
if (!storageOptions) storageOptions = '<option value="local-lvm">local-lvm</option>';
// Load IPAM networks
await loadIPAMNetworks();
let ipamOptions = buildIPAMNetworkOptions();
// Check if server is in cluster mode (for HA option visibility)
let isCluster = await checkServerClusterMode(createVMServerId);
window.currentServerIsCluster = isCluster;
content.innerHTML = `
<form id="createVMForm" onsubmit="executeCreateVM(event)">
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;">
<div class="form-group">
<label>{{ t('server') }} *</label>
<select id="vmServerSelect" onchange="onVMServerChange(this.value)" class="form-control">
${serverOptions}
</select>
</div>
<div class="form-group">
<label>{{ t('target_node') }}</label>
<select id="vmNodeSelect" class="form-control">
<option value="">{{ t('auto') }}</option>
${nodeOptions}
</select>
</div>
<div class="form-group" style="grid-column: 1 / -1;">
<label>{{ t('os_template') }} *</label>
<select id="vmTemplateSelect" required onchange="onVMTemplateChange()" class="form-control">
${templateOptions}
</select>
</div>
<div class="form-group" style="grid-column: 1 / -1;">
<label>{{ t('vm_name') }} *</label>
<input type="text" id="vmNameInput" required placeholder="my-server" pattern="[a-zA-Z0-9\\-_]+" class="form-control">
</div>
<div class="form-group">
<label>CPU ({{ t('cores') }})</label>
<input type="number" id="vmCoresInput" min="1" max="128" value="1" class="form-control">
</div>
<div class="form-group">
<label>{{ t('memory') }} (MB)</label>
<input type="number" id="vmMemoryInput" min="128" step="128" value="1024" class="form-control">
</div>
<div class="form-group">
<label>{{ t('disk') }} (GB)</label>
<input type="number" id="vmDiskInput" min="1" value="10" class="form-control">
</div>
<div class="form-group">
<label>{{ t('storage') }}</label>
<select id="vmStorageSelect" class="form-control">
${storageOptions}
</select>
</div>
<div class="form-group" style="grid-column: 1 / -1;">
<label><i class="fa-solid fa-network-wired"></i> {{ t('ipam_network') }}</label>
<select id="vmIPAMNetworkSelect" onchange="onVMIPAMNetworkChange()" class="form-control">
${ipamOptions}
</select>
<small style="color: var(--text-muted); font-size: 0.8rem;">{{ t('select_network_for_auto_ip') }}</small>
</div>
</div>
<div style="margin-top: 1rem; display: flex; flex-direction: column; gap: 0.75rem;">
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
<input type="checkbox" id="vmStartAfterCreate" checked>
{{ t('start_after_create') }}
</label>
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
<input type="checkbox" id="vmOnboot">
<i class="fa-solid fa-power-off" style="color: var(--text-muted);"></i>
{{ t('autostart_on_boot') }}
</label>
<label id="vmHAOption" style="display: ${isCluster ? 'flex' : 'none'}; align-items: center; gap: 0.5rem; cursor: pointer;">
<input type="checkbox" id="vmEnableHA">
<i class="fa-solid fa-shield-halved" style="color: var(--text-muted);"></i>
{{ t('high_availability') }}
<small style="color: var(--success); font-size: 0.75rem; margin-left: 0.5rem;"><i class="fa-solid fa-check-circle"></i> {{ t('cluster_detected') }}</small>
</label>
</div>
<div id="createVMStatus" style="display: none; margin-top: 1rem; padding: 1rem; border-radius: 6px;"></div>
<div style="display: flex; gap: 1rem; justify-content: flex-end; margin-top: 1.5rem; padding-top: 1rem; border-top: 1px solid var(--border-color);">
<button type="button" onclick="closeCreateVMModal()" class="btn btn-secondary">{{ t('cancel') }}</button>
<button type="submit" id="createVMBtn" class="btn btn-success"><i class="fa-solid fa-rocket"></i> {{ t('create') }}</button>
</div>
</form>
`;
// Store templates for later use
window.allVMTemplates = allTemplates;
window.templateGroups = groups;
} catch (error) {
console.error('Error loading create VM form:', error);
content.innerHTML = `
<div style="text-align: center; padding: 2rem;">
<div style="font-size: 2rem; margin-bottom: 1rem; color: var(--warning);"><i class="fa-solid fa-triangle-exclamation"></i></div>
<p style="color: var(--text-secondary);">${error.message}</p>
<button onclick="closeCreateVMModal()" class="btn" style="margin-top: 1rem;">{{ t('close') }}</button>
</div>`;
}
}
function buildTemplateOptions(groups, templates) {
if (!templates || templates.length === 0) {
return '<option value="">{{ t("no_templates") }}</option>';
}
// Group templates by group_id
const grouped = {};
const usedTemplateIds = new Set();
templates.forEach(t => {
const gid = t.group_id || 'other';
if (!grouped[gid]) grouped[gid] = [];
grouped[gid].push(t);
});
let html = '<option value="">{{ t("select_template") }}</option>';
// Add templates by groups
if (groups && groups.length > 0) {
groups.forEach(g => {
if (grouped[g.id] && grouped[g.id].length > 0) {
html += `<optgroup label="${g.name}">`;
grouped[g.id].forEach(t => {
html += `<option value="${t.id}" data-cores="${t.default_cores}" data-memory="${t.default_memory}" data-disk="${t.default_disk}">${t.name}</option>`;
usedTemplateIds.add(t.id);
});
html += '</optgroup>';
}
});
}
// Add ungrouped templates (group_id is null or group not found)
const ungroupedTemplates = templates.filter(t => !usedTemplateIds.has(t.id));
if (ungroupedTemplates.length > 0) {
html += '<optgroup label="{{ t("other") }}">';
ungroupedTemplates.forEach(t => {
html += `<option value="${t.id}" data-cores="${t.default_cores}" data-memory="${t.default_memory}" data-disk="${t.default_disk}">${t.name}</option>`;
});
html += '</optgroup>';
}
return html;
}
async function onVMServerChange(serverId) {
createVMServerId = parseInt(serverId);
// Reload templates for this server first to determine the node
const vmTemplates = (window.allVMTemplates || []).filter(t => t.server_id === createVMServerId);
// Determine the node where templates are located
const templateNodes = [...new Set(vmTemplates.map(t => t.node || t.source_node).filter(Boolean))];
const preferredNode = templateNodes.length > 0 ? templateNodes[0] : null;
window.templatePreferredNode = preferredNode;
// Check cluster mode and update HA option visibility
const isCluster = await checkServerClusterMode(createVMServerId);
window.currentServerIsCluster = isCluster;
updateHAOptionVisibility(isCluster, 'vm');
// Reload nodes
await loadNodesForServer(createVMServerId);
const nodeSelect = document.getElementById('vmNodeSelect');
nodeSelect.innerHTML = '<option value="">{{ t("auto") }}</option>' +
createVMNodes.map((n) => {
const isPreferred = preferredNode && n.node === preferredNode;
return `<option value="${n.node}" ${isPreferred ? 'selected' : ''}>${n.node} ${n.status === 'online' ? '' : '(offline)'}</option>`;
}).join('');
// Reload storages for the preferred node
await loadStoragesForServer(createVMServerId, preferredNode);
const storageSelect = document.getElementById('vmStorageSelect');
storageSelect.innerHTML = createVMStorages.length > 0
? createVMStorages.map((s, idx) => `<option value="${s.storage}" ${idx === 0 ? 'selected' : ''}>${s.storage}</option>`).join('')
: '<option value="local-lvm">local-lvm</option>';
// Reload templates for this server
const templateSelect = document.getElementById('vmTemplateSelect');
templateSelect.innerHTML = buildTemplateOptions(window.templateGroups || [], vmTemplates);
}
async function loadNodesForServer(serverId) {
try {
const response = await fetchWithAuth(`/proxmox/api/${serverId}/nodes`);
if (response.ok) {
const data = await response.json();
createVMNodes = data.nodes || data || [];
}
} catch (e) {
createVMNodes = [];
}
}
async function loadStoragesForServer(serverId, preferredNode = null) {
try {
const node = preferredNode || (createVMNodes.length > 0 ? createVMNodes[0].node : null);
if (node) {
const response = await fetchWithAuth(`/proxmox/api/${serverId}/storages?node=${node}`);
if (response.ok) {
const data = await response.json();
createVMStorages = (data.storages || []).filter(s => s.content && s.content.includes('images'));
}
}
} catch (e) {
createVMStorages = [];
}
}
function onVMTemplateChange() {
const select = document.getElementById('vmTemplateSelect');
const opt = select.options[select.selectedIndex];
if (opt && opt.value) {
document.getElementById('vmCoresInput').value = opt.dataset.cores || 1;
document.getElementById('vmMemoryInput').value = opt.dataset.memory || 1024;
document.getElementById('vmDiskInput').value = opt.dataset.disk || 10;
}
}
function onVMIPAMNetworkChange() {
// Could add preview of selected network info here if needed
}
async function executeCreateVM(event) {
event.preventDefault();
const templateId = document.getElementById('vmTemplateSelect').value;
const name = document.getElementById('vmNameInput').value.trim();
if (!templateId || !name) {
showToast('warning', '{{ t("warning") }}', '{{ t("fill_required_fields") }}');
return;
}
const btn = document.getElementById('createVMBtn');
const originalText = btn.innerHTML;
btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> {{ t("creating") }}...';
btn.disabled = true;
const statusEl = document.getElementById('createVMStatus');
statusEl.style.display = 'block';
statusEl.style.background = 'var(--accent-dark)';
statusEl.style.color = 'var(--text-primary)';
statusEl.innerHTML = '<i class="fa-solid fa-rotate fa-spin"></i> {{ t("cloning_template") }}...';
// Check if IPAM network selected for auto IP allocation
const ipamNetworkId = document.getElementById('vmIPAMNetworkSelect')?.value;
let allocatedIP = null;
let allocatedGateway = null;
let allocationId = null;
if (ipamNetworkId) {
statusEl.innerHTML = '<i class="fa-solid fa-rotate fa-spin"></i> {{ t("allocating_ip") }}...';
try {
const allocResponse = await fetchWithAuth('/ipam/api/allocations/auto', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
network_id: parseInt(ipamNetworkId),
resource_type: 'vm',
resource_name: name,
hostname: name,
notes: 'Auto-allocated during VM creation'
})
});
if (allocResponse.ok) {
const allocation = await allocResponse.json();
allocatedIP = allocation.ip_address;
allocationId = allocation.id;
// Get gateway from network
const netSelect = document.getElementById('vmIPAMNetworkSelect');
const selectedOpt = netSelect.options[netSelect.selectedIndex];
allocatedGateway = selectedOpt.dataset.gateway;
statusEl.innerHTML = `<i class="fa-solid fa-rotate fa-spin"></i> {{ t("cloning_template") }}... (IP: ${allocatedIP})`;
} else {
const err = await allocResponse.json();
throw new Error(err.detail || 'Failed to allocate IP');
}
} catch (error) {
console.error('IP allocation error:', error);
statusEl.style.background = 'var(--danger-dark)';
statusEl.innerHTML = `<i class="fa-solid fa-circle-xmark"></i> {{ t("ip_allocation_failed") }}: ${error.message}`;
btn.innerHTML = originalText;
btn.disabled = false;
return;
}
}
const deployData = {
template_id: parseInt(templateId),
name: name,
cores: parseInt(document.getElementById('vmCoresInput').value) || null,
memory: parseInt(document.getElementById('vmMemoryInput').value) || null,
disk: parseInt(document.getElementById('vmDiskInput').value) || null,
target_storage: document.getElementById('vmStorageSelect').value || null,
target_node: document.getElementById('vmNodeSelect').value || null,
start_after_create: document.getElementById('vmStartAfterCreate').checked,
onboot: document.getElementById('vmOnboot').checked,
enable_ha: document.getElementById('vmEnableHA').checked,
ip_address: allocatedIP,
gateway: allocatedGateway,
ipam_network_id: ipamNetworkId ? parseInt(ipamNetworkId) : null
};
try {
const response = await fetchWithAuth('/templates/api/deploy', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(deployData)
});
const result = await response.json();
if (response.ok) {
statusEl.style.background = 'var(--success-dark)';
statusEl.innerHTML = `<i class="fa-solid fa-circle-check"></i> VM "${result.name}" {{ t("created") }}!<br>VMID: ${result.vmid}`;
showToast('success', '{{ t("success") }}', `VM ${result.name} {{ t("created") }}`);
setTimeout(() => {
closeCreateVMModal();
loadAllVMs();
}, 1500);
} else {
statusEl.style.background = 'var(--danger-dark)';
statusEl.innerHTML = `<i class="fa-solid fa-circle-xmark"></i> ${result.detail || '{{ t("create_failed") }}'}`;
}
} catch (error) {
console.error('Create VM error:', error);
statusEl.style.background = 'var(--danger-dark)';
statusEl.innerHTML = `<i class="fa-solid fa-circle-xmark"></i> {{ t("error") }}: ${error.message}`;
} finally {
btn.innerHTML = originalText;
btn.disabled = false;
}
}
function closeCreateVMModal() {
document.getElementById('createVMModal').style.display = 'none';
}
// ============================================
// CREATE LXC MODAL
// ============================================
let createLXCServerId = null;
let createLXCTemplates = [];
let createLXCStorages = [];
let createLXCNodes = [];
function showCreateLXCModal() {
const modal = document.getElementById('createLXCModal');
document.getElementById('createLXCContent').innerHTML = '<div style="text-align: center; padding: 2rem; color: var(--text-muted);"><i class="fa-solid fa-spinner fa-spin"></i> {{ t("loading") }}...</div>';
modal.style.display = 'flex';
loadCreateLXCForm();
}
async function loadCreateLXCForm() {
const content = document.getElementById('createLXCContent');
// Load all servers from API instead of just from loaded VMs
let servers = [];
try {
const serversResponse = await fetchWithAuth('/proxmox/api/servers');
if (serversResponse.ok) {
servers = await serversResponse.json();
}
} catch (e) {
console.error('Failed to load servers:', e);
}
// Fallback to unique servers from VMs if API fails
if (servers.length === 0) {
servers = getUniqueServers();
}
if (servers.length === 0) {
content.innerHTML = `
<div style="text-align: center; padding: 2rem;">
<div style="font-size: 3rem; margin-bottom: 1rem; color: var(--text-muted);"><i class="fa-solid fa-server"></i></div>
<p style="color: var(--text-secondary);">{{ t('no_servers_available') }}</p>
<button onclick="closeCreateLXCModal()" class="btn" style="margin-top: 1rem;">{{ t('close') }}</button>
</div>`;
return;
}
try {
// Use first server and load ALL templates from all nodes
createLXCServerId = servers[0].id;
// Load nodes and ALL templates from cluster
await loadLXCNodesForServer(createLXCServerId);
await loadAllLXCTemplatesForServer(createLXCServerId);
const selectedNode = createLXCNodes.length > 0 ? createLXCNodes[0].node : null;
// Load storages for selected node
if (selectedNode) {
await loadLXCStoragesForServer(createLXCServerId, selectedNode);
}
// Build options
let serverOptions = servers.map(s =>
`<option value="${s.id}" ${s.id === createLXCServerId ? 'selected' : ''}>${s.name}</option>`
).join('');
let nodeOptions = createLXCNodes.map((n, idx) =>
`<option value="${n.node}" ${idx === 0 ? 'selected' : ''}>${n.node} ${n.status === 'online' ? '' : '(offline)'}</option>`
).join('');
// Template options with node info
let templateOptions = createLXCTemplates.map(t => {
const name = t.volid.split('/').pop();
const nodeInfo = t.shared ? '(shared)' : `(${t.node})`;
return `<option value="${t.volid}" data-node="${t.node}" data-shared="${t.shared}" data-storage="${t.storage}">${name} ${nodeInfo}</option>`;
}).join('');
if (!templateOptions) templateOptions = '<option value="">{{ t("no_templates") }}</option>';
let storageOptions = createLXCStorages.map((s, idx) =>
`<option value="${s.storage}" ${idx === 0 ? 'selected' : ''}>${s.storage}</option>`
).join('');
if (!storageOptions) storageOptions = '<option value="local-lvm">local-lvm</option>';
// Load IPAM networks (reuse already loaded or load fresh)
if (!ipamNetworks || ipamNetworks.length === 0) {
await loadIPAMNetworks();
}
let ipamOptions = buildIPAMNetworkOptions();
// Check if server is in cluster mode (for HA option visibility)
let isCluster = await checkServerClusterMode(createLXCServerId);
window.currentLXCServerIsCluster = isCluster;
content.innerHTML = `
<form id="createLXCForm" onsubmit="executeCreateLXC(event)">
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;">
<div class="form-group">
<label>{{ t('server') }} *</label>
<select id="lxcServerSelect" onchange="onLXCServerChange(this.value)" class="form-control">
${serverOptions}
</select>
</div>
<div class="form-group">
<label>{{ t('node') }} *</label>
<select id="lxcNodeSelect" required onchange="onLXCNodeChange(this.value)" class="form-control">
${nodeOptions}
</select>
</div>
<div class="form-group" style="grid-column: 1 / -1;">
<label>{{ t('lxc_template') }} *</label>
<select id="lxcTemplateSelect" required class="form-control">
<option value="">{{ t('select_template') }}</option>
${templateOptions}
</select>
</div>
<div class="form-group" style="grid-column: 1 / -1;">
<label>{{ t('hostname') }} *</label>
<input type="text" id="lxcHostnameInput" required placeholder="my-container" pattern="[a-zA-Z0-9\\-]+" class="form-control">
</div>
<div class="form-group">
<label>{{ t('root_password') }}</label>
<input type="password" id="lxcPasswordInput" placeholder="••••••••" class="form-control">
</div>
<div class="form-group">
<label>{{ t('storage') }}</label>
<select id="lxcStorageSelect" class="form-control">
${storageOptions}
</select>
</div>
<div class="form-group">
<label>CPU ({{ t('cores') }})</label>
<input type="number" id="lxcCoresInput" min="1" max="128" value="1" class="form-control">
</div>
<div class="form-group">
<label>{{ t('memory') }} (MB)</label>
<input type="number" id="lxcMemoryInput" min="128" step="128" value="512" class="form-control">
</div>
<div class="form-group">
<label>{{ t('disk') }} (GB)</label>
<input type="number" id="lxcDiskInput" min="1" value="8" class="form-control">
</div>
<div class="form-group">
<label>Swap (MB)</label>
<input type="number" id="lxcSwapInput" min="0" step="128" value="512" class="form-control">
</div>
<div class="form-group" style="grid-column: 1 / -1;">
<label><i class="fa-solid fa-network-wired"></i> {{ t('ipam_network') }}</label>
<select id="lxcIPAMNetworkSelect" class="form-control">
${ipamOptions}
</select>
<small style="color: var(--text-muted); font-size: 0.8rem;">{{ t('select_network_for_auto_ip') }}</small>
</div>
</div>
<div style="margin-top: 1rem; display: flex; flex-direction: column; gap: 0.75rem;">
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
<input type="checkbox" id="lxcStartAfterCreate" checked>
{{ t('start_after_create') }}
</label>
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
<input type="checkbox" id="lxcOnboot">
<i class="fa-solid fa-power-off" style="color: var(--text-muted);"></i>
{{ t('autostart_on_boot') }}
</label>
<label id="lxcHAOption" style="display: ${isCluster ? 'flex' : 'none'}; align-items: center; gap: 0.5rem; cursor: pointer;">
<input type="checkbox" id="lxcEnableHA">
<i class="fa-solid fa-shield-halved" style="color: var(--text-muted);"></i>
{{ t('high_availability') }}
<small style="color: var(--success); font-size: 0.75rem; margin-left: 0.5rem;"><i class="fa-solid fa-check-circle"></i> {{ t('cluster_detected') }}</small>
</label>
</div>
<div id="createLXCStatus" style="display: none; margin-top: 1rem; padding: 1rem; border-radius: 6px;"></div>
<div style="display: flex; gap: 1rem; justify-content: flex-end; margin-top: 1.5rem; padding-top: 1rem; border-top: 1px solid var(--border-color);">
<button type="button" onclick="closeCreateLXCModal()" class="btn btn-secondary">{{ t('cancel') }}</button>
<button type="submit" id="createLXCBtn" class="btn" style="background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 100%); color: var(--text-on-primary);"><i class="fa-solid fa-cube"></i> {{ t('create') }}</button>
</div>
</form>
`;
} catch (error) {
console.error('Error loading create LXC form:', error);
content.innerHTML = `
<div style="text-align: center; padding: 2rem;">
<div style="font-size: 2rem; margin-bottom: 1rem; color: var(--warning);"><i class="fa-solid fa-triangle-exclamation"></i></div>
<p style="color: var(--text-secondary);">${error.message}</p>
<button onclick="closeCreateLXCModal()" class="btn" style="margin-top: 1rem;">{{ t('close') }}</button>
</div>`;
}
}
async function onLXCServerChange(serverId) {
createLXCServerId = parseInt(serverId);
// Check cluster mode and update HA option visibility
const isCluster = await checkServerClusterMode(createLXCServerId);
window.currentLXCServerIsCluster = isCluster;
updateHAOptionVisibility(isCluster, 'lxc');
// Load nodes and ALL templates from this server/cluster
await loadLXCNodesForServer(createLXCServerId);
await loadAllLXCTemplatesForServer(createLXCServerId);
const nodeSelect = document.getElementById('lxcNodeSelect');
nodeSelect.innerHTML = createLXCNodes.map((n, idx) =>
`<option value="${n.node}" ${idx === 0 ? 'selected' : ''}>${n.node} ${n.status === 'online' ? '' : '(offline)'}</option>`
).join('');
// Update template select with all templates
updateLXCTemplateSelect();
if (createLXCNodes.length > 0) {
// Only load storages for selected node
await loadLXCStoragesForServer(createLXCServerId, createLXCNodes[0].node);
const storageSelect = document.getElementById('lxcStorageSelect');
if (storageSelect) {
storageSelect.innerHTML = createLXCStorages.length > 0
? createLXCStorages.map((s, idx) => `<option value="${s.storage}" ${idx === 0 ? 'selected' : ''}>${s.storage}</option>`).join('')
: '<option value="local-lvm">local-lvm</option>';
}
}
}
async function onLXCNodeChange(node) {
// Шаблоны уже загружены со всех нод, просто обновляем storages для выбранной ноды
await loadLXCStoragesForServer(createLXCServerId, node);
// Обновляем список шаблонов - показываем все доступные
updateLXCTemplateSelect();
const storageSelect = document.getElementById('lxcStorageSelect');
storageSelect.innerHTML = createLXCStorages.length > 0
? createLXCStorages.map((s, idx) => `<option value="${s.storage}" ${idx === 0 ? 'selected' : ''}>${s.storage}</option>`).join('')
: '<option value="local-lvm">local-lvm</option>';
}
function updateLXCTemplateSelect() {
const templateSelect = document.getElementById('lxcTemplateSelect');
if (!templateSelect) return;
// Показываем все шаблоны с информацией откуда они
templateSelect.innerHTML = '<option value="">{{ t("select_template") }}</option>' +
createLXCTemplates.map(t => {
const name = t.volid.split('/').pop();
const nodeInfo = t.shared ? '(shared)' : `(${t.node})`;
return `<option value="${t.volid}" data-node="${t.node}" data-shared="${t.shared}" data-storage="${t.storage}">${name} ${nodeInfo}</option>`;
}).join('');
}
async function loadLXCNodesForServer(serverId) {
try {
const response = await fetchWithAuth(`/proxmox/api/${serverId}/nodes`);
if (response.ok) {
const data = await response.json();
createLXCNodes = data.nodes || data || [];
}
} catch (e) {
createLXCNodes = [];
}
}
async function loadAllLXCTemplatesForServer(serverId) {
// Загружаем шаблоны со ВСЕХ нод кластера
try {
const response = await fetchWithAuth(`/proxmox/api/${serverId}/all-lxc-templates`);
if (response.ok) {
const data = await response.json();
createLXCTemplates = data.templates || [];
}
} catch (e) {
createLXCTemplates = [];
}
}
async function loadLXCTemplatesForServer(serverId, node) {
try {
const response = await fetchWithAuth(`/proxmox/api/${serverId}/lxc-templates?node=${node}`);
if (response.ok) {
const data = await response.json();
createLXCTemplates = data.templates || data || [];
}
} catch (e) {
createLXCTemplates = [];
}
}
async function loadLXCStoragesForServer(serverId, node) {
try {
const response = await fetchWithAuth(`/proxmox/api/${serverId}/storages?node=${node}`);
if (response.ok) {
const data = await response.json();
createLXCStorages = (data.storages || []).filter(s => s.content && s.content.includes('rootdir'));
}
} catch (e) {
createLXCStorages = [];
}
}
async function executeCreateLXC(event) {
event.preventDefault();
const templateSelect = document.getElementById('lxcTemplateSelect');
const template = templateSelect.value;
const hostname = document.getElementById('lxcHostnameInput').value.trim();
const targetNode = document.getElementById('lxcNodeSelect').value;
if (!template || !hostname || !targetNode) {
showToast('warning', '{{ t("warning") }}', '{{ t("fill_required_fields") }}');
return;
}
const password = document.getElementById('lxcPasswordInput').value;
if (!password) {
showToast('warning', '{{ t("warning") }}', '{{ t("password_required") }}');
return;
}
// Get template metadata from selected option
const selectedOption = templateSelect.options[templateSelect.selectedIndex];
const templateNode = selectedOption.dataset.node;
const isShared = selectedOption.dataset.shared === 'true';
const templateStorage = selectedOption.dataset.storage;
const btn = document.getElementById('createLXCBtn');
const originalText = btn.innerHTML;
btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> {{ t("creating") }}...';
btn.disabled = true;
const statusEl = document.getElementById('createLXCStatus');
statusEl.style.display = 'block';
statusEl.style.background = 'var(--accent-dark)';
statusEl.style.color = 'var(--text-primary)';
statusEl.innerHTML = '<i class="fa-solid fa-rotate fa-spin"></i> {{ t("creating_container") }}...';
// Check if IPAM network selected for auto IP allocation
const ipamNetworkId = document.getElementById('lxcIPAMNetworkSelect')?.value;
let allocatedIP = null;
let allocatedGateway = null;
let allocationId = null;
let net0Config = null;
if (ipamNetworkId) {
statusEl.innerHTML = '<i class="fa-solid fa-rotate fa-spin"></i> {{ t("allocating_ip") }}...';
try {
const allocResponse = await fetchWithAuth('/ipam/api/allocations/auto', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
network_id: parseInt(ipamNetworkId),
resource_type: 'lxc',
resource_name: hostname,
hostname: hostname,
notes: 'Auto-allocated during LXC creation'
})
});
if (allocResponse.ok) {
const allocation = await allocResponse.json();
allocatedIP = allocation.ip_address;
allocationId = allocation.id;
// Get gateway and CIDR from network
const netSelect = document.getElementById('lxcIPAMNetworkSelect');
const selectedNetOpt = netSelect.options[netSelect.selectedIndex];
allocatedGateway = selectedNetOpt.dataset.gateway;
const cidr = selectedNetOpt.dataset.cidr || '';
// Extract prefix length from CIDR (e.g., "10.0.0.0/24" -> "24")
const prefixLen = (cidr && cidr.includes('/')) ? cidr.split('/')[1] : '24';
// Build net0 config for Proxmox
net0Config = `name=eth0,bridge=vmbr0,ip=${allocatedIP}/${prefixLen},gw=${allocatedGateway}`;
statusEl.innerHTML = `<i class="fa-solid fa-rotate fa-spin"></i> {{ t("creating_container") }}... (IP: ${allocatedIP})`;
} else {
const err = await allocResponse.json();
throw new Error(err.detail || 'Failed to allocate IP');
}
} catch (error) {
console.error('IP allocation error:', error);
statusEl.style.background = 'var(--danger-dark)';
statusEl.innerHTML = `<i class="fa-solid fa-circle-xmark"></i> {{ t("ip_allocation_failed") }}: ${error.message}`;
btn.innerHTML = originalText;
btn.disabled = false;
return;
}
}
// Determine creation strategy
// If template is on shared storage OR template is on target node -> create directly on target
// If template is on local storage of different node -> create on template's node, then migrate
const needsMigration = !isShared && templateNode !== targetNode;
const createNode = needsMigration ? templateNode : targetNode;
if (needsMigration) {
statusEl.innerHTML = `<i class="fa-solid fa-rotate fa-spin"></i> {{ t("creating_container") }} on ${templateNode}, then migrating to ${targetNode}...`;
}
try {
const response = await fetchWithAuth(`/proxmox/api/${createLXCServerId}/create-lxc-smart`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
template_node: templateNode,
target_node: targetNode,
ostemplate: template,
hostname: hostname,
password: password,
storage: document.getElementById('lxcStorageSelect').value,
rootfs_size: parseInt(document.getElementById('lxcDiskInput').value),
cores: parseInt(document.getElementById('lxcCoresInput').value),
memory: parseInt(document.getElementById('lxcMemoryInput').value),
swap: parseInt(document.getElementById('lxcSwapInput').value),
start_after_create: document.getElementById('lxcStartAfterCreate').checked,
onboot: document.getElementById('lxcOnboot').checked,
enable_ha: document.getElementById('lxcEnableHA').checked,
is_shared: isShared,
net0: net0Config,
ipam_allocation_id: allocationId
})
});
const result = await response.json();
if (response.ok && result.success) {
statusEl.style.background = 'var(--success-dark)';
statusEl.innerHTML = `<i class="fa-solid fa-circle-check"></i> ${result.message}`;
showToast('success', '{{ t("success") }}', result.message);
setTimeout(() => {
closeCreateLXCModal();
loadAllVMs();
}, 1500);
} else {
statusEl.style.background = 'var(--danger-dark)';
statusEl.innerHTML = `<i class="fa-solid fa-circle-xmark"></i> ${result.detail || '{{ t("create_failed") }}'}`;
}
} catch (error) {
console.error('Create LXC error:', error);
statusEl.style.background = 'var(--danger-dark)';
statusEl.innerHTML = `<i class="fa-solid fa-circle-xmark"></i> {{ t("error") }}: ${error.message}`;
} finally {
btn.innerHTML = originalText;
btn.disabled = false;
}
}
function closeCreateLXCModal() {
document.getElementById('createLXCModal').style.display = 'none';
}
// Initialize
window.addEventListener('DOMContentLoaded', () => {
// Restore filters from URL first
restoreFiltersFromUrl();
// Wait for permissions
if (window.userPermissions && Object.keys(window.userPermissions).length > 0) {
loadAllVMs();
} else {
window.addEventListener('permissionsLoaded', () => {
loadAllVMs();
}, { once: true });
setTimeout(() => {
if (allVMs.length === 0) {
loadAllVMs();
}
}, 1000);
}
// Start real-time status monitoring
startStatusMonitoring();
});
// Real-time status monitoring
let statusMonitorInterval = null;
function startStatusMonitoring() {
// Poll for status changes every 10 seconds
statusMonitorInterval = setInterval(async () => {
if (allVMs.length === 0) return;
// Group VMs by server for efficient API calls
const serverGroups = {};
allVMs.forEach(vm => {
if (!serverGroups[vm.server_id]) {
serverGroups[vm.server_id] = [];
}
serverGroups[vm.server_id].push(vm);
});
// Check status for each VM
for (const [serverId, vms] of Object.entries(serverGroups)) {
for (const vm of vms) {
try {
const apiType = vm.type === 'lxc' ? 'container' : 'vm';
const response = await fetchWithAuth(`/proxmox/api/${serverId}/${apiType}/${vm.vmid}/status?node=${vm.node}`);
if (response && response.ok) {
const data = await response.json();
const newStatus = data.status || 'stopped';
// Only update if status changed
if (vm.status !== newStatus) {
console.log(`[Monitor] Status changed for ${vm.name}: ${vm.status} -> ${newStatus}`);
vm.status = newStatus;
updateVMStatusUI(parseInt(serverId), vm.vmid, newStatus);
updateVMActionButton(parseInt(serverId), vm.vmid, vm.type, vm.node, newStatus);
}
}
} catch (error) {
// Silently ignore errors during background monitoring
}
}
}
}, 10000); // Every 10 seconds
}
function stopStatusMonitoring() {
if (statusMonitorInterval) {
clearInterval(statusMonitorInterval);
statusMonitorInterval = null;
}
}
// Clean up on page unload
window.addEventListener('beforeunload', () => {
stopStatusMonitoring();
});
</script>
<!-- Create VM Modal -->
<div id="createVMModal" style="display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.85); z-index: 2000; align-items: center; justify-content: center; padding: 1rem;">
<div class="card" style="max-width: 700px; width: 95%; max-height: 95vh; overflow-y: auto;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem; padding-bottom: 1rem; border-bottom: 1px solid var(--border-color);">
<h3 id="createVMModalTitle" style="margin: 0;"><i class="fa-solid fa-plus"></i> {{ t('create_vm') }}</h3>
<button onclick="closeCreateVMModal()" style="background: none; border: none; color: var(--text-muted); font-size: 1.5rem; cursor: pointer;">&times;</button>
</div>
<div id="createVMContent">{{ t('loading') }}...</div>
</div>
</div>
<!-- Create LXC Modal -->
<div id="createLXCModal" style="display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.85); z-index: 2000; align-items: center; justify-content: center; padding: 1rem;">
<div class="card" style="max-width: 700px; width: 95%; max-height: 95vh; overflow-y: auto;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem; padding-bottom: 1rem; border-bottom: 1px solid var(--border-color);">
<h3 style="margin: 0;"><i class="fa-solid fa-cube"></i> {{ t('create_lxc') }}</h3>
<button onclick="closeCreateLXCModal()" style="background: none; border: none; color: var(--text-muted); font-size: 1.5rem; cursor: pointer;">&times;</button>
</div>
<div id="createLXCContent">{{ t('loading') }}...</div>
</div>
</div>
<!-- VNC Modal -->
<div id="vncModal" style="display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.95); z-index: 2000; align-items: center; justify-content: center; padding: 1rem;">
<div class="card" style="max-width: 1400px; width: 95%; max-height: 95vh; display: flex; flex-direction: column;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; padding-bottom: 1rem; border-bottom: 1px solid var(--border-color);">
<h3 id="vncTitle" style="margin: 0;"><i class="fa-solid fa-display"></i> VNC Console</h3>
<button onclick="closeVNCModal()" style="background: none; border: none; color: var(--text-muted); font-size: 1.5rem; cursor: pointer; padding: 0.5rem;">&times;</button>
</div>
<div id="vncContent" style="flex: 1; min-height: 600px; background: #000; border-radius: 4px; overflow: hidden;"></div>
</div>
</div>
<!-- Delete Confirm Modal -->
<div id="deleteConfirmModal" style="display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.85); z-index: 2000; align-items: center; justify-content: center; padding: 1rem;">
<div class="card" style="max-width: 450px; width: 95%;">
<div style="text-align: center; padding: 1.5rem;">
<div style="font-size: 3rem; margin-bottom: 1rem; color: var(--warning);"><i class="fa-solid fa-triangle-exclamation"></i></div>
<h3 style="margin: 0 0 1rem 0; color: var(--text-primary);">{{ t('confirm_delete') }}</h3>
<p style="color: var(--text-secondary); margin-bottom: 1.5rem;">
{{ t('confirm_delete_vm') }} "<span id="deleteConfirmVMName" style="font-weight: 600;"></span>" (ID: <span id="deleteConfirmVMID"></span>)?
</p>
<div style="display: flex; gap: 1rem; justify-content: center;">
<button onclick="closeDeleteConfirmModal()" class="btn btn-secondary" style="min-width: 100px;">{{ t('cancel') }}</button>
<button onclick="executeDelete()" class="btn btn-danger" style="min-width: 100px;">{{ t('delete') }}</button>
</div>
</div>
</div>
</div>
<!-- Settings Modal -->
<div id="settingsModal" style="display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.85); z-index: 2000; align-items: center; justify-content: center; padding: 1rem;">
<div class="card" style="max-width: 700px; width: 95%; max-height: 95vh; overflow-y: auto;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem; padding-bottom: 1rem; border-bottom: 1px solid var(--border-color);">
<h3 style="margin: 0;"><i class="fa-solid fa-gear"></i> {{ t('settings') }}</h3>
<button onclick="closeSettingsModal()" style="background: none; border: none; color: var(--text-muted); font-size: 1.5rem; cursor: pointer;">&times;</button>
</div>
<div id="settingsContent">{{ t('loading') }}</div>
</div>
</div>
<!-- Reinstall Modal -->
<div id="reinstallModal" style="display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.85); z-index: 2000; align-items: center; justify-content: center; padding: 1rem;">
<div class="card" style="max-width: 600px; width: 95%; max-height: 95vh; overflow-y: auto;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem; padding-bottom: 1rem; border-bottom: 1px solid var(--border-color);">
<h3 style="margin: 0;"><i class="fa-solid fa-compact-disc"></i> {{ t('reinstall') }}</h3>
<button onclick="closeReinstallModal()" style="background: none; border: none; color: var(--text-muted); font-size: 1.5rem; cursor: pointer;">&times;</button>
</div>
<div id="reinstallContent">{{ t('loading') }}</div>
</div>
</div>
<!-- noVNC scripts -->
<script type="module">
import RFB from '/static/novnc/core/rfb.js';
window.RFB = RFB;
window.noVNCLoaded = true;
</script>
<script>
// Wait for noVNC to load
window.waitForRFB = function() {
return new Promise((resolve) => {
if (window.RFB) {
resolve(window.RFB);
return;
}
let attempts = 0;
const maxAttempts = 50;
const interval = setInterval(() => {
attempts++;
if (window.RFB) {
clearInterval(interval);
resolve(window.RFB);
} else if (attempts >= maxAttempts) {
clearInterval(interval);
resolve(null);
}
}, 100);
});
};
// Settings Modal
async function showSettingsModal(serverId, vmid, type, node) {
document.getElementById('settingsModal').style.display = 'flex';
const content = document.getElementById('settingsContent');
content.innerHTML = '<div style="text-align: center; padding: 2rem; color: var(--text-muted);"><i class="fa-solid fa-spinner fa-spin"></i> {{ t("loading") }}...</div>';
try {
const apiType = type === 'lxc' ? 'container' : 'vm';
const configRes = await fetchWithAuth(`/proxmox/api/${serverId}/${apiType}/${vmid}/config?node=${node}`);
if (!configRes.ok) {
throw new Error('{{ t("error_loading_config") }}');
}
const config = await configRes.json();
// For LXC: hostname instead of name, memory in MB
// For QEMU: name, memory in bytes (convert to MB)
let displayName, displayCores, displayMemory;
if (type === 'lxc') {
displayName = config.hostname || '';
displayCores = config.cores || 1;
displayMemory = config.memory || 512;
} else {
displayName = config.name || '';
displayCores = config.cores || 1;
// QEMU memory is in bytes, convert to MB
displayMemory = config.memory ? Math.round(config.memory) : 512;
}
const onboot = config.onboot || 0;
content.innerHTML = `
<form onsubmit="saveVMConfig(event)">
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 1rem; margin-bottom: 1.5rem;">
<div>
<label style="display: block; color: var(--text-secondary); font-size: 0.875rem; margin-bottom: 0.25rem;">{{ t('name') }}</label>
<input type="text" id="edit-name" value="${displayName}" style="width: 100%; padding: 0.5rem; border-radius: 4px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary);">
</div>
<div>
<label style="display: block; color: var(--text-secondary); font-size: 0.875rem; margin-bottom: 0.25rem;">{{ t('cpu_cores') }}</label>
<input type="number" id="edit-cores" value="${displayCores}" min="1" max="128" style="width: 100%; padding: 0.5rem; border-radius: 4px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary);">
</div>
<div>
<label style="display: block; color: var(--text-secondary); font-size: 0.875rem; margin-bottom: 0.25rem;">{{ t('memory_mb') }}</label>
<input type="number" id="edit-memory" value="${displayMemory}" min="128" step="128" style="width: 100%; padding: 0.5rem; border-radius: 4px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary);">
</div>
<div style="display: flex; align-items: center;">
<label style="display: flex; align-items: center; gap: 0.5rem; color: var(--text-secondary); font-size: 0.875rem; cursor: pointer;">
<input type="checkbox" id="edit-onboot" ${onboot ? 'checked' : ''}>
{{ t('autostart_on_boot') }}
</label>
</div>
</div>
<div style="display: flex; gap: 1rem; justify-content: flex-end;">
<button type="button" onclick="closeSettingsModal()" class="btn btn-secondary">{{ t('cancel') }}</button>
<button type="submit" class="btn btn-success"><i class="fa-solid fa-floppy-disk"></i> {{ t('save') }}</button>
</div>
</form>
`;
} catch (error) {
content.innerHTML = `<div style="text-align: center; padding: 2rem; color: var(--danger);"><i class="fa-solid fa-circle-xmark"></i> ${error.message}</div>`;
}
}
function closeSettingsModal() {
document.getElementById('settingsModal').style.display = 'none';
}
async function saveVMConfig(event) {
event.preventDefault();
const ctx = window.currentVMContext;
if (!ctx) return;
const nameValue = document.getElementById('edit-name').value.trim();
const cores = parseInt(document.getElementById('edit-cores').value);
const memory = parseInt(document.getElementById('edit-memory').value);
const onboot = document.getElementById('edit-onboot').checked ? 1 : 0;
closeSettingsModal();
showToast('info', '{{ t("info") }}', '{{ t("saving") }}...');
try {
const apiType = ctx.type === 'lxc' ? 'container' : 'vm';
// For LXC use 'hostname', for QEMU use 'name'
const configData = { cores, memory, onboot };
if (ctx.type === 'lxc') {
configData.hostname = nameValue;
} else {
configData.name = nameValue;
}
const response = await fetchWithAuth(`/proxmox/api/${ctx.serverId}/${apiType}/${ctx.vmid}/config?node=${ctx.node}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(configData)
});
if (response.ok) {
showToast('success', '{{ t("success") }}', '{{ t("settings_saved") }}');
setTimeout(() => loadAllVMs(), 1000);
} else {
const data = await response.json();
showToast('error', '{{ t("error") }}', data.detail || '{{ t("save_failed") }}');
}
} catch (error) {
console.error('Save config error:', error);
showToast('error', '{{ t("error") }}', '{{ t("save_failed") }}');
}
}
// Reinstall Modal
async function showReinstallModal(serverId, vmid, type, node, vmName) {
document.getElementById('reinstallModal').style.display = 'flex';
const content = document.getElementById('reinstallContent');
content.innerHTML = '<div style="text-align: center; padding: 2rem; color: var(--text-muted);"><i class="fa-solid fa-spinner fa-spin"></i> {{ t("loading") }}...</div>';
try {
const [groupsRes, templatesRes] = await Promise.all([
fetchWithAuth('/templates/api/groups'),
fetchWithAuth(`/templates/api/templates?active_only=true`)
]);
const groups = groupsRes.ok ? await groupsRes.json() : [];
const templates = templatesRes.ok ? await templatesRes.json() : [];
if (templates.length === 0) {
content.innerHTML = `
<div style="text-align: center; padding: 2rem;">
<div style="font-size: 2rem; margin-bottom: 1rem; color: var(--text-muted);"><i class="fa-solid fa-box-open"></i></div>
<p style="color: var(--text-secondary);">{{ t('no_templates') }}</p>
<a href="/templates" class="btn" style="margin-top: 1rem;">{{ t('add_templates') }}</a>
</div>
`;
return;
}
// Group templates
const grouped = {};
templates.forEach(tpl => {
const gid = tpl.group_id || 'other';
if (!grouped[gid]) grouped[gid] = [];
grouped[gid].push(tpl);
});
let options = '<option value="">{{ t("select_template") }}</option>';
groups.forEach(g => {
if (grouped[g.id]) {
options += '<optgroup label="' + (g.icon || '') + ' ' + g.name + '">';
grouped[g.id].forEach(tpl => {
options += '<option value="' + tpl.id + '" data-vmid="' + tpl.vmid + '" data-node="' + tpl.node + '">' + tpl.name + '</option>';
});
options += '</optgroup>';
}
});
content.innerHTML = `
<div style="margin-bottom: 1rem; padding: 1rem; background: rgba(255, 170, 0, 0.1); border-radius: 8px; border-left: 4px solid var(--warning);">
<p style="color: var(--warning); margin: 0;"><i class="fa-solid fa-triangle-exclamation"></i> {{ t('reinstall_warning') }}</p>
</div>
<form onsubmit="executeReinstall(event)">
<div style="margin-bottom: 1rem;">
<label style="display: block; color: var(--text-secondary); margin-bottom: 0.5rem;">{{ t('os_template') }}:</label>
<select id="reinstall-template" required class="form-select">
${options}
</select>
</div>
<div style="margin-bottom: 1rem; padding: 1rem; background: var(--bg-tertiary); border-radius: 8px;">
<h4 style="margin: 0 0 0.75rem 0; color: var(--text-primary); font-size: 0.9rem;"><i class="fa-solid fa-lock"></i> {{ t('cloud_init_settings') }}</h4>
<div style="margin-bottom: 0.75rem;">
<label style="display: block; color: var(--text-secondary); margin-bottom: 0.25rem; font-size: 0.875rem;">{{ t('username') }}:</label>
<input type="text" id="reinstall-user" placeholder="root" class="form-input">
</div>
<div style="margin-bottom: 0.75rem;">
<label style="display: block; color: var(--text-secondary); margin-bottom: 0.25rem; font-size: 0.875rem;">{{ t('password') }}:</label>
<input type="password" id="reinstall-password" placeholder="{{ t('leave_empty_for_ssh') }}" class="form-input">
</div>
<div>
<label style="display: block; color: var(--text-secondary); margin-bottom: 0.25rem; font-size: 0.875rem;">{{ t('ssh_public_key') }}:</label>
<textarea id="reinstall-sshkey" placeholder="ssh-rsa AAAAB3..." rows="2" class="form-input" style="font-family: monospace; font-size: 0.75rem;"></textarea>
</div>
</div>
<div style="margin-bottom: 1.5rem;">
<label style="display: flex; align-items: center; gap: 0.5rem; color: var(--text-secondary); cursor: pointer;">
<input type="checkbox" id="reinstall-confirm" required>
{{ t('confirm_data_loss') }}
</label>
</div>
<div style="display: flex; gap: 1rem; justify-content: flex-end;">
<button type="button" onclick="closeReinstallModal()" class="btn btn-secondary">{{ t('cancel') }}</button>
<button type="submit" class="btn btn-warning"><i class="fa-solid fa-compact-disc"></i> {{ t('do_reinstall') }}</button>
</div>
</form>
`;
} catch (error) {
content.innerHTML = '<div style="text-align: center; padding: 2rem; color: var(--danger);"><i class="fa-solid fa-circle-xmark"></i> ' + error.message + '</div>';
}
}
function closeReinstallModal() {
document.getElementById('reinstallModal').style.display = 'none';
}
async function executeReinstall(event) {
event.preventDefault();
const ctx = window.currentVMContext;
if (!ctx) return;
const templateId = document.getElementById('reinstall-template').value;
if (!templateId) {
showToast('warning', '{{ t("warning") }}', '{{ t("select_template") }}');
return;
}
const cloudInitUser = document.getElementById('reinstall-user').value.trim();
const cloudInitPassword = document.getElementById('reinstall-password').value;
const sshKeys = document.getElementById('reinstall-sshkey').value.trim();
closeReinstallModal();
showToast('info', '{{ t("info") }}', '{{ t("reinstalling") }}...');
try {
const response = await fetchWithAuth('/templates/api/deploy', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
template_id: parseInt(templateId),
name: ctx.vmName,
vmid: ctx.vmid,
target_node: ctx.node,
cloud_init_user: cloudInitUser || undefined,
cloud_init_password: cloudInitPassword || undefined,
ssh_keys: sshKeys || undefined,
start_after_create: true
})
});
if (response.ok) {
const result = await response.json();
showToast('success', '{{ t("success") }}', '{{ t("reinstall_complete") }}');
setTimeout(() => loadAllVMs(), 2000);
} else {
const data = await response.json();
showToast('error', '{{ t("error") }}', data.detail || '{{ t("reinstall_failed") }}');
}
} catch (error) {
console.error('Reinstall error:', error);
showToast('error', '{{ t("error") }}', '{{ t("reinstall_failed") }}');
}
}
</script>
<style>
.create-vm-form {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.create-vm-form .form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.create-vm-form label {
color: var(--text-secondary);
font-weight: 500;
font-size: 0.9rem;
}
.create-vm-form .form-select,
.create-vm-form .form-input {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 0.75rem 1rem;
color: var(--text-primary);
font-size: 0.95rem;
width: 100%;
}
.create-vm-form .form-select:focus,
.create-vm-form .form-input:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 2px rgba(var(--primary-rgb), 0.2);
}
.create-vm-form .form-input::placeholder {
color: var(--text-muted);
}
/* Snapshots styles */
.snapshot-card {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 1rem;
margin-bottom: 0.75rem;
transition: border-color 0.2s, box-shadow 0.2s;
}
.snapshot-card:hover {
border-color: var(--primary);
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.snapshot-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
flex-wrap: wrap;
gap: 0.5rem;
}
.snapshot-name {
font-weight: 600;
color: var(--text-primary);
display: flex;
align-items: center;
gap: 0.5rem;
}
.snapshot-name i {
color: var(--primary);
}
.snapshot-title {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.badge-info {
background: var(--info);
color: var(--text-on-info);
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.snapshot-actions {
display: flex;
gap: 0.5rem;
flex-shrink: 0;
}
.snapshot-meta {
display: flex;
flex-wrap: wrap;
gap: 1rem;
color: var(--text-secondary);
font-size: 0.875rem;
}
.snapshot-meta span {
display: flex;
align-items: center;
gap: 0.35rem;
}
.snapshot-description {
color: var(--text-muted);
font-size: 0.875rem;
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid var(--border-color);
}
/* Snapshots container - grid layout for larger screens */
#snapshotsContainer {
display: grid;
grid-template-columns: 1fr;
gap: 0.75rem;
padding: 0.5rem;
}
@media (min-width: 992px) {
#snapshotsContainer {
grid-template-columns: repeat(2, 1fr);
}
}
@media (min-width: 1400px) {
#snapshotsContainer {
grid-template-columns: repeat(3, 1fr);
}
}
/* Snapshots toolbar responsive */
#snapshotsTabContent .vm-toolbar {
flex-wrap: wrap;
gap: 0.75rem;
}
#snapshotsTabContent .vm-toolbar-left {
flex-wrap: wrap;
gap: 0.5rem;
flex: 1;
min-width: 280px;
}
#snapshotsTabContent .vm-toolbar-left select,
#snapshotsTabContent .vm-toolbar-left input {
min-width: 140px;
flex: 1;
}
@media (max-width: 768px) {
#snapshotsTabContent .vm-toolbar {
flex-direction: column;
}
#snapshotsTabContent .vm-toolbar-left,
#snapshotsTabContent .vm-toolbar-right {
width: 100%;
}
#snapshotsTabContent .vm-toolbar-right {
justify-content: flex-end;
}
.snapshot-header {
flex-direction: column;
align-items: flex-start;
}
.snapshot-actions {
width: 100%;
justify-content: flex-end;
margin-top: 0.5rem;
}
/* Hide button text on small screens */
#createSnapshotBtn .btn-text {
display: none;
}
}
/* Empty state styling */
.snapshots-empty-state {
grid-column: 1 / -1;
text-align: center;
padding: 3rem;
color: var(--text-muted);
}
.snapshots-empty-state i {
font-size: 3rem;
margin-bottom: 1rem;
opacity: 0.3;
display: block;
}
/* Icon button style */
.btn-icon {
padding: 0.5rem 0.75rem;
}
/* Snapshot queue indicator */
.snapshot-queue-indicator {
display: none;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: var(--bg-secondary);
border: 1px solid var(--primary);
border-radius: 6px;
color: var(--primary);
font-size: 0.875rem;
animation: pulse 2s infinite;
}
.snapshot-queue-indicator i {
color: var(--primary);
}
.snapshot-queue-indicator .badge {
background: var(--primary);
color: var(--text-on-primary);
padding: 0.15rem 0.5rem;
border-radius: 10px;
font-size: 0.75rem;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
</style>
<script>
// ==================== Snapshots Functions ====================
let snapshotsServers = [];
let snapshotsVMs = [];
let currentSnapshots = [];
let selectedSnapshotVM = null;
// ==================== Operation Queue System ====================
const snapshotOperationQueue = {
queue: [], // Array of pending operations
isProcessing: false, // Is queue being processed
currentOp: null, // Current operation being executed
pollInterval: null, // Interval for polling task status
// Add operation to queue
add(operation) {
this.queue.push({
...operation,
id: Date.now() + Math.random(),
status: 'pending',
addedAt: new Date()
});
this.updateUI();
this.process();
},
// Process queue
async process() {
if (this.isProcessing || this.queue.length === 0) return;
this.isProcessing = true;
while (this.queue.length > 0) {
const op = this.queue[0];
op.status = 'running';
this.currentOp = op;
this.updateUI();
try {
const result = await this.executeOperation(op);
op.status = result.success ? 'completed' : 'failed';
op.result = result;
if (result.success) {
showToast('success', t('success'), result.message);
} else {
showToast('error', t('error'), result.message);
}
} catch (e) {
op.status = 'failed';
op.result = { success: false, message: e.message };
showToast('error', t('error'), e.message);
}
// Remove completed operation from queue
this.queue.shift();
this.currentOp = null;
this.updateUI();
// Small delay between operations
await new Promise(r => setTimeout(r, 500));
}
this.isProcessing = false;
refreshSnapshots();
},
// Execute single operation
async executeOperation(op) {
const { serverId, vmid, node, type } = op.vm;
let endpoint, method, body = null;
switch (op.type) {
case 'delete':
endpoint = type === 'lxc'
? `/proxmox/api/${serverId}/container/${vmid}/snapshots/${op.snapname}?node=${node}`
: `/proxmox/api/${serverId}/vm/${vmid}/snapshots/${op.snapname}?node=${node}`;
method = 'DELETE';
break;
case 'rollback':
endpoint = type === 'lxc'
? `/proxmox/api/${serverId}/container/${vmid}/snapshots/${encodeURIComponent(op.snapname)}/rollback?node=${encodeURIComponent(node)}`
: `/proxmox/api/${serverId}/vm/${vmid}/snapshots/${encodeURIComponent(op.snapname)}/rollback?node=${encodeURIComponent(node)}`;
method = 'POST';
break;
case 'create':
endpoint = type === 'lxc'
? `/proxmox/api/${serverId}/container/${vmid}/snapshots?node=${encodeURIComponent(node)}`
: `/proxmox/api/${serverId}/vm/${vmid}/snapshots?node=${encodeURIComponent(node)}`;
method = 'POST';
body = JSON.stringify({
snapname: op.snapname,
description: op.description || '',
vmstate: op.vmstate || false
});
break;
default:
throw new Error('Unknown operation type');
}
const response = await fetchWithAuth(endpoint, {
method,
headers: body ? { 'Content-Type': 'application/json' } : {},
body
});
if (response.ok) {
const data = await response.json();
// If Proxmox returned UPID, wait for task completion
if (data.upid) {
return await this.waitForTask(serverId, node, data.upid, op);
}
return {
success: true,
message: this.getSuccessMessage(op.type, op.snapname)
};
} else {
const error = await response.json();
return {
success: false,
message: error.detail || this.getFailMessage(op.type, op.snapname)
};
}
},
// Wait for Proxmox task to complete
async waitForTask(serverId, node, upid, op) {
const maxAttempts = 60; // 60 attempts * 2 seconds = 2 minutes max
let attempts = 0;
while (attempts < maxAttempts) {
try {
const response = await fetchWithAuth(
`/proxmox/api/${serverId}/task/${encodeURIComponent(upid)}/status?node=${node}`
);
if (response.ok) {
const status = await response.json();
if (status.status === 'stopped') {
// Task completed
if (status.exitstatus === 'OK') {
return {
success: true,
message: this.getSuccessMessage(op.type, op.snapname)
};
} else {
return {
success: false,
message: `${this.getFailMessage(op.type, op.snapname)}: ${status.exitstatus}`
};
}
}
// Task still running, update progress
op.taskStatus = status;
this.updateUI();
}
} catch (e) {
console.error('Error checking task status:', e);
}
await new Promise(r => setTimeout(r, 2000));
attempts++;
}
return {
success: false,
message: t('task_timeout') || 'Operation timed out'
};
},
getSuccessMessage(type, snapname) {
switch (type) {
case 'delete': return t('snapshot_deleted') || 'Snapshot deleted';
case 'rollback': return t('snapshot_rollback_started') || 'Rollback completed';
case 'create': return t('snapshot_created') || 'Snapshot created';
default: return 'Operation completed';
}
},
getFailMessage(type, snapname) {
switch (type) {
case 'delete': return t('snapshot_delete_failed') || 'Failed to delete snapshot';
case 'rollback': return t('snapshot_rollback_failed') || 'Failed to rollback';
case 'create': return t('snapshot_create_failed') || 'Failed to create snapshot';
default: return 'Operation failed';
}
},
// Update UI to show queue status
updateUI() {
let queueIndicator = document.getElementById('snapshotQueueIndicator');
if (!queueIndicator) {
// Create queue indicator
queueIndicator = document.createElement('div');
queueIndicator.id = 'snapshotQueueIndicator';
queueIndicator.className = 'snapshot-queue-indicator';
const toolbar = document.querySelector('#snapshotsTabContent .vm-toolbar-right');
if (toolbar) {
toolbar.insertBefore(queueIndicator, toolbar.firstChild);
}
}
if (this.queue.length === 0 && !this.currentOp) {
queueIndicator.style.display = 'none';
// Enable buttons
document.querySelectorAll('.snapshot-actions button').forEach(btn => btn.disabled = false);
if (selectedSnapshotVM) {
document.getElementById('createSnapshotBtn').disabled = false;
}
} else {
queueIndicator.style.display = 'flex';
const currentLabel = this.currentOp
? `${this.getOpLabel(this.currentOp.type)}: ${this.currentOp.snapname}`
: '';
const queueCount = this.queue.length;
queueIndicator.innerHTML = `
<i class="fa-solid fa-spinner fa-spin"></i>
<span>${currentLabel}</span>
${queueCount > 1 ? `<span class="badge">${queueCount} ${t('in_queue') || 'in queue'}</span>` : ''}
`;
// Disable buttons while processing
document.querySelectorAll('.snapshot-actions button').forEach(btn => btn.disabled = true);
document.getElementById('createSnapshotBtn').disabled = true;
}
},
getOpLabel(type) {
switch (type) {
case 'delete': return t('deleting') || 'Deleting';
case 'rollback': return t('rolling_back') || 'Rolling back';
case 'create': return t('creating') || 'Creating';
default: return 'Processing';
}
}
};
async function initSnapshotsTab() {
const serverSelect = document.getElementById('snapshotServerFilter');
// Load servers if not loaded
if (snapshotsServers.length === 0) {
try {
const response = await fetchWithAuth('/proxmox/api/servers');
if (response.ok) {
snapshotsServers = await response.json();
serverSelect.innerHTML = '<option value="">{{ t("select_server") }}</option>';
snapshotsServers.forEach(s => {
serverSelect.innerHTML += `<option value="${s.id}">${s.name}</option>`;
});
}
} catch (e) {
console.error('Error loading servers:', e);
}
}
}
async function loadSnapshotsForServer(serverId) {
const vmSelect = document.getElementById('snapshotVMFilter');
const createBtn = document.getElementById('createSnapshotBtn');
if (!serverId) {
vmSelect.innerHTML = '<option value="">{{ t("select_vm") }}</option>';
vmSelect.disabled = true;
createBtn.disabled = true;
return;
}
vmSelect.innerHTML = '<option value="">{{ t("loading") }}...</option>';
vmSelect.disabled = true;
try {
const response = await fetchWithAuth(`/proxmox/api/${serverId}/resources`);
if (response.ok) {
const data = await response.json();
// Combine VMs and containers into one array
const vms = (data.vms || []).map(vm => ({ ...vm, type: 'qemu' }));
const containers = (data.containers || []).map(ct => ({ ...ct, type: 'lxc' }));
snapshotsVMs = [...vms, ...containers];
vmSelect.innerHTML = '<option value="">{{ t("select_vm") }}</option>';
snapshotsVMs.forEach(vm => {
const typeIcon = vm.type === 'qemu' ? 'fa-desktop' : 'fa-cube';
const statusClass = vm.status === 'running' ? 'text-success' : 'text-muted';
const statusDot = vm.status === 'running' ? '●' : '○';
vmSelect.innerHTML += `<option value="${vm.vmid}" data-node="${vm.node}" data-type="${vm.type}">${statusDot} ${vm.name || vm.vmid} (${vm.vmid})</option>`;
});
vmSelect.disabled = false;
} else {
console.error('Error response:', await response.text());
vmSelect.innerHTML = '<option value="">{{ t("error") }}</option>';
}
} catch (e) {
console.error('Error loading VMs:', e);
vmSelect.innerHTML = '<option value="">{{ t("error") }}</option>';
}
}
async function loadSnapshotsForVM(vmid) {
const container = document.getElementById('snapshotsContainer');
const createBtn = document.getElementById('createSnapshotBtn');
const vmSelect = document.getElementById('snapshotVMFilter');
const serverId = document.getElementById('snapshotServerFilter').value;
if (!vmid || !serverId) {
createBtn.disabled = true;
container.innerHTML = `
<div class="snapshots-empty-state">
<i class="fa-solid fa-camera"></i>
<div>{{ t('select_vm_for_snapshots') }}</div>
</div>
`;
return;
}
const selectedOption = vmSelect.options[vmSelect.selectedIndex];
const node = selectedOption.dataset.node;
const type = selectedOption.dataset.type;
selectedSnapshotVM = { serverId, vmid, node, type };
createBtn.disabled = false;
container.innerHTML = `
<div class="snapshots-empty-state">
<i class="fa-solid fa-spinner fa-spin" style="opacity: 1;"></i>
<div>{{ t('loading') }}...</div>
</div>
`;
try {
const endpoint = type === 'lxc'
? `/proxmox/api/${serverId}/container/${vmid}/snapshots?node=${node}`
: `/proxmox/api/${serverId}/vm/${vmid}/snapshots?node=${node}`;
const response = await fetchWithAuth(endpoint);
if (response.ok) {
const data = await response.json();
currentSnapshots = data.snapshots || [];
renderSnapshots();
} else {
container.innerHTML = `
<div class="snapshots-empty-state" style="color: var(--danger);">
<i class="fa-solid fa-exclamation-triangle" style="opacity: 1;"></i>
<div>{{ t('error_loading_snapshots') }}</div>
</div>
</div>
`;
}
} catch (e) {
console.error('Error loading snapshots:', e);
container.innerHTML = `
<div style="text-align: center; padding: 2rem; color: var(--danger);">
<i class="fa-solid fa-exclamation-triangle"></i> ${e.message}
</div>
`;
}
}
function renderSnapshots() {
const container = document.getElementById('snapshotsContainer');
if (currentSnapshots.length === 0) {
container.innerHTML = `
<div class="snapshots-empty-state">
<i class="fa-solid fa-camera"></i>
<div>{{ t('no_snapshots') }}</div>
<button class="btn btn-primary" onclick="showCreateSnapshotModal()" style="margin-top: 1rem;">
<i class="fa-solid fa-plus"></i> {{ t('create_first_snapshot') }}
</button>
</div>
`;
return;
}
let html = '';
currentSnapshots.forEach(snap => {
const date = snap.snaptime ? new Date(snap.snaptime * 1000).toLocaleString() : '-';
const hasRam = snap.vmstate ? '<span class="badge badge-info"><i class="fa-solid fa-memory"></i> RAM</span>' : '';
html += `
<div class="snapshot-card">
<div class="snapshot-header">
<div class="snapshot-title">
<span class="snapshot-name"><i class="fa-solid fa-camera"></i> ${snap.name}</span>
${hasRam}
</div>
<div class="snapshot-actions">
<button class="btn btn-sm btn-secondary" onclick="rollbackSnapshot('${snap.name}')" title="{{ t('rollback') }}">
<i class="fa-solid fa-rotate-left"></i>
</button>
<button class="btn btn-sm btn-danger" onclick="deleteSnapshot('${snap.name}')" title="{{ t('delete') }}">
<i class="fa-solid fa-trash"></i>
</button>
</div>
</div>
<div class="snapshot-meta">
<span><i class="fa-regular fa-calendar"></i> ${date}</span>
${snap.parent ? `<span><i class="fa-solid fa-code-branch"></i> {{ t('parent') }}: ${snap.parent}</span>` : ''}
</div>
${snap.description ? `<div class="snapshot-description"><i class="fa-solid fa-align-left"></i> ${snap.description}</div>` : ''}
</div>
`;
});
container.innerHTML = html;
}
function filterSnapshots(query) {
const cards = document.querySelectorAll('.snapshot-card');
const q = query.toLowerCase();
cards.forEach(card => {
const name = card.querySelector('.snapshot-name').textContent.toLowerCase();
card.style.display = name.includes(q) ? '' : 'none';
});
}
function refreshSnapshots() {
if (selectedSnapshotVM) {
loadSnapshotsForVM(selectedSnapshotVM.vmid);
}
}
function showCreateSnapshotModal() {
if (!selectedSnapshotVM) {
showToast('warning', t('warning'), t('select_vm_first'));
return;
}
const modal = document.createElement('div');
modal.id = 'createSnapshotModal';
modal.style.cssText = 'position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.85); z-index: 2000; display: flex; align-items: center; justify-content: center;';
modal.innerHTML = `
<div class="card" style="max-width: 500px; width: 95%;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
<h3 style="margin: 0;"><i class="fa-solid fa-camera"></i> {{ t('create_snapshot') }}</h3>
<button onclick="this.closest('#createSnapshotModal').remove()" style="background: none; border: none; color: var(--text-muted); font-size: 1.5rem; cursor: pointer;">&times;</button>
</div>
<form onsubmit="createSnapshot(event)">
<div class="form-group" style="margin-bottom: 1rem;">
<label style="display: block; margin-bottom: 0.5rem; color: var(--text-secondary);">{{ t('snapshot_name') }} *</label>
<input type="text" id="snapshotName" required pattern="[a-zA-Z0-9_\-]+"
placeholder="snapshot-1"
style="width: 100%; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary);">
<small style="color: var(--text-muted);">{{ t('snapshot_name_hint') }}</small>
</div>
<div class="form-group" style="margin-bottom: 1rem;">
<label style="display: block; margin-bottom: 0.5rem; color: var(--text-secondary);">{{ t('description') }}</label>
<textarea id="snapshotDescription" rows="2"
style="width: 100%; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary); resize: vertical;"></textarea>
</div>
${selectedSnapshotVM.type === 'qemu' ? `
<div class="form-group" style="margin-bottom: 1.5rem;">
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
<input type="checkbox" id="snapshotIncludeRAM">
<span>{{ t('include_ram_state') }}</span>
</label>
<small style="color: var(--text-muted);">{{ t('include_ram_hint') }}</small>
</div>
` : ''}
<div style="display: flex; gap: 1rem; justify-content: flex-end;">
<button type="button" class="btn btn-secondary" onclick="this.closest('#createSnapshotModal').remove()">{{ t('cancel') }}</button>
<button type="submit" class="btn btn-primary"><i class="fa-solid fa-camera"></i> {{ t('create') }}</button>
</div>
</form>
</div>
`;
document.body.appendChild(modal);
document.getElementById('snapshotName').focus();
}
async function createSnapshot(event) {
event.preventDefault();
const name = document.getElementById('snapshotName').value.trim();
const description = document.getElementById('snapshotDescription').value.trim();
const includeRAM = document.getElementById('snapshotIncludeRAM')?.checked || false;
if (!name) return;
const modal = document.getElementById('createSnapshotModal');
modal.remove();
// Add to operation queue
snapshotOperationQueue.add({
type: 'create',
snapname: name,
description: description,
vmstate: includeRAM,
vm: { ...selectedSnapshotVM }
});
}
async function deleteSnapshot(snapname) {
const confirmed = await showDeleteConfirm(
t('delete_snapshot'),
t('delete_snapshot_confirm').replace('{name}', snapname)
);
if (!confirmed) return;
// Add to operation queue
snapshotOperationQueue.add({
type: 'delete',
snapname: snapname,
vm: { ...selectedSnapshotVM }
});
}
async function rollbackSnapshot(snapname) {
const confirmed = await showDeleteConfirm(
t('rollback_snapshot'),
t('rollback_snapshot_confirm').replace('{name}', snapname)
);
if (!confirmed) return;
// Add to operation queue
snapshotOperationQueue.add({
type: 'rollback',
snapname: snapname,
vm: { ...selectedSnapshotVM }
});
}
</script>
{% endblock %}