Производительность панели была улучшена
Добавлен 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
Этот коммит содержится в:
@@ -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):
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
Ссылка в новой задаче
Block a user