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

Добавлен 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 удалений
+75 -15
Просмотреть файл
@@ -39,8 +39,10 @@ from itsdangerous import BadSignature, URLSafeSerializer
import cloud_images
import host_profile
import metrics
import network_core
import update_core
import virt_backend
from network_core import NetworkError
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)
# 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]:
try:
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, "Команда завершилась успешно.")
if refresh_pool:
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())
else:
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}
def parse_virsh_list() -> list[dict[str, str]]:
def parse_virsh_list_uncached() -> list[dict[str, str]]:
def clean_ip(ip: str) -> str:
ip = (ip or "").split("/")[0].strip()
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
return ""
result = run_cmd(["virsh", "list", "--all"])
rows = []
if not result["ok"]:
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"]})
backend_rows = virt_backend.list_domains()
if backend_rows is not None:
# Быстрый путь: одно libvirt-соединение вместо virsh + dominfo на каждую VM.
for dom in backend_rows:
enabled = dom["autostart_enabled"]
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"})
else:
result = run_cmd(["virsh", "list", "--all"])
if not result["ok"]:
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:
row["ip"] = resolve_ip(row.get("name", ""))
row["manual_ip"] = manual_ips.get(row.get("name", ""), "")
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"])
rows = []
if not result["ok"]:
@@ -512,6 +553,10 @@ def parse_pool_list() -> list[dict[str, str]]:
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]]:
ISO_DIR.mkdir(parents=True, exist_ok=True)
files = []
@@ -1293,8 +1338,7 @@ def system_summary() -> dict[str, str]:
def service_state(unit: str) -> str:
result = run_cmd(["systemctl", "is-active", unit])
return result["stdout"] or "inactive"
return cached(f"svc:{unit}", 10, lambda: run_cmd(["systemctl", "is-active", unit])["stdout"] or "inactive")
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"):
return JSONResponse({"ok": False, "error": "Unsupported snapshot action"}, status_code=400)
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"
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", "undefine", name, "--remove-all-storage"], timeout=60)
set_vm_template(name, False)
cache_invalidate("vm_list")
return RedirectResponse(url="/", status_code=303)
if action not in allowed:
return JSONResponse({"ok": False, "error": "Unsupported action"}, status_code=400)
if action == "start" and is_vm_template(name):
return RedirectResponse(url=f"/vm/{name}?clone_error=VM помечена как шаблон — запуск заблокирован. Склонируй её или сними флаг шаблона", status_code=303)
run_cmd(allowed[action], timeout=30)
cache_invalidate("vm_list")
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)})
@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")
def live_operations(request: Request):
if not get_current_user(request):