Производительность панели была улучшена
Добавлен 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
|
||||
|
||||
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=""
|
||||
|
||||
+65
-5
@@ -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,8 +512,15 @@ def parse_virsh_list() -> list[dict[str, str]]:
|
||||
pass
|
||||
return "—"
|
||||
|
||||
result = run_cmd(["virsh", "list", "--all"])
|
||||
rows = []
|
||||
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:]:
|
||||
@@ -500,7 +537,11 @@ def parse_virsh_list() -> list[dict[str, str]]:
|
||||
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):
|
||||
|
||||
@@ -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.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 {
|
||||
|
||||
@@ -54,6 +54,29 @@
|
||||
</article>
|
||||
</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">
|
||||
<div class="card-head">
|
||||
<h2>Виртуальные машины</h2>
|
||||
@@ -185,6 +208,47 @@
|
||||
</section>
|
||||
</main>
|
||||
</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>
|
||||
</body>
|
||||
</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