From 374bcbb29fd4bb529c95e9f2ed27da6f1775f57b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Aug 2026 19:23:14 +0000 Subject: [PATCH] =?UTF-8?q?=D0=9F=D1=80=D0=BE=D0=B8=D0=B7=D0=B2=D0=BE?= =?UTF-8?q?=D0=B4=D0=B8=D1=82=D0=B5=D0=BB=D1=8C=D0=BD=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D1=8C=20=D0=BF=D0=B0=D0=BD=D0=B5=D0=BB=D0=B8=20=D0=B1=D1=8B?= =?UTF-8?q?=D0=BB=D0=B0=20=D1=83=D0=BB=D1=83=D1=87=D1=88=D0=B5=D0=BD=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Добавлен virt_backend: одно постоянное libvirt-соединение вместо десятков вызовов virsh на каждую страницу, с автоматическим fallback на virsh, если libvirt-python недоступен. Обзорные запросы (список VM, пулы, сервисы) закешированы с коротким TTL и сбросом после действий. Появился фоновый сборщик метрик хоста и спарклайны CPU/RAM на дашборде. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PKUoZwHMCHnytfswFVRUHw --- scripts/install_web_panel.sh | 7 ++- web/app.py | 90 ++++++++++++++++++++++----- web/metrics.py | 115 +++++++++++++++++++++++++++++++++++ web/static/app.css | 2 + web/templates/dashboard.html | 64 +++++++++++++++++++ web/virt_backend.py | 114 ++++++++++++++++++++++++++++++++++ 6 files changed, 376 insertions(+), 16 deletions(-) create mode 100644 web/metrics.py create mode 100644 web/virt_backend.py diff --git a/scripts/install_web_panel.sh b/scripts/install_web_panel.sh index 83df4a8..3dd52aa 100644 --- a/scripts/install_web_panel.sh +++ b/scripts/install_web_panel.sh @@ -137,7 +137,7 @@ step "Обновляем apt cache" run_logged "apt update выполнен" apt update 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) ARM_PACKAGES=(qemu-system-arm qemu-efi-aarch64 virtinst libvirt-daemon-system libvirt-clients bridge-utils cloud-image-utils) PACKAGES=("${COMMON_PACKAGES[@]}") @@ -212,6 +212,11 @@ fi step "Устанавливаем Python-зависимости" run_logged "pip обновлён" "$VENV_DIR/bin/pip" install --upgrade pip 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" UVICORN_TLS_ARGS="" diff --git a/web/app.py b/web/app.py index 92e422c..31ef3a5 100644 --- a/web/app.py +++ b/web/app.py @@ -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("

Сессия устарела

Открой страницу заново и повтори действие.

", 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): diff --git a/web/metrics.py b/web/metrics.py new file mode 100644 index 0000000..7e01a9c --- /dev/null +++ b/web/metrics.py @@ -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, + } diff --git a/web/static/app.css b/web/static/app.css index d52308c..a1fda26 100644 --- a/web/static/app.css +++ b/web/static/app.css @@ -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.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); } +.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; } .service-row, .big-action, .knowledge-list div, .meta-grid div { diff --git a/web/templates/dashboard.html b/web/templates/dashboard.html index 8c1e0e6..782be3d 100644 --- a/web/templates/dashboard.html +++ b/web/templates/dashboard.html @@ -54,6 +54,29 @@ +
+
+
+

CPU хоста

+ +
+ + + +

Загрузка процессора за последний час, обновляется каждые 15 секунд.

+
+
+
+

Память хоста

+ +
+ + + +

Использование RAM за последний час. Хвост графика заполняется по мере работы панели.

+
+
+

Виртуальные машины

@@ -185,6 +208,47 @@
+ diff --git a/web/virt_backend.py b/web/virt_backend.py new file mode 100644 index 0000000..3cdfa19 --- /dev/null +++ b/web/virt_backend.py @@ -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