fix: wait for Proxmox resize task before VM start (v1.2.0)

- configure_vm: capture resize UPID and call wait_for_task(120s)
  before returning; prevents lock conflict when VM is started right
  after disk resize
- resize_vm_disk / resize_container_disk (x2): same fix applied
- remove unnecessary restart_vm() after manual QEMU disk resize —
  resize is a storage-layer operation and does not require a reboot;
  the auto-restart was causing a second lock conflict

Fixes: can't lock file '/var/lock/qemu-server/lock-xxx.conf' - got timeout
Fixes: command '/usr/bin/qemu-img resize' failed: got timeout
This commit is contained in:
markmorado
2026-03-16 20:23:06 +01:00
parent bd1777f67b
commit 4ef79112be
15 changed files with 342 additions and 156 deletions

View File

@@ -705,7 +705,12 @@ body.update-in-progress .layout {
{% endif %}
</div>
<!-- System Status -->
{% block sidebar_status %}{% endblock %}
<div class="sidebar-status-section" id="sidebarStatusSection">
<div class="status-chip status-ok" id="sidebarStatusChip">
<span class="status-chip-dot"></span>
<span id="sidebarStatusText">...</span>
</div>
</div>
<!-- Workspace Selector -->
<div class="sidebar-workspace-section" id="sidebarWorkspaceSection">
<div class="sidebar-workspace-label">Workspace</div>
@@ -1457,6 +1462,39 @@ body.update-in-progress .layout {
}
});
async function loadSidebarStatus() {
const chip = document.getElementById('sidebarStatusChip');
const text = document.getElementById('sidebarStatusText');
if (!chip || !text) return;
try {
const resp = await fetchWithAuth('/api/dashboard/alerts', { hideOnForbidden: true });
if (!resp || !resp.ok) return;
const data = await resp.json();
const total = data.total || 0;
const critical = (data.alerts || []).filter(a => a.level === 'critical').length;
chip.classList.remove('status-ok', 'status-warning', 'status-error');
if (critical > 0) {
chip.classList.add('status-error');
chip.style.cursor = 'pointer';
chip.onclick = () => { if (typeof openAlertsModal === 'function') openAlertsModal(); };
text.textContent = `${critical} ${t('critical') || 'critical'}`;
} else if (total > 0) {
chip.classList.add('status-warning');
chip.style.cursor = 'pointer';
chip.onclick = () => { if (typeof openAlertsModal === 'function') openAlertsModal(); };
text.textContent = `${total} ${t('warning') || 'warning'}`;
} else {
chip.classList.add('status-ok');
chip.style.cursor = '';
chip.onclick = null;
text.textContent = t('all_systems_operational') || 'All systems operational';
}
} catch (e) {
const text2 = document.getElementById('sidebarStatusText');
if (text2) text2.textContent = t('all_systems_operational') || 'All systems operational';
}
}
async function loadSidebarWorkspace() {
try {
// Determine if current user is admin
@@ -1958,6 +1996,10 @@ body.update-in-progress .layout {
// Load workspace selector for sidebar
loadSidebarWorkspace();
// Load sidebar status chip
loadSidebarStatus();
setInterval(loadSidebarStatus, 60000);
}
// Add click handlers for sidebar menu items to update page title

View File

@@ -11,23 +11,6 @@
</div>
{% endblock %}
{% block sidebar_status %}
<div class="sidebar-status-section">
<div class="status-chip {% if stats.total_alerts == 0 %}status-ok{% elif stats.critical_alerts > 0 %}status-error{% else %}status-warning{% endif %}" id="dashStatusChip"{% if stats.total_alerts > 0 %} onclick="openAlertsModal()" style="cursor:pointer;" title="{{ t('view_alerts') if t('view_alerts') else 'Посмотреть предупреждения' }}"{% endif %}>
<span class="status-chip-dot"></span>
<span id="dashStatusText">
{%- if stats.total_alerts == 0 -%}
{{ t('all_systems_operational') }}
{%- elif stats.critical_alerts > 0 -%}
{{ stats.critical_alerts }} {{ t('critical') | lower }} {{ t('alerts_label') | lower }}
{%- else -%}
{{ stats.total_alerts }} {{ t('warning') | lower }}{{ 's' if stats.total_alerts != 1 else '' }}
{%- endif -%}
</span>
</div>
</div>
{% endblock %}
{% block content %}
<!-- ======== 5 STAT CARDS ======== -->
<div class="stats-grid-5">
@@ -337,15 +320,15 @@ async function _refreshNodeStats(onlineServers) {
Promise.all(uniqueNodes.map(async ({ server, nodeName, maxVms }) => {
try {
const r = await fetchWithAuth(`/proxmox/api/${server.id}/node/status?node=${nodeName}`, { hideOnForbidden: true });
if (!r || !r.ok) return { nodeName, cpuPct: 0, ramPct: 0, diskPct: 0, vmCount: maxVms };
if (!r || !r.ok) return { nodeName, serverName: server.name, cpuPct: 0, ramPct: 0, diskPct: 0, vmCount: maxVms };
const st = await r.json();
return {
nodeName, vmCount: maxVms,
nodeName, serverName: server.name, vmCount: maxVms,
cpuPct: Math.round((st.cpu || 0) * 100),
ramPct: st.memory && st.memory.total > 0 ? Math.round((st.memory.used / st.memory.total) * 100) : 0,
diskPct: st.rootfs && st.rootfs.total > 0 ? Math.round((st.rootfs.used / st.rootfs.total) * 100) : 0,
};
} catch (e_) { return { nodeName, cpuPct: 0, ramPct: 0, diskPct: 0, vmCount: maxVms }; }
} catch (e_) { return { nodeName, serverName: server.name, cpuPct: 0, ramPct: 0, diskPct: 0, vmCount: maxVms }; }
})),
Promise.all(uniqueNodes.map(async ({ server, nodeName }) => {
try {
@@ -420,7 +403,7 @@ async function _refreshNodeStats(onlineServers) {
<div class="node-item-header">
<div class="node-item-name">
<span class="node-dot-online"></span>
${escapeHtml(node.nodeName)}
<span class="node-name-text">${escapeHtml(node.serverName || node.nodeName)}</span>
</div>
<span class="node-vm-count">${node.vmCount > 0 ? node.vmCount + ' ' + t('vms') : ''}</span>
</div>
@@ -530,7 +513,7 @@ function renderDashboardData(data) {
const allNodes = [];
for (const server of (data.servers || [])) {
if (Array.isArray(server.nodes)) {
for (const node of server.nodes) allNodes.push(node);
for (const node of server.nodes) allNodes.push({ ...node, serverName: server.name });
}
}
if (allNodes.length > 0) {
@@ -583,7 +566,7 @@ function renderDashboardData(data) {
<div class="node-item-header">
<div class="node-item-name">
<span class="node-dot-online"></span>
${escapeHtml(node.name)}
<span class="node-name-text">${escapeHtml(node.serverName || node.name)}</span>
</div>
</div>
<div class="node-bar-row">

View File

@@ -1292,6 +1292,9 @@ function createResourceCard(resource, serverId, type) {
}
// Модальное окно мониторинга
// Track open monitor channel for cleanup
window._monitorWsChannel = null;
function showMonitorModal(serverId, node, vmid, type, name) {
const modal = document.getElementById('monitorModal');
document.getElementById('monitorTitle').innerHTML = `
@@ -1299,75 +1302,85 @@ function showMonitorModal(serverId, node, vmid, type, name) {
<span id="updateIndicator" style="display: inline-block; margin-left: 0.5rem; width: 8px; height: 8px; background: var(--accent); border-radius: 50%; opacity: 0; transition: opacity 0.3s ease;"></span>
`;
document.getElementById('monitorContent').innerHTML = '<div style="text-align: center; padding: 2rem; color: var(--text-muted);"><i class="fa-solid fa-spinner fa-spin"></i> Загрузка данных...</div>';
modal.style.display = 'flex';
// Загрузить данные мониторинга
// Clean up any previous WS subscription
if (window._monitorWsChannel && typeof unsubscribeWsChannel === 'function') {
unsubscribeWsChannel(window._monitorWsChannel);
}
window._monitorWsChannel = `instance_metrics:${serverId}_${vmid}_${type}_${node}`;
// Initial load via HTTP (RRD) + WS status (if cached) — fallback to HTTP
loadMonitorData(serverId, node, vmid, type);
// Обновлять каждые 5 секунд
if (window.monitorInterval) clearInterval(window.monitorInterval);
window.monitorInterval = setInterval(() => {
if (modal.style.display === 'flex') {
loadMonitorData(serverId, node, vmid, type);
}
}, 5000);
// Subscribe to live WS updates
if (typeof subscribeWsChannel === 'function') {
subscribeWsChannel(window._monitorWsChannel);
}
if (typeof registerWsHandler === 'function') {
registerWsHandler('metrics_update', function _monitorWsHandler(msg) {
if (msg.channel !== window._monitorWsChannel) return;
if (!msg.data) return;
const monitorContent = document.getElementById('monitorContent');
if (!monitorContent) return;
const updateIndicator = document.getElementById('updateIndicator');
if (updateIndicator) { updateIndicator.style.opacity = '1'; setTimeout(() => { updateIndicator.style.opacity = '0'; }, 300); }
if (monitorContent.dataset.initialized) {
updateMonitorData(msg.data, null);
} else {
// First time: need RRD too — fetch once then mark initialized
fetchWithAuth(`/proxmox/api/${serverId}/${type}/${vmid}/rrddata?node=${node}&timeframe=hour`)
.then(r => r.ok ? r.json() : null)
.catch(() => null)
.then(rrdData => {
renderMonitorData(msg.data, rrdData || [], type);
monitorContent.dataset.initialized = 'true';
});
}
});
}
}
function closeMonitorModal() {
const modal = document.getElementById('monitorModal');
const monitorContent = document.getElementById('monitorContent');
modal.style.display = 'none';
// Очистить флаг инициализации для следующего открытия
if (monitorContent) {
delete monitorContent.dataset.initialized;
}
if (window.monitorInterval) {
clearInterval(window.monitorInterval);
window.monitorInterval = null;
if (monitorContent) delete monitorContent.dataset.initialized;
// Unsubscribe WS
if (window._monitorWsChannel && typeof unsubscribeWsChannel === 'function') {
unsubscribeWsChannel(window._monitorWsChannel);
window._monitorWsChannel = null;
}
}
async function loadMonitorData(serverId, node, vmid, type) {
const monitorContent = document.getElementById('monitorContent');
const isFirstLoad = !monitorContent.dataset.initialized;
try {
// Показать индикатор обновления
const updateIndicator = document.getElementById('updateIndicator');
if (updateIndicator) {
updateIndicator.style.opacity = '1';
}
if (updateIndicator) updateIndicator.style.opacity = '1';
const [statusRes, rrdRes] = await Promise.all([
fetchWithAuth(`/proxmox/api/${serverId}/${type}/${vmid}/status?node=${node}`),
fetchWithAuth(`/proxmox/api/${serverId}/${type}/${vmid}/rrddata?node=${node}&timeframe=hour`)
]);
if (!statusRes.ok || !rrdRes.ok) {
throw new Error(`HTTP error: ${statusRes.status} / ${rrdRes.status}`);
}
if (!statusRes.ok || !rrdRes.ok) throw new Error(`HTTP error: ${statusRes.status} / ${rrdRes.status}`);
const status = await statusRes.json();
const rrdData = await rrdRes.json();
// Первая загрузка - рендерим всё, последующие - только обновляем данные
if (isFirstLoad) {
renderMonitorData(status, rrdData, type);
monitorContent.dataset.initialized = 'true';
} else {
updateMonitorData(status, rrdData);
}
// Скрыть индикатор
if (updateIndicator) {
setTimeout(() => {
updateIndicator.style.opacity = '0';
}, 300);
}
if (updateIndicator) setTimeout(() => { updateIndicator.style.opacity = '0'; }, 300);
} catch (error) {
console.error('Error loading monitor data:', error);
document.getElementById('monitorContent').innerHTML = `

View File

@@ -1906,29 +1906,77 @@ function revertVMStatus(serverId, vmid) {
delete statusEl.dataset.prevStatus;
}
// Poll VM status until it changes
// Poll VM status until it changes — via WebSocket instance_metrics
async function pollVMStatus(serverId, vmid, type, node, action, attempts = 0, phase = 'initial') {
const maxAttempts = 30;
const pollInterval = 1500;
if (attempts >= maxAttempts) {
loadAllVMs();
// Prefer WS subscription over HTTP polling
if (typeof subscribeWsChannel === 'function' && typeof registerWsHandler === 'function') {
const channel = `instance_metrics:${serverId}_${vmid}_${type}_${node}`;
let _resolved = false;
let _timeoutId = null;
const _cleanup = () => {
clearTimeout(_timeoutId);
if (typeof unsubscribeWsChannel === 'function') unsubscribeWsChannel(channel);
};
// Timeout: fall back to HTTP after 45 s
_timeoutId = setTimeout(() => {
if (_resolved) return;
_resolved = true;
_cleanup();
_pollVMStatusHTTP(serverId, vmid, type, node, action, 0, phase);
}, 45000);
subscribeWsChannel(channel);
registerWsHandler('metrics_update', function _wsHandler(msg) {
if (msg.channel !== channel || _resolved) return;
const newStatus = msg.data && msg.data.status;
if (!newStatus) return;
if (action === 'restart') {
if (phase === 'initial' && newStatus === 'stopped') {
phase = 'restarting';
updateVMStatusUI(serverId, vmid, 'pending', 'restart');
return;
} else if (phase === 'restarting' && newStatus === 'running') {
_resolved = true;
_cleanup();
updateVMStatusUI(serverId, vmid, newStatus);
updateVMActionButton(serverId, vmid, type, node, newStatus);
}
} else {
const expected = action === 'start' ? 'running' : action === 'stop' ? 'stopped' : null;
if (expected && newStatus === expected) {
_resolved = true;
_cleanup();
updateVMStatusUI(serverId, vmid, newStatus);
updateVMActionButton(serverId, vmid, type, node, newStatus);
}
}
});
return;
}
// Fallback: HTTP polling (no WS available)
_pollVMStatusHTTP(serverId, vmid, type, node, action, attempts, phase);
}
async function _pollVMStatusHTTP(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);
setTimeout(() => _pollVMStatusHTTP(serverId, vmid, type, node, action, 0, 'restarting'), pollInterval);
return;
} else if (phase === 'restarting' && newStatus === 'running') {
updateVMStatusUI(serverId, vmid, newStatus);
@@ -1936,24 +1984,18 @@ async function pollVMStatus(serverId, vmid, type, node, action, attempts = 0, ph
return;
}
} else {
const expectedStatus = action === 'start' ? 'running' :
action === 'stop' ? 'stopped' : null;
if (expectedStatus && newStatus === expectedStatus) {
const expected = action === 'start' ? 'running' : action === 'stop' ? 'stopped' : null;
if (expected && newStatus === expected) {
updateVMStatusUI(serverId, vmid, newStatus);
updateVMActionButton(serverId, vmid, type, node, newStatus);
return;
}
}
// Continue polling
setTimeout(() => pollVMStatus(serverId, vmid, type, node, action, attempts + 1, phase), pollInterval);
setTimeout(() => _pollVMStatusHTTP(serverId, vmid, type, node, action, attempts + 1, phase), pollInterval);
} else {
setTimeout(() => pollVMStatus(serverId, vmid, type, node, action, attempts + 1, phase), pollInterval);
setTimeout(() => _pollVMStatusHTTP(serverId, vmid, type, node, action, attempts + 1, phase), pollInterval);
}
} catch (error) {
setTimeout(() => pollVMStatus(serverId, vmid, type, node, action, attempts + 1, phase), pollInterval);
}
} catch { setTimeout(() => _pollVMStatusHTTP(serverId, vmid, type, node, action, attempts + 1, phase), pollInterval); }
}
// Update action button with new status for context menu
@@ -3200,64 +3242,34 @@ window.addEventListener('DOMContentLoaded', () => {
}, 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);
}
// Subscribe to WS dashboard_metrics for live status updates (replaces HTTP polling)
if (typeof subscribeWsChannel === 'function') {
subscribeWsChannel('dashboard_metrics');
}
if (typeof registerWsHandler === 'function') {
registerWsHandler('metrics_update', (msg) => {
if (msg.channel !== 'dashboard_metrics' || !msg.data) return;
if (allVMs.length === 0) return;
// Update only VMs whose status changed (no DOM rebuild)
for (const server of (msg.data.servers || [])) {
const sid = server.id;
for (const item of [...(server.vms || []), ...(server.containers || [])]) {
const vm = allVMs.find(v => v.server_id === sid && v.vmid === item.vmid);
if (!vm) continue;
const newStatus = item.status || 'stopped';
if (vm.status !== newStatus) {
vm.status = newStatus;
updateVMStatusUI(sid, item.vmid, newStatus);
updateVMActionButton(sid, item.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;
updateStats();
});
}
}
// Clean up on page unload
window.addEventListener('beforeunload', () => {
stopStatusMonitoring();
window.addEventListener('beforeunload', () => {
if (typeof unsubscribeWsChannel === 'function') unsubscribeWsChannel('dashboard_metrics');
});
});
</script>