Добавлен virt_backend: одно постоянное libvirt-соединение вместо десятков вызовов virsh на каждую страницу, с автоматическим fallback на virsh, если libvirt-python недоступен. Обзорные запросы (список VM, пулы, сервисы) закешированы с коротким TTL и сбросом после действий. Появился фоновый сборщик метрик хоста и спарклайны CPU/RAM на дашборде. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PKUoZwHMCHnytfswFVRUHw
116 строки
3.5 KiB
Python
116 строки
3.5 KiB
Python
#!/usr/bin/env python3
|
|
"""Лёгкий сборщик метрик хоста: кольцевой буфер в памяти, без БД и зависимостей.
|
|
|
|
Раз в METRICS_INTERVAL секунд снимаются CPU, память и load average хоста
|
|
плюс число работающих VM. Буфер хранит около часа истории для спарклайнов
|
|
на дашборде. Стоимость — одно чтение /proc и один вызов libvirt за цикл.
|
|
"""
|
|
import threading
|
|
import time
|
|
from collections import deque
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import virt_backend
|
|
|
|
METRICS_INTERVAL = 15
|
|
SAMPLES = deque(maxlen=240) # ~1 час при интервале 15 секунд
|
|
_LOCK = threading.Lock()
|
|
_STARTED = threading.Event()
|
|
_prev_cpu: tuple[int, int] | None = None
|
|
|
|
|
|
def _read_cpu_times() -> tuple[int, int] | None:
|
|
"""(busy, total) джиффи из /proc/stat."""
|
|
try:
|
|
fields = Path("/proc/stat").read_text().splitlines()[0].split()[1:]
|
|
values = [int(v) for v in fields[:8]]
|
|
idle = values[3] + values[4] # idle + iowait
|
|
total = sum(values)
|
|
return total - idle, total
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _cpu_percent() -> float:
|
|
global _prev_cpu
|
|
current = _read_cpu_times()
|
|
if current is None:
|
|
return 0.0
|
|
if _prev_cpu is None:
|
|
_prev_cpu = current
|
|
return 0.0
|
|
busy = current[0] - _prev_cpu[0]
|
|
total = current[1] - _prev_cpu[1]
|
|
_prev_cpu = current
|
|
if total <= 0:
|
|
return 0.0
|
|
return round(100.0 * busy / total, 1)
|
|
|
|
|
|
def _memory_mb() -> tuple[int, int]:
|
|
"""(used_mb, total_mb) из /proc/meminfo."""
|
|
try:
|
|
info = {}
|
|
for line in Path("/proc/meminfo").read_text().splitlines():
|
|
key, _, rest = line.partition(":")
|
|
info[key] = int(rest.split()[0])
|
|
total = info.get("MemTotal", 0) // 1024
|
|
available = info.get("MemAvailable", 0) // 1024
|
|
return max(0, total - available), total
|
|
except Exception:
|
|
return 0, 0
|
|
|
|
|
|
def _load_average() -> float:
|
|
try:
|
|
return float(Path("/proc/loadavg").read_text().split()[0])
|
|
except Exception:
|
|
return 0.0
|
|
|
|
|
|
def _running_vms() -> int:
|
|
stats = virt_backend.domain_stats()
|
|
if stats is not None:
|
|
return len(stats)
|
|
return -1 # backend недоступен — не считаем через subprocess, чтобы не грузить хост
|
|
|
|
|
|
def collect_sample() -> dict[str, Any]:
|
|
used_mb, total_mb = _memory_mb()
|
|
return {
|
|
"ts": int(time.time()),
|
|
"cpu": _cpu_percent(),
|
|
"mem_used_mb": used_mb,
|
|
"mem_total_mb": total_mb,
|
|
"mem_percent": round(100.0 * used_mb / total_mb, 1) if total_mb else 0.0,
|
|
"load1": _load_average(),
|
|
"vms_running": _running_vms(),
|
|
}
|
|
|
|
|
|
def _worker() -> None:
|
|
_cpu_percent() # первая выборка задаёт базу для дельты
|
|
while True:
|
|
sample = collect_sample()
|
|
with _LOCK:
|
|
SAMPLES.append(sample)
|
|
time.sleep(METRICS_INTERVAL)
|
|
|
|
|
|
def start() -> None:
|
|
if _STARTED.is_set():
|
|
return
|
|
_STARTED.set()
|
|
threading.Thread(target=_worker, daemon=True, name="virtuality-metrics").start()
|
|
|
|
|
|
def snapshot(limit: int = 240) -> dict[str, Any]:
|
|
with _LOCK:
|
|
series = list(SAMPLES)[-limit:]
|
|
return {
|
|
"interval": METRICS_INTERVAL,
|
|
"series": series,
|
|
"current": series[-1] if series else None,
|
|
}
|