Files
pvemanager/backend/app/proxmox/mixins/vm.py
Dilshod Rasulov a507e3c4bc feat(metrics): real-time VM disk fill, IOPS and network I/O
Add live per-disk usage, disk read/write and network in/out rates to the
instance Overview tab, the instances list and the dashboard top-loaded list.

Backend:
- get_vm_fsinfo() pulls per-filesystem usage via QEMU guest agent
- metrics_broadcaster computes I/O rates (bytes/sec) from cumulative counters
- instances-list loop now refreshes each running VM via status/current
  (1s-granular) instead of cluster/resources (10s pvestatd), with bounded
  concurrency, and paces both realtime loops to a 1s cadence

Frontend:
- OverviewTab: per-disk cards, Network I/O and I/O-rate cards via WS
- InstancesPage: Net and Disk I/O columns; Disk% shown for LXC only
- DashboardPage: top-loaded list overlaid with realtime metrics

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 08:26:29 +05:00

273 lines
13 KiB
Python
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.
from typing import List, Dict, Optional, Union, Any
import time
import urllib3
from loguru import logger
class VmMixin:
def start_vm(self, node: str, vmid: int) -> Optional[str]:
"""Запустить VM. Возвращает UPID задачи или None при ошибке."""
if not self.proxmox:
return None
try:
upid = self.proxmox.nodes(node).qemu(vmid).status.start.post()
return upid if isinstance(upid, str) else None
except Exception as e:
logger.error(f"Ошибка запуска VM {vmid} на {node}: {e}")
return None
def stop_vm(self, node: str, vmid: int, force: bool = False) -> Optional[str]:
"""Остановить VM. Возвращает UPID задачи или None при ошибке."""
if not self.proxmox:
return None
try:
upid = self.proxmox.nodes(node).qemu(vmid).status.stop.post()
return upid if isinstance(upid, str) else None
except Exception as e:
logger.error(f"Ошибка остановки VM {vmid} на {node}: {e}")
return None
def restart_vm(self, node: str, vmid: int) -> Optional[str]:
"""Перезапустить VM. Возвращает UPID задачи или None при ошибке."""
if not self.proxmox:
return None
try:
upid = self.proxmox.nodes(node).qemu(vmid).status.reboot.post()
return upid if isinstance(upid, str) else None
except Exception as e:
logger.error(f"Ошибка перезапуска VM {vmid} на {node}: {e}")
return None
def shutdown_vm(self, node: str, vmid: int, timeout: int = 60) -> Optional[str]:
"""Корректно выключить VM (ACPI). Возвращает UPID задачи."""
if not self.proxmox:
return None
try:
upid = self.proxmox.nodes(node).qemu(vmid).status.shutdown.post(timeout=timeout)
return upid if isinstance(upid, str) else None
except Exception as e:
logger.error(f"Ошибка корректного выключения VM {vmid} на {node}: {e}")
return None
def clone_vm(self, node: str, vmid: int, new_vmid: int, name: str,
full: bool = True, target_node: Optional[str] = None,
target_storage: Optional[str] = None,
description: Optional[str] = None) -> Optional[str]:
"""Клонировать VM (qemu). Работает и для шаблонов, и для обычных VM."""
if not self.proxmox:
return None
try:
params = {'newid': new_vmid, 'name': name, 'full': 1 if full else 0}
if target_storage:
params['storage'] = target_storage
if target_node and target_node != node:
params['target'] = target_node
if description:
params['description'] = description
upid = self.proxmox.nodes(node).qemu(vmid).clone.post(**params)
return upid if isinstance(upid, str) else None
except Exception as e:
logger.error(f"Ошибка клонирования VM {vmid} -> {new_vmid}: {e}")
return None
def change_vm_password(self, node: str, vmid: int, username: str, password: str) -> Dict:
"""
Сменить пароль пользователя на VM через QEMU guest agent.
Требует установленный qemu-guest-agent в VM.
"""
if not self.proxmox:
return {'success': False, 'error': 'Proxmox connection not initialized'}
try:
# Proxmox API: POST /nodes/{node}/qemu/{vmid}/agent/set-user-password
self.proxmox.nodes(node).qemu(vmid).agent('set-user-password').post(
username=username,
password=password,
)
return {'success': True}
except Exception as e:
logger.error(f"Не удалось сменить пароль на VM {vmid}: {e}")
return {'success': False, 'error': str(e)}
def set_vm_notes(self, node: str, vmid: int, description: str) -> bool:
"""Установить description (заметки) у VM."""
if not self.proxmox:
return False
try:
self.proxmox.nodes(node).qemu(vmid).config.put(description=description or '')
return True
except Exception as e:
logger.error(f"Ошибка обновления заметок VM {vmid}: {e}")
return False
def attach_iso(self, node: str, vmid: int, iso_volid: str, device: str = 'ide2') -> bool:
"""Подключить ISO образ к VM (только KVM)."""
if not self.proxmox:
return False
try:
value = f"{iso_volid},media=cdrom"
self.proxmox.nodes(node).qemu(vmid).config.put(**{device: value})
return True
except Exception as e:
logger.error(f"Ошибка подключения ISO {iso_volid} к VM {vmid}: {e}")
return False
def detach_iso(self, node: str, vmid: int, device: str = 'ide2') -> bool:
"""Отключить ISO образ (CD-ROM) у VM."""
if not self.proxmox:
return False
try:
self.proxmox.nodes(node).qemu(vmid).config.put(**{device: 'none,media=cdrom'})
return True
except Exception as e:
logger.error(f"Ошибка отключения ISO у VM {vmid}: {e}")
return False
def force_stop_vm(self, node: str, vmid: int) -> bool:
"""Принудительно остановить ВМ (через SSH - аналог kill -9)"""
if not self.proxmox or not self.host:
return False
try:
# Пробуем остановить через SSH команду
# Сначала получаем информацию о ВМ
vm_info = self.proxmox.nodes(node).qemu(vmid).status.current.get()
if vm_info.get('status') != 'running':
logger.info(f"VM {vmid} на {node} уже остановлена")
return True
# Используем qm sendkey или qm stop с timeout=0
try:
# Пробуем обычный stop - может сработать
self.proxmox.nodes(node).qemu(vmid).status.stop.post()
logger.info(f"Принудительная остановка VM {vmid} на {node} выполнена")
return True
except Exception as api_error:
logger.warning(f"Прямой stop не сработал, ошибка: {api_error}")
# Если прямой stop не сработал, это просто значит что ВМ не бежит или API не поддерживает
return False
except Exception as e:
logger.error(f"Ошибка принудительной остановки VM {vmid}: {e}")
return False
def get_vm_status(self, node: str, vmid: int) -> Optional[Dict]:
"""Получить статус конкретной VM"""
if not self.proxmox:
return None
try:
status = self.proxmox.nodes(node).qemu(vmid).status.current.get()
return status
except Exception as e:
logger.error(f"Ошибка получения статуса VM {vmid} на {node}: {e}")
return None
def get_vm_stats(self, node: str, vmid: int) -> Optional[Dict]:
"""Получить статистику VM (CPU, память, диск)"""
return self.get_vm_status(node, vmid)
def get_vm_fsinfo(self, node: str, vmid: int) -> list:
"""Return per-filesystem info via QEMU guest agent (get-fsinfo).
Returns [] if agent is not installed or unreachable."""
if not self.proxmox:
return []
try:
result = self.proxmox.nodes(node).qemu(vmid).agent('get-fsinfo').get()
fslist = result if isinstance(result, list) else result.get('result', [])
disks = []
seen_disks: set = set()
for fs in fslist:
if not isinstance(fs, dict):
continue
total = fs.get('total-bytes', 0) or 0
if total == 0:
continue
# Deduplicate by disk+partition key to avoid pseudo-filesystems
disk_key = fs.get('name', '') or fs.get('mountpoint', '')
if disk_key in seen_disks:
continue
seen_disks.add(disk_key)
disks.append({
'name': fs.get('name', ''),
'mountpoint': fs.get('mountpoint', '/'),
'used': fs.get('used-bytes', 0) or 0,
'total': total,
})
return disks
except Exception:
return []
def get_vm_rrddata(self, node: str, vmid: int, timeframe: str = "hour") -> Dict:
"""
Получить исторические данные VM для графиков (CPU, Memory, Network, Disk IO)
Args:
node: Имя ноды
vmid: ID виртуальной машины
timeframe: Период времени (hour, day, week, month, year)
Returns:
Dict с временными рядами данных
"""
if not self.proxmox:
return {}
try:
rrddata = self.proxmox.nodes(node).qemu(vmid).rrddata.get(timeframe=timeframe)
return {'data': rrddata, 'timeframe': timeframe}
except Exception as e:
logger.error(f"Ошибка получения RRD данных VM {vmid} на {node}: {e}")
return {}
def get_vm_vnc(self, node: str, vmid: int) -> Dict:
"""
Получить данные для VNC подключения к VM
Args:
node: Имя ноды
vmid: ID виртуальной машины
Returns:
Dict с данными VNC (port, ticket, cert, upid)
"""
if not self.proxmox:
return {}
try:
# Создать VNC прокси сессию
vnc_data = self.proxmox.nodes(node).qemu(vmid).vncproxy.post(websocket=1)
return vnc_data
except Exception as e:
logger.error(f"Ошибка получения VNC для VM {vmid} на {node}: {e}")
return {}
def resize_vm_disk(self, node: str, vmid: int, disk: str, size: str) -> bool:
"""
Изменить размер диска VM
Args:
node: Имя ноды
vmid: ID виртуальной машины
disk: Имя диска (scsi0, virtio0 и т.д.)
size: Новый размер (+10G, 50G)
Returns:
True при успехе
"""
if not self.proxmox:
return False
try:
resize_upid = self.proxmox.nodes(node).qemu(vmid).resize.put(disk=disk, size=size)
# Wait for resize task to complete to avoid lock conflicts
if isinstance(resize_upid, str):
self.wait_for_task(node, resize_upid, timeout=120)
logger.info(f"Размер диска {disk} VM {vmid} изменен на {size}")
return True
except Exception as e:
logger.error(f"Ошибка изменения размера диска VM {vmid}: {e}")
return False