v1.3.1: VM list node/server filtering + responsive toolbar redesign

- Add server filter dropdown (populated dynamically from loaded VMs)
- Add node filter dropdown (narrows to nodes of selected server)
- Both filters persisted in URL params and restored on reload
- Toolbar restructured into 2 rows: filters row + actions row
- Responsive breakpoints: ≤900px text labels hidden, ≤768px 2-per-row,
  ≤480px full-width filters, stats hidden on mobile
- Snapshots toolbar migrated to inline flex (no legacy class dependency)
- Update VERSION, CHANGELOG, README, WIKI to v1.3.1
This commit is contained in:
markmorado
2026-04-05 21:47:16 +02:00
parent abeb8d53a3
commit 6a0564cea2
5 changed files with 260 additions and 109 deletions

View File

@@ -4,6 +4,20 @@ All notable changes to PVEmanager will be documented in this file.
---
## [v1.3.1] - 2026-04-05
### 🔍 VM List — Node Filtering & Responsive Toolbar
- **Server filter** — new dropdown to filter the VM list by Proxmox server/cluster; populated dynamically from loaded VMs
- **Node filter** — new dropdown to filter by Proxmox node; automatically narrows to nodes belonging to the selected server
- Both filters are saved in URL params (`?server=...&node=...`) and restored on page reload
- **Toolbar redesigned into 2 rows** — row 1: all filters (type / status / server / node / search with icon + stats); row 2: select-all checkbox, pagination, action buttons
- **Responsive breakpoints** — at ≤900px button labels hide; at ≤768px filters wrap 2-per-row; at ≤480px each filter is full-width; stats counter hidden on mobile
- `populateServerFilter()` and `populateNodeFilter()` helper functions populate dropdowns from loaded VM data
- Snapshots toolbar migrated from legacy `.vm-toolbar-left/.vm-toolbar-right` classes to inline flex, unaffected by toolbar refactor
---
## [v1.3.0] - 2026-03-27
### 🖥️ Console — VNC & Terminal Fixes

View File

@@ -4,7 +4,7 @@
**Modern web panel for managing Proxmox servers, virtual machines and LXC containers**
[![Version](https://img.shields.io/badge/version-1.3.0-blue?style=flat-square)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/version-1.3.1-blue?style=flat-square)](CHANGELOG.md)
[![License](https://img.shields.io/badge/license-MIT-green?style=flat-square)](LICENSE)
[![Docker](https://img.shields.io/badge/docker-ready-2496ED?style=flat-square&logo=docker&logoColor=white)](compose.yml)
[![Python](https://img.shields.io/badge/python-3.12-3776AB?style=flat-square&logo=python&logoColor=white)](backend/)

View File

@@ -1 +1 @@
1.3.0
1.3.1

View File

@@ -1,6 +1,6 @@
# 📖 PVEmanager - Documentation
> Complete guide for installation, configuration and usage of PVEmanager v1.3.0
> Complete guide for installation, configuration and usage of PVEmanager v1.3.1
---

View File

@@ -41,26 +41,46 @@
.vm-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.5rem;
flex-direction: column;
padding: 0.75rem 1.25rem;
border-bottom: 1px solid var(--border-color);
gap: 0.625rem;
}
/* Строка 1: фильтры */
.vm-filter-row {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
gap: 1rem;
}
.vm-toolbar-left {
/* Строка 2: чекбокс + пагинация + кнопки */
.vm-actions-row {
display: flex;
align-items: center;
gap: 1rem;
justify-content: space-between;
gap: 0.5rem;
flex-wrap: wrap;
min-height: 34px;
}
.vm-toolbar-right {
.vm-actions-left {
display: flex;
align-items: center;
gap: 1rem;
gap: 0.75rem;
}
.vm-actions-right {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
/* Оставляем для обратной совместимости (используется в switchVMTab) */
.vm-toolbar-left, .vm-toolbar-right { display: contents; }
.vm-filter-btn {
display: flex;
align-items: center;
@@ -394,11 +414,14 @@
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 0.5rem 0.75rem;
padding: 0.4rem 0.625rem;
color: var(--text-primary);
font-size: 0.875rem;
font-size: 0.8125rem;
cursor: pointer;
min-width: 120px;
flex: 1 1 110px;
min-width: 100px;
max-width: 180px;
height: 34px;
}
.vm-filter-select:hover {
@@ -415,10 +438,12 @@
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 0.5rem 0.75rem;
padding: 0.4rem 0.75rem;
color: var(--text-primary);
font-size: 0.875rem;
min-width: 200px;
font-size: 0.8125rem;
flex: 2 1 160px;
min-width: 140px;
height: 34px;
}
.vm-search-input:focus {
@@ -593,6 +618,21 @@
color: var(--danger);
}
/* Адаптивность: планшеты */
@media (max-width: 900px) {
.vm-toolbar {
padding: 0.75rem 1rem;
}
.vm-filter-select {
max-width: 150px;
}
.btn-text {
display: none;
}
}
/* Мобильная адаптация */
@media (max-width: 768px) {
.vm-tabs {
@@ -607,29 +647,44 @@
}
.vm-toolbar {
flex-direction: column;
align-items: stretch;
padding: 0.75rem;
padding: 0.625rem 0.75rem;
}
.vm-toolbar-left,
.vm-toolbar-right {
width: 100%;
justify-content: space-between;
.vm-filter-row {
gap: 0.375rem;
}
.vm-toolbar-right {
.vm-filter-select {
flex: 1 1 calc(50% - 0.375rem);
max-width: none;
}
.vm-search-input {
flex: 1 1 100%;
min-width: 0;
}
.vm-stats-row {
display: none;
}
.vm-actions-row {
flex-wrap: wrap;
gap: 0.375rem;
}
.vm-actions-right {
flex: 1;
justify-content: flex-end;
}
.vm-pagination {
width: 100%;
justify-content: center;
font-size: 0.8rem;
}
.vm-table th,
.vm-table td {
padding: 0.5rem 0.25rem;
padding: 0.5rem 0.375rem;
font-size: 0.8rem;
}
@@ -655,14 +710,6 @@
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;
@@ -672,17 +719,13 @@
}
@media (max-width: 480px) {
.vm-toolbar-left {
flex-direction: column;
gap: 0.5rem;
.vm-filter-select {
flex: 1 1 100%;
max-width: none;
}
.vm-search {
width: 100%;
}
.vm-search input {
width: 100%;
.vm-actions-right .btn span {
display: none;
}
/* Скрываем ещё больше колонок */
@@ -737,14 +780,8 @@
<!-- 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>
<!-- Строка 1: Фильтры -->
<div class="vm-filter-row">
<select id="typeFilter" class="vm-filter-select" onchange="filterByType(this.value)">
<option value="">{{ t('all_types') }}</option>
<option value="qemu">KVM (QEMU)</option>
@@ -757,48 +794,67 @@
<option value="stopped">{{ t('inactive') }}</option>
</select>
<input type="text" id="searchInput" class="vm-search-input" placeholder="{{ t('search') }}..." oninput="searchVMs(this.value)">
<select id="serverFilter" class="vm-filter-select" onchange="filterByServer(this.value)">
<option value="">{{ t('all_servers') }}</option>
</select>
<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>
<select id="nodeFilter" class="vm-filter-select" onchange="filterByNode(this.value)">
<option value="">{{ t('all_nodes') }}</option>
</select>
<div style="position: relative; flex: 2 1 160px; min-width: 140px; display: flex; align-items: center;">
<i class="fa-solid fa-magnifying-glass" style="position: absolute; left: 0.625rem; color: var(--text-muted); font-size: 0.75rem; pointer-events: none;"></i>
<input type="text" id="searchInput" class="vm-search-input" placeholder="{{ t('search') }}..." oninput="searchVMs(this.value)" style="padding-left: 1.875rem; width: 100%;">
</div>
<div class="vm-stats-row" style="display: flex; align-items: center; gap: 0.625rem; color: var(--text-muted); font-size: 0.8rem; white-space: nowrap; margin-left: auto;">
<span>{{ t('total') }}: <strong id="totalCount" style="color: var(--text-primary);">0</strong></span>
<span style="color: var(--success);"><i class="fa-solid fa-circle" style="font-size: 0.5rem;"></i> <span id="runningCount">0</span></span>
<span style="color: var(--danger);"><i class="fa-solid fa-circle" style="font-size: 0.5rem;"></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>
<!-- Строка 2: Выбор + Пагинация + Кнопки -->
<div class="vm-actions-row">
<div class="vm-actions-left">
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer; white-space: nowrap;" title="{{ t('select_all') }}">
<input type="checkbox" id="selectAll" class="vm-checkbox" onclick="toggleSelectAll(event)">
<span style="color: var(--text-secondary); font-size: 0.8rem;">
<span id="selectedCountLabel">0</span> {{ t('selected') }}
</span>
</label>
<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: 44px; text-align: center; background: var(--bg-tertiary); border: 1px solid var(--border-color); border-radius: 4px; padding: 0.2rem; color: var(--text-primary); font-size: 0.8rem;" onchange="goToPage(this.value)">
<span style="color: var(--text-muted); font-size: 0.8rem;">{{ 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>
</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') }}">
<div class="vm-actions-right">
<button class="btn btn-secondary btn-icon" 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>
<button class="btn btn-primary" onclick="showCreateVMModal()" data-permission="vms.create"><span class="btn-text">{{ t('create_vm') }}</span><span class="btn-icon-mobile" style="display:none;"><i class="fa-solid fa-server"></i></span></button>
<button class="btn btn-success" onclick="showCreateLXCModal()" data-permission="vms.create"><i class="fa-solid fa-cube"></i> <span class="btn-text">{{ t('create_lxc') }}</span></button>
</div>
</div>
</div>
@@ -850,20 +906,20 @@
<!-- Snapshots Tab Content -->
<div id="snapshotsTabContent" style="display: none;">
<div class="vm-toolbar" style="margin-bottom: 1rem;">
<div class="vm-toolbar-left">
<div class="vm-toolbar" style="margin-bottom: 1rem; flex-direction: row; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 0.5rem;">
<div style="display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; flex: 1;">
<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 style="position: relative; flex: 1; min-width: 150px; display: flex; align-items: center;">
<i class="fa-solid fa-search" style="position: absolute; left: 0.75rem; color: var(--text-muted); font-size: 0.75rem; pointer-events: none;"></i>
<input type="text" id="snapshotSearchInput" class="vm-search-input" placeholder="{{ t('search') }}..." oninput="filterSnapshots(this.value)" style="padding-left: 2.25rem; width: 100%;">
</div>
</div>
<div class="vm-toolbar-right">
<div style="display: flex; align-items: center; gap: 0.5rem;">
<button class="btn btn-secondary btn-icon" onclick="refreshSnapshots()" title="{{ t('refresh') }}">
<i class="fa-solid fa-rotate"></i>
</button>
@@ -908,6 +964,8 @@ let sortDirection = 'asc';
let currentTypeFilter = '';
let currentStatusFilter = '';
let currentSearchQuery = '';
let currentServerFilter = '';
let currentNodeFilter = '';
// URL params helper functions
function getUrlParams() {
@@ -947,6 +1005,18 @@ function restoreFiltersFromUrl() {
const searchInput = document.getElementById('searchInput');
if (searchInput) searchInput.value = search;
// Restore server filter
const server = params.get('server') || '';
currentServerFilter = server;
const serverSelect = document.getElementById('serverFilter');
if (serverSelect) serverSelect.value = server;
// Restore node filter
const node = params.get('node') || '';
currentNodeFilter = node;
const nodeSelect = document.getElementById('nodeFilter');
if (nodeSelect) nodeSelect.value = node;
// Restore page size
const pageSize = params.get('pageSize');
if (pageSize && ['25', '50', '100'].includes(pageSize)) {
@@ -1377,6 +1447,10 @@ async function loadAllVMs() {
allVMs = await response.json();
// Populate server and node filter dropdowns
populateServerFilter(currentServerFilter);
populateNodeFilter(currentServerFilter);
// Apply filters without resetting page (page is restored from URL)
applyFilters(false);
} catch (error) {
@@ -1600,6 +1674,16 @@ function applyFilters(resetPage = true) {
return false;
}
// Server filter
if (currentServerFilter && String(vm.server_id) !== currentServerFilter) {
return false;
}
// Node filter
if (currentNodeFilter && vm.node !== currentNodeFilter) {
return false;
}
// Search filter
if (currentSearchQuery) {
const query = currentSearchQuery.toLowerCase();
@@ -1649,6 +1733,68 @@ function filterByStatus(status) {
applyFilters();
}
function filterByServer(serverId) {
currentServerFilter = serverId;
updateUrlParams({ server: serverId, page: '1' });
// Update node filter options based on selected server
populateNodeFilter(serverId);
applyFilters();
}
function filterByNode(node) {
currentNodeFilter = node;
updateUrlParams({ node: node, page: '1' });
applyFilters();
}
function populateServerFilter(selectedServerId) {
const select = document.getElementById('serverFilter');
if (!select) return;
// Collect unique servers
const servers = new Map();
allVMs.forEach(vm => {
if (vm.server_id && !servers.has(vm.server_id)) {
servers.set(vm.server_id, vm.server_name || vm.cluster || 'Server ' + vm.server_id);
}
});
// Sort by name
const sorted = [...servers.entries()].sort((a, b) => a[1].localeCompare(b[1]));
const currentVal = selectedServerId !== undefined ? selectedServerId : select.value;
select.innerHTML = `<option value="">${t('all_servers')}</option>` +
sorted.map(([id, name]) => `<option value="${id}">${name}</option>`).join('');
select.value = currentVal;
}
function populateNodeFilter(serverIdFilter) {
const select = document.getElementById('nodeFilter');
if (!select) return;
// Collect unique nodes, optionally filtered by selected server
const nodes = new Set();
allVMs.forEach(vm => {
if (serverIdFilter && String(vm.server_id) !== serverIdFilter) return;
if (vm.node) nodes.add(vm.node);
});
const sorted = [...nodes].sort();
const currentVal = select.value;
select.innerHTML = `<option value="">${t('all_nodes')}</option>` +
sorted.map(n => `<option value="${n}">${n}</option>`).join('');
// Restore previous value if still available
if (nodes.has(currentVal)) {
select.value = currentVal;
} else if (currentNodeFilter && nodes.has(currentNodeFilter)) {
select.value = currentNodeFilter;
} else {
select.value = '';
currentNodeFilter = '';
}
}
function searchVMs(query) {
currentSearchQuery = query;
updateUrlParams({ search: query, page: '1' });
@@ -3992,34 +4138,25 @@ window.addEventListener('DOMContentLoaded', () => {
/* Snapshots toolbar responsive */
#snapshotsTabContent .vm-toolbar {
flex-direction: row;
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;
align-items: center;
justify-content: space-between;
}
@media (max-width: 768px) {
#snapshotsTabContent .vm-toolbar {
flex-direction: column;
align-items: stretch;
}
#snapshotsTabContent .vm-toolbar-left,
#snapshotsTabContent .vm-toolbar-right {
#snapshotsTabContent .vm-toolbar > div:first-child {
width: 100%;
}
#snapshotsTabContent .vm-toolbar-right {
#snapshotsTabContent .vm-toolbar > div:last-child {
width: 100%;
justify-content: flex-end;
}