Производительность панели была улучшена

Добавлен 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
Этот коммит содержится в:
Claude
2026-08-15 19:23:14 +00:00
родитель d322c1352c
Коммит 374bcbb29f
6 изменённых файлов: 376 добавлений и 16 удалений
+6 -1
Просмотреть файл
@@ -137,7 +137,7 @@ step "Обновляем apt cache"
run_logged "apt update выполнен" apt update run_logged "apt update выполнен" apt update
step "Устанавливаем системные зависимости под выбранную сборку" step "Устанавливаем системные зависимости под выбранную сборку"
COMMON_PACKAGES=(python3 python3-venv python3-pip rsync openssl curl wget unzip nftables novnc python3-websockify) COMMON_PACKAGES=(python3 python3-venv python3-pip python3-dev rsync openssl curl wget unzip nftables novnc python3-websockify libvirt-dev pkg-config gcc)
X86_PACKAGES=(qemu-system-x86 qemu-system-arm qemu-efi-aarch64 virtinst libvirt-daemon-system libvirt-clients bridge-utils cloud-image-utils) X86_PACKAGES=(qemu-system-x86 qemu-system-arm qemu-efi-aarch64 virtinst libvirt-daemon-system libvirt-clients bridge-utils cloud-image-utils)
ARM_PACKAGES=(qemu-system-arm qemu-efi-aarch64 virtinst libvirt-daemon-system libvirt-clients bridge-utils cloud-image-utils) ARM_PACKAGES=(qemu-system-arm qemu-efi-aarch64 virtinst libvirt-daemon-system libvirt-clients bridge-utils cloud-image-utils)
PACKAGES=("${COMMON_PACKAGES[@]}") PACKAGES=("${COMMON_PACKAGES[@]}")
@@ -212,6 +212,11 @@ fi
step "Устанавливаем Python-зависимости" step "Устанавливаем Python-зависимости"
run_logged "pip обновлён" "$VENV_DIR/bin/pip" install --upgrade pip run_logged "pip обновлён" "$VENV_DIR/bin/pip" install --upgrade pip
run_logged "Python-зависимости установлены" "$VENV_DIR/bin/pip" install -r "$APP_DIR/requirements.txt" run_logged "Python-зависимости установлены" "$VENV_DIR/bin/pip" install -r "$APP_DIR/requirements.txt"
if "$VENV_DIR/bin/pip" install libvirt-python >> "$LOG_FILE" 2>&1; then
ok "libvirt-python установлен — панель работает через быстрый libvirt-backend"
else
warn "libvirt-python не установился — панель будет работать через virsh (медленнее, но полностью функционально)"
fi
step "Создаём systemd service" step "Создаём systemd service"
UVICORN_TLS_ARGS="" UVICORN_TLS_ARGS=""
+75 -15
Просмотреть файл
@@ -39,8 +39,10 @@ from itsdangerous import BadSignature, URLSafeSerializer
import cloud_images import cloud_images
import host_profile import host_profile
import metrics
import network_core import network_core
import update_core import update_core
import virt_backend
from network_core import NetworkError from network_core import NetworkError
BASE_DIR = Path(__file__).resolve().parent BASE_DIR = Path(__file__).resolve().parent
@@ -180,6 +182,33 @@ def require_csrf(request: Request, csrf_token: str):
return HTMLResponse("<h1>Сессия устарела</h1><p>Открой страницу заново и повтори действие.</p>", status_code=403) return HTMLResponse("<h1>Сессия устарела</h1><p>Открой страницу заново и повтори действие.</p>", status_code=403)
# TTL-кеш для дорогих обзорных запросов: на слабом железе (Raspberry Pi)
# каждый рендер дашборда без кеша порождает десятки subprocess-вызовов.
_TTL_CACHE: dict[str, tuple[float, Any]] = {}
_TTL_CACHE_LOCK = threading.Lock()
def cached(key: str, ttl_seconds: float, producer):
now = time.monotonic()
with _TTL_CACHE_LOCK:
entry = _TTL_CACHE.get(key)
if entry and now - entry[0] < ttl_seconds:
return entry[1]
value = producer()
with _TTL_CACHE_LOCK:
_TTL_CACHE[key] = (time.monotonic(), value)
return value
def cache_invalidate(prefix: str = "") -> None:
with _TTL_CACHE_LOCK:
if not prefix:
_TTL_CACHE.clear()
return
for key in [k for k in _TTL_CACHE if k.startswith(prefix)]:
_TTL_CACHE.pop(key, None)
def run_cmd(cmd: list[str], timeout: int = 12) -> dict[str, Any]: def run_cmd(cmd: list[str], timeout: int = 12) -> dict[str, Any]:
try: try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, check=False) result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, check=False)
@@ -303,6 +332,7 @@ def run_operation_worker(operation_id: str, cmd: list[str], progress_fn=None, st
append_operation_log(operation_id, "Команда завершилась успешно.") append_operation_log(operation_id, "Команда завершилась успешно.")
if refresh_pool: if refresh_pool:
run_cmd(["virsh", "pool-refresh", "virtuality-images"], timeout=20) run_cmd(["virsh", "pool-refresh", "virtuality-images"], timeout=20)
cache_invalidate()
update_operation(fresh, status="success", progress=100, exit_code=exit_code, message=success_message, finished_at=utc_now()) update_operation(fresh, status="success", progress=100, exit_code=exit_code, message=success_message, finished_at=utc_now())
else: else:
append_operation_log(operation_id, f"Команда завершилась с ошибкой. Exit code: {exit_code}") append_operation_log(operation_id, f"Команда завершилась с ошибкой. Exit code: {exit_code}")
@@ -377,7 +407,7 @@ def vm_autostart_status(name: str) -> dict[str, str | bool]:
return {"enabled": enabled, "label": label, "css": css, "raw": raw} return {"enabled": enabled, "label": label, "css": css, "raw": raw}
def parse_virsh_list() -> list[dict[str, str]]: def parse_virsh_list_uncached() -> list[dict[str, str]]:
def clean_ip(ip: str) -> str: def clean_ip(ip: str) -> str:
ip = (ip or "").split("/")[0].strip() ip = (ip or "").split("/")[0].strip()
if not ip or ip.startswith("127.") or ip.startswith("169.254.") or ip == "0.0.0.0": if not ip or ip.startswith("127.") or ip.startswith("169.254.") or ip == "0.0.0.0":
@@ -482,25 +512,36 @@ def parse_virsh_list() -> list[dict[str, str]]:
pass pass
return "" return ""
result = run_cmd(["virsh", "list", "--all"])
rows = [] rows = []
if not result["ok"]: backend_rows = virt_backend.list_domains()
return rows if backend_rows is not None:
for line in result["stdout"].splitlines()[2:]: # Быстрый путь: одно libvirt-соединение вместо virsh + dominfo на каждую VM.
parts = line.strip().split(None, 2) for dom in backend_rows:
if len(parts) == 3: enabled = dom["autostart_enabled"]
autostart = vm_autostart_status(parts[1]) rows.append({"id": dom["id"], "name": dom["name"], "state": dom["state"], "autostart_enabled": enabled, "autostart_label": "enabled" if enabled else "disabled", "autostart_css": "ok" if enabled else "warn"})
rows.append({"id": parts[0], "name": parts[1], "state": parts[2], "autostart_enabled": autostart["enabled"], "autostart_label": autostart["label"], "autostart_css": autostart["css"]}) else:
elif len(parts) == 2: result = run_cmd(["virsh", "list", "--all"])
autostart = vm_autostart_status(parts[0]) if not result["ok"]:
rows.append({"id": "-", "name": parts[0], "state": parts[1], "autostart_enabled": autostart["enabled"], "autostart_label": autostart["label"], "autostart_css": autostart["css"]}) return rows
for line in result["stdout"].splitlines()[2:]:
parts = line.strip().split(None, 2)
if len(parts) == 3:
autostart = vm_autostart_status(parts[1])
rows.append({"id": parts[0], "name": parts[1], "state": parts[2], "autostart_enabled": autostart["enabled"], "autostart_label": autostart["label"], "autostart_css": autostart["css"]})
elif len(parts) == 2:
autostart = vm_autostart_status(parts[0])
rows.append({"id": "-", "name": parts[0], "state": parts[1], "autostart_enabled": autostart["enabled"], "autostart_label": autostart["label"], "autostart_css": autostart["css"]})
for row in rows: for row in rows:
row["ip"] = resolve_ip(row.get("name", "")) row["ip"] = resolve_ip(row.get("name", ""))
row["manual_ip"] = manual_ips.get(row.get("name", ""), "") row["manual_ip"] = manual_ips.get(row.get("name", ""), "")
return rows return rows
def parse_pool_list() -> list[dict[str, str]]: def parse_virsh_list() -> list[dict[str, str]]:
return cached("vm_list", 5, parse_virsh_list_uncached)
def parse_pool_list_uncached() -> list[dict[str, str]]:
result = run_cmd(["virsh", "pool-list", "--all"]) result = run_cmd(["virsh", "pool-list", "--all"])
rows = [] rows = []
if not result["ok"]: if not result["ok"]:
@@ -512,6 +553,10 @@ def parse_pool_list() -> list[dict[str, str]]:
return rows return rows
def parse_pool_list() -> list[dict[str, str]]:
return cached("pool_list", 15, parse_pool_list_uncached)
def list_iso_files() -> list[dict[str, str]]: def list_iso_files() -> list[dict[str, str]]:
ISO_DIR.mkdir(parents=True, exist_ok=True) ISO_DIR.mkdir(parents=True, exist_ok=True)
files = [] files = []
@@ -1293,8 +1338,7 @@ def system_summary() -> dict[str, str]:
def service_state(unit: str) -> str: def service_state(unit: str) -> str:
result = run_cmd(["systemctl", "is-active", unit]) return cached(f"svc:{unit}", 10, lambda: run_cmd(["systemctl", "is-active", unit])["stdout"] or "inactive")
return result["stdout"] or "inactive"
def network_summary() -> dict[str, str]: def network_summary() -> dict[str, str]:
@@ -2230,6 +2274,8 @@ def vm_snapshot_apply(request: Request, name: str, snap_action: str, snapshot_na
if snap_action not in ("create", "revert", "delete"): if snap_action not in ("create", "revert", "delete"):
return JSONResponse({"ok": False, "error": "Unsupported snapshot action"}, status_code=400) return JSONResponse({"ok": False, "error": "Unsupported snapshot action"}, status_code=400)
ok, message = snapshot_action(name, snap_action, snapshot_name.strip(), description.strip()) ok, message = snapshot_action(name, snap_action, snapshot_name.strip(), description.strip())
if ok:
cache_invalidate("vm_list")
param = "snap_message" if ok else "snap_error" param = "snap_message" if ok else "snap_error"
return RedirectResponse(url=f"/vm/{name}?{param}={message}", status_code=303) return RedirectResponse(url=f"/vm/{name}?{param}={message}", status_code=303)
@@ -2285,12 +2331,14 @@ def vm_action(request: Request, name: str, action: str, csrf_token: str = Form("
run_cmd(["virsh", "destroy", name], timeout=20) run_cmd(["virsh", "destroy", name], timeout=20)
run_cmd(["virsh", "undefine", name, "--remove-all-storage"], timeout=60) run_cmd(["virsh", "undefine", name, "--remove-all-storage"], timeout=60)
set_vm_template(name, False) set_vm_template(name, False)
cache_invalidate("vm_list")
return RedirectResponse(url="/", status_code=303) return RedirectResponse(url="/", status_code=303)
if action not in allowed: if action not in allowed:
return JSONResponse({"ok": False, "error": "Unsupported action"}, status_code=400) return JSONResponse({"ok": False, "error": "Unsupported action"}, status_code=400)
if action == "start" and is_vm_template(name): if action == "start" and is_vm_template(name):
return RedirectResponse(url=f"/vm/{name}?clone_error=VM помечена как шаблон — запуск заблокирован. Склонируй её или сними флаг шаблона", status_code=303) return RedirectResponse(url=f"/vm/{name}?clone_error=VM помечена как шаблон — запуск заблокирован. Склонируй её или сними флаг шаблона", status_code=303)
run_cmd(allowed[action], timeout=30) run_cmd(allowed[action], timeout=30)
cache_invalidate("vm_list")
return RedirectResponse(url=f"/vm/{name}", status_code=303) return RedirectResponse(url=f"/vm/{name}", status_code=303)
@@ -2311,6 +2359,18 @@ def live_status(request: Request):
return JSONResponse({"ok": True, "generated_at": utc_now(), "vms": vms, "services": {"libvirtd": service_state("libvirtd.service"), "virtlogd": service_state("virtlogd.service"), "cockpit": service_state("cockpit.socket"), "web": service_state("virtuality-web.service")}, "operations": list_operations(5)}) return JSONResponse({"ok": True, "generated_at": utc_now(), "vms": vms, "services": {"libvirtd": service_state("libvirtd.service"), "virtlogd": service_state("virtlogd.service"), "cockpit": service_state("cockpit.socket"), "web": service_state("virtuality-web.service")}, "operations": list_operations(5)})
@app.on_event("startup")
def start_metrics_collector():
metrics.start()
@app.get("/live/metrics")
def live_metrics(request: Request):
if not get_current_user(request):
return JSONResponse({"ok": False, "error": "Unauthorized"}, status_code=401)
return JSONResponse({"ok": True, **metrics.snapshot()})
@app.get("/live/operations") @app.get("/live/operations")
def live_operations(request: Request): def live_operations(request: Request):
if not get_current_user(request): if not get_current_user(request):
+115
Просмотреть файл
@@ -0,0 +1,115 @@
#!/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,
}
+2
Просмотреть файл
@@ -230,6 +230,8 @@ input[type="file"] { padding: 12px; }
.alert.danger { color: #fecaca; background: rgba(239,68,68,.10); border: 1px solid rgba(239,68,68,.28); } .alert.danger { color: #fecaca; background: rgba(239,68,68,.10); border: 1px solid rgba(239,68,68,.28); }
.alert.success { color: #bbf7d0; background: rgba(34,197,94,.10); border: 1px solid rgba(34,197,94,.26); } .alert.success { color: #bbf7d0; background: rgba(34,197,94,.10); border: 1px solid rgba(34,197,94,.26); }
.alert.warn { color: #fde68a; background: rgba(245,158,11,.10); border: 1px solid rgba(245,158,11,.28); } .alert.warn { color: #fde68a; background: rgba(245,158,11,.10); border: 1px solid rgba(245,158,11,.28); }
.sparkline { display: block; width: 100%; height: 60px; }
.sparkline polyline { stroke: var(--accent, #38bdf8); stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; }
.services, .quick-actions, .knowledge-list { display: grid; gap: 10px; } .services, .quick-actions, .knowledge-list { display: grid; gap: 10px; }
.service-row, .big-action, .knowledge-list div, .meta-grid div { .service-row, .big-action, .knowledge-list div, .meta-grid div {
+64
Просмотреть файл
@@ -54,6 +54,29 @@
</article> </article>
</section> </section>
<section class="grid two">
<article class="card">
<div class="card-head">
<h2>CPU хоста</h2>
<span class="pill" id="cpu-now"></span>
</div>
<svg id="cpu-spark" class="sparkline" viewBox="0 0 300 60" preserveAspectRatio="none" aria-label="График CPU за час">
<polyline points="" fill="none"></polyline>
</svg>
<p class="muted small-note">Загрузка процессора за последний час, обновляется каждые 15 секунд.</p>
</article>
<article class="card">
<div class="card-head">
<h2>Память хоста</h2>
<span class="pill" id="mem-now"></span>
</div>
<svg id="mem-spark" class="sparkline" viewBox="0 0 300 60" preserveAspectRatio="none" aria-label="График памяти за час">
<polyline points="" fill="none"></polyline>
</svg>
<p class="muted small-note">Использование RAM за последний час. Хвост графика заполняется по мере работы панели.</p>
</article>
</section>
<section class="card"> <section class="card">
<div class="card-head"> <div class="card-head">
<h2>Виртуальные машины</h2> <h2>Виртуальные машины</h2>
@@ -185,6 +208,47 @@
</section> </section>
</main> </main>
</div> </div>
<script>
(function () {
function drawSpark(svgId, values, max) {
const svg = document.getElementById(svgId);
if (!svg) return;
const line = svg.querySelector('polyline');
if (!values.length) { line.setAttribute('points', ''); return; }
const width = 300, height = 60, pad = 3;
const step = values.length > 1 ? (width - pad * 2) / (values.length - 1) : 0;
const points = values.map((value, index) => {
const x = pad + index * step;
const y = height - pad - Math.max(0, Math.min(1, value / max)) * (height - pad * 2);
return x.toFixed(1) + ',' + y.toFixed(1);
});
line.setAttribute('points', points.join(' '));
}
async function refreshMetrics() {
try {
const response = await fetch('/live/metrics', { cache: 'no-store' });
if (!response.ok) return;
const payload = await response.json();
if (!payload.ok || !payload.series) return;
const cpu = payload.series.map(sample => sample.cpu);
const mem = payload.series.map(sample => sample.mem_percent);
drawSpark('cpu-spark', cpu, 100);
drawSpark('mem-spark', mem, 100);
const current = payload.current;
if (current) {
const cpuNow = document.getElementById('cpu-now');
const memNow = document.getElementById('mem-now');
if (cpuNow) cpuNow.textContent = current.cpu.toFixed(1) + '% · load ' + current.load1.toFixed(2);
if (memNow) memNow.textContent = current.mem_used_mb + ' / ' + current.mem_total_mb + ' MB · ' + current.mem_percent.toFixed(0) + '%';
}
} catch (error) { /* сеть моргнула — попробуем в следующий цикл */ }
}
refreshMetrics();
setInterval(refreshMetrics, 15000);
})();
</script>
<script src="/static/panel.js" defer></script> <script src="/static/panel.js" defer></script>
</body> </body>
</html> </html>
+114
Просмотреть файл
@@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""Быстрый доступ к libvirt через python-байндинги.
Одно постоянное соединение вместо десятков subprocess-вызовов virsh на
каждую загрузку страницы — критично для Raspberry Pi и слабых VPS.
Если libvirt-python не установлен или libvirtd недоступен, модуль честно
сообщает об этом через available(), и приложение работает через virsh.
"""
import threading
from typing import Any
try:
import libvirt # python3-libvirt / libvirt-python
# Отключаем печать ошибок libvirt в stderr — ошибки обрабатываются кодом.
libvirt.registerErrorHandler(lambda ctx, err: None, None)
except ImportError:
libvirt = None
_CONN_LOCK = threading.Lock()
_conn = None
# Коды состояний libvirt → строки в стиле virsh, которые уже ждёт UI.
_STATE_NAMES = {
0: "no state",
1: "running",
2: "blocked",
3: "paused",
4: "in shutdown",
5: "shut off",
6: "crashed",
7: "pmsuspended",
}
def _connection():
global _conn
if libvirt is None:
return None
with _CONN_LOCK:
if _conn is not None:
try:
if _conn.isAlive():
return _conn
except Exception:
pass
try:
_conn.close()
except Exception:
pass
_conn = None
try:
_conn = libvirt.open("qemu:///system")
except Exception:
_conn = None
return _conn
def available() -> bool:
return _connection() is not None
def list_domains() -> list[dict[str, Any]] | None:
"""Список всех доменов с состоянием и автозапуском. None — backend недоступен."""
conn = _connection()
if conn is None:
return None
rows = []
try:
for dom in conn.listAllDomains(0):
try:
state_code = dom.state()[0]
dom_id = dom.ID()
rows.append({
"id": str(dom_id) if dom_id > 0 else "-",
"name": dom.name(),
"state": _STATE_NAMES.get(state_code, "unknown"),
"autostart_enabled": bool(dom.autostart()),
})
except Exception:
continue
except Exception:
return None
rows.sort(key=lambda row: row["name"])
return rows
def domain_stats() -> dict[str, dict[str, int]] | None:
"""cpu_time (нс) и память (KiB) работающих доменов для сбора метрик."""
conn = _connection()
if conn is None:
return None
stats: dict[str, dict[str, int]] = {}
try:
for dom in conn.listAllDomains(libvirt.VIR_CONNECT_LIST_DOMAINS_ACTIVE):
try:
info = dom.info() # [state, maxMem, memory, nrVirtCpu, cpuTime]
stats[dom.name()] = {"cpu_time_ns": int(info[4]), "memory_kb": int(info[2]), "max_memory_kb": int(info[1]), "vcpus": int(info[3])}
except Exception:
continue
except Exception:
return None
return stats
def node_memory_mb() -> dict[str, int] | None:
"""Память хоста по данным libvirt (fallback для метрик)."""
conn = _connection()
if conn is None:
return None
try:
info = conn.getInfo() # [model, memory_mb, cpus, ...]
return {"total_mb": int(info[1]), "cpus": int(info[2])}
except Exception:
return None