diff --git a/tests/conftest.py b/tests/conftest.py
index 6939649..667dba2 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -43,6 +43,9 @@ QEMU_IMG_INFO = (FIXTURES / "qemu-img-info.json").read_text()
# Снимки web01: имя → (состояние, описание); список берётся из fixtures/snapshot-list.txt.
SNAPSHOTS = {"before-upd": ("shutoff", "Перед обновлением ядра"), "clean": ("running", "Чистая система после установки")}
+# Заметки машин для `virsh desc`: имя → (название, описание).
+NOTES = {"web01": ("Сайт компании", "Nginx + база. Бэкап по пятницам.")}
+
def fake_result(stdout: str = "", ok: bool = True, stderr: str = "") -> dict:
return {"ok": ok, "code": 0 if ok else 1, "stdout": stdout, "stderr": stderr, "cmd": ""}
@@ -70,19 +73,31 @@ def fake_run_cmd(cmd, timeout=12, **_kwargs):
if cmd[:2] in (["virsh", "snapshot-create-as"], ["virsh", "snapshot-revert"], ["virsh", "snapshot-delete"]):
return fake_result("Domain snapshot %s created" % option(cmd, "--name"))
if cmd[:2] == ["virsh", "list"]:
+ if "--title" in cmd:
+ return fake_result((FIXTURES / "list-title.txt").read_text())
if "--name" in cmd:
- return fake_result("web01\ndb01\n")
+ return fake_result("web01\ndb01\n" if "--all" in cmd else "web01\n")
return fake_result(" Id Name State\n-----------------------\n 1 web01 running\n - db01 shut off")
if cmd[:2] == ["virsh", "dominfo"]:
return fake_result(DOMINFO) if cmd[-1] in ("web01", "db01") else fake_result(ok=False, stderr="failed to get domain")
if cmd[:2] == ["virsh", "dumpxml"]:
return fake_result(DUMPXML_MIGRATABLE if "--migratable" in cmd else DUMPXML)
- if cmd[:2] == ["virsh", "domblklist"]:
- return fake_result(DOMBLKLIST_DETAILS)
- if cmd[:2] == ["qemu-img", "info"]:
- return fake_result(QEMU_IMG_INFO)
if cmd[:2] == ["virsh", "domstate"]:
return fake_result("running")
+ if cmd[:2] == ["virsh", "domblklist"]:
+ import core # диск лежит в подменённом data_dirs хранилище — иначе панель откажется его увеличивать
+
+ return fake_result((FIXTURES / "domblklist-details.txt").read_text().replace("/var/lib/virtuality/images", str(core.IMAGES_DIR)))
+ if cmd[:2] == ["virsh", "domstats"]:
+ return fake_result((FIXTURES / "domstats.txt").read_text())
+ if cmd[:1] == ["virsh"] and "desc" in cmd[:3]:
+ if any(arg.startswith("--new-desc") for arg in cmd):
+ return fake_result("Domain description updated successfully")
+ vm = cmd[cmd.index("desc") + 1]
+ title, description = NOTES.get(vm, ("", ""))
+ return fake_result(title if "--title" in cmd else description)
+ if cmd[:2] == ["qemu-img", "info"]:
+ return fake_result(QEMU_IMG_INFO)
if cmd[:2] == ["virsh", "domifaddr"]:
return fake_result(" Name MAC address Protocol Address\n vnet0 52:54:00:aa:bb:cc ipv4 192.168.100.51/24")
if cmd[:2] == ["virsh", "domiflist"]:
@@ -141,6 +156,7 @@ def data_dirs(tmp_path, monkeypatch):
monkeypatch.setattr(module, "OPERATIONS_DIR", dirs["operations"])
monkeypatch.setattr(core, "BACKUPS_DIR", dirs["backups"])
monkeypatch.setattr(core, "CONFIG_DIR", dirs["config"])
+ monkeypatch.setattr(core, "STORAGE_DIR", tmp_path)
monkeypatch.setattr(network_core, "CONFIG_DIR", dirs["config"])
monkeypatch.setattr(network_core, "NETWORK_DIR", dirs["network"])
monkeypatch.setattr(network_core, "NFT_DIR", dirs["nft"])
diff --git a/tests/dev_server.py b/tests/dev_server.py
index 77d373a..55dd9c6 100644
--- a/tests/dev_server.py
+++ b/tests/dev_server.py
@@ -7,6 +7,7 @@ import json
import os
import sys
import tempfile
+import time
import uuid
from pathlib import Path
@@ -19,6 +20,64 @@ import host_profile # noqa: E402
import network_core # noqa: E402
import update_core # noqa: E402
+# Машины демо-сервера: имя → состояние virsh (web01 работает, db01 выключена, win11 на паузе).
+DEMO_VMS = {"web01": "running", "db01": "shut off", "win11": "paused"}
+DEMO_NOTES = {"web01": ("Сайт компании", "Nginx + база. Бэкап по пятницам.\nЗа сервер отвечает Иван."), "win11": ("Бухгалтерия 1С", "")}
+DEMO_DOMSTATS = """Domain: 'web01'
+ cpu.time={cpu_web}
+ balloon.current=2097152
+ balloon.maximum=2097152
+ balloon.rss=1534208
+ vcpu.current=2
+ vcpu.maximum=2
+
+Domain: 'win11'
+ cpu.time={cpu_win}
+ balloon.current=4194304
+ balloon.maximum=4194304
+ balloon.rss=3102720
+ vcpu.current=4
+ vcpu.maximum=4
+
+"""
+_cpu_clock = {"web": 48_123_456_789, "win": 90_000_000_000, "at": time.monotonic()}
+
+
+def demo_vm_run_cmd(cmd, timeout=12, **kwargs):
+ """Три машины в разных состояниях и живая нагрузка; остальное — conftest.fake_run_cmd."""
+ if cmd[:2] == ["virsh", "list"]:
+ if "--title" in cmd:
+ rows = "\n".join(f" {'1' if state == 'running' else '2' if state == 'paused' else '-':<4} {name:<7} {state:<10} {DEMO_NOTES.get(name, ('', ''))[0]}" for name, state in DEMO_VMS.items())
+ return conftest.fake_result(" Id Name State Title\n" + "-" * 44 + "\n" + rows + "\n")
+ if "--name" in cmd:
+ names = [name for name, state in DEMO_VMS.items() if "--all" in cmd or state != "shut off"]
+ return conftest.fake_result("\n".join(names) + "\n")
+ rows = "\n".join(f" {'1' if state == 'running' else '2' if state == 'paused' else '-':<4} {name:<7} {state}" for name, state in DEMO_VMS.items())
+ return conftest.fake_result(" Id Name State\n" + "-" * 25 + "\n" + rows)
+ if cmd[:2] in (["virsh", "dominfo"], ["virsh", "domstate"]):
+ name = cmd[-1]
+ if name not in DEMO_VMS:
+ return conftest.fake_result(ok=False, stderr=f"error: failed to get domain '{name}'")
+ if cmd[1] == "domstate":
+ return conftest.fake_result(DEMO_VMS[name])
+ info = conftest.DOMINFO.replace("web01", name).replace("State: running", f"State: {DEMO_VMS[name]}")
+ if name == "db01":
+ info += "Managed save: yes\n"
+ return conftest.fake_result(info)
+ if cmd[:2] == ["virsh", "domstats"]:
+ # Процессорное время растёт вместе с реальным: web01 нагружена на ~37 % (2 ядра), win11 на ~8 % (4 ядра).
+ now = time.monotonic()
+ elapsed_ns = (now - _cpu_clock["at"]) * 1e9
+ _cpu_clock.update(web=_cpu_clock["web"] + int(elapsed_ns * 2 * 0.37), win=_cpu_clock["win"] + int(elapsed_ns * 4 * 0.08), at=now)
+ return conftest.fake_result(DEMO_DOMSTATS.format(cpu_web=_cpu_clock["web"], cpu_win=_cpu_clock["win"]))
+ if cmd[:2] == ["virsh", "domblklist"]:
+ return conftest.fake_result((conftest.FIXTURES / "domblklist-details.txt").read_text().replace("/var/lib/virtuality/images/web01", str(core.IMAGES_DIR / cmd[2])))
+ if cmd[:1] == ["virsh"] and "desc" in cmd[:3] and not any(arg.startswith("--new-desc") for arg in cmd):
+ title, description = DEMO_NOTES.get(cmd[cmd.index("desc") + 1], ("", ""))
+ return conftest.fake_result(title if "--title" in cmd else description)
+ return conftest.fake_run_cmd(cmd, timeout, **kwargs)
+
+
# Снимки для демо: имя, дата, состояние, описание.
DEMO_SNAPSHOTS = [
("snap-20260925-1012", "2026-09-25 10:12:03 +0000", "shutoff", "Перед обновлением ядра"),
@@ -43,7 +102,7 @@ def demo_run_cmd(cmd, timeout=12, **kwargs):
state, description = next(((s, d) for n, _, s, d in DEMO_SNAPSHOTS if n == snap), ("shutoff", ""))
memory = "internal" if state == "running" else "no"
return conftest.fake_result(f"{snap} {description} {state} ")
- return conftest.fake_run_cmd(cmd, timeout, **kwargs)
+ return demo_vm_run_cmd(cmd, timeout, **kwargs)
def seed_backups(backups_dir: Path) -> None:
@@ -71,9 +130,12 @@ def seed(root: Path) -> None:
for path in dirs.values():
path.mkdir(parents=True, exist_ok=True)
app.ISO_DIR, app.IMAGES_DIR, app.DISK_IMAGES_DIR, app.OPERATIONS_DIR = dirs["iso"], dirs["images"], dirs["disk-images"], dirs["operations"]
- core.ISO_DIR, core.IMAGES_DIR, core.DISK_IMAGES_DIR, core.OPERATIONS_DIR = dirs["iso"], dirs["images"], dirs["disk-images"], dirs["operations"]
+ core.ISO_DIR, core.IMAGES_DIR, core.DISK_IMAGES_DIR, core.OPERATIONS_DIR, core.STORAGE_DIR = dirs["iso"], dirs["images"], dirs["disk-images"], dirs["operations"], root
core.BACKUPS_DIR, core.CONFIG_DIR = dirs["backups"], dirs["config"]
seed_backups(dirs["backups"])
+ for name in DEMO_VMS: # диски машин лежат в хранилище, иначе форма увеличения не появится
+ with (dirs["images"] / f"{name}.qcow2").open("wb") as handle:
+ handle.truncate(4 * 1024 * 1024)
network_core.CONFIG_DIR, network_core.NETWORK_DIR, network_core.NFT_DIR = dirs["config"], dirs["network"], dirs["nft"]
network_core.PORT_FORWARDS_FILE = dirs["network"] / "port_forwards.json"
network_core.UFW_STATE_FILE = dirs["network"] / "ufw_rules.json"
@@ -142,6 +204,12 @@ def seed(root: Path) -> None:
op = core.new_operation("download", title, download_kind=kind, url=url, filename=filename, target_path=str(target), page="/iso" if kind == "iso" else "/disk-images", catalog_id=catalog_id, total_bytes=total, downloaded_bytes=done, cancel_requested=False)
core.update_operation(op, status="running", progress=progress, message=message, started_at=core.utc_now())
core.append_operation_log(op["id"], f"Ссылка: {url}")
+ # Завершённое клонирование web01 → web01-copy: видно на обзоре и на странице /operations/{id}.
+ clone_op = core.new_operation("vm_clone", "Клонирование web01 → web01-copy", vm_name="web01-copy", source_vm="web01", targets=[str(dirs["images"] / "web01-copy.qcow2")], expected_bytes=6443499520)
+ core.append_operation_log(clone_op["id"], "virt-clone --original web01 --name web01-copy --file /var/lib/virtuality/images/web01-copy.qcow2")
+ core.append_operation_log(clone_op["id"], "Allocating 'web01-copy.qcow2' | 6.0 GB 00:01:12")
+ core.update_operation(clone_op, cmd="virt-clone --original web01 --name web01-copy --file " + str(dirs["images"] / "web01-copy.qcow2"))
+ core.finish_operation(clone_op, True, "Машина web01-copy создана — это копия web01. Запустите её и смените имя компьютера внутри системы.")
if __name__ == "__main__":
diff --git a/tests/fixtures/list-title.txt b/tests/fixtures/list-title.txt
new file mode 100644
index 0000000..7593822
--- /dev/null
+++ b/tests/fixtures/list-title.txt
@@ -0,0 +1,6 @@
+ Id Name State Title
+--------------------------------------------
+ 1 web01 running Сайт компании
+ - db01 shut off
+ - win11 shut off Бухгалтерия 1С
+
diff --git a/tests/test_backups.py b/tests/test_backups.py
index 3148727..cf78e62 100644
--- a/tests/test_backups.py
+++ b/tests/test_backups.py
@@ -98,7 +98,7 @@ def test_parse_qemu_img_info_fixture():
def test_vm_disks_skips_cdrom(data_dirs):
disks = backups.vm_disks("web01")
- assert [(d["target"], d["source"]) for d in disks] == [("vda", "/var/lib/virtuality/images/web01.qcow2")]
+ assert [(d["target"], d["source"]) for d in disks] == [("vda", str(core.IMAGES_DIR / "web01.qcow2"))]
assert DOMBLKLIST_DETAILS.count("cdrom") == 1
@@ -193,7 +193,7 @@ def test_create_backup_shuts_down_copies_and_restarts(fast_worker, monkeypatch):
assert ["virsh", "dumpxml", "web01", "--migratable"] in virsh.calls
assert virsh.calls[-1] == ["virsh", "start", "web01"]
assert ["virsh", "destroy", "web01"] not in virsh.calls
- assert stream.calls == [["qemu-img", "convert", "-p", "-O", "qcow2", "-c", "/var/lib/virtuality/images/web01.qcow2", str(fast_worker["backups"] / "web01" / operation["backup_id"] / "vda.qcow2")]]
+ assert stream.calls == [["qemu-img", "convert", "-p", "-O", "qcow2", "-c", str(core.IMAGES_DIR / "web01.qcow2"), str(fast_worker["backups"] / "web01" / operation["backup_id"] / "vda.qcow2")]]
path = fast_worker["backups"] / "web01" / operation["backup_id"]
meta = json.loads((path / "meta.json").read_text())
assert meta["vm"] == "web01" and meta["note"] == "ночная копия" and meta["disks"][0]["target"] == "vda"
diff --git a/tests/test_vmops.py b/tests/test_vmops.py
new file mode 100644
index 0000000..5d3dd32
--- /dev/null
+++ b/tests/test_vmops.py
@@ -0,0 +1,437 @@
+"""vmops: увеличение диска, клонирование, пауза и спящий режим, заметки, живая нагрузка."""
+import threading
+from pathlib import Path
+from urllib.parse import unquote
+
+import pytest
+
+import conftest
+import core
+import presenters
+from features import vmops
+
+FIXTURES = Path(__file__).resolve().parent / "fixtures"
+DOMSTATS = (FIXTURES / "domstats.txt").read_text()
+GIB = 1024 ** 3
+
+
+class Recorder:
+ """Записывает команды; отвечает по overrides {(префикс,): результат|функция}, иначе как fake_run_cmd."""
+
+ def __init__(self, overrides=None):
+ self.calls: list[list[str]] = []
+ self.overrides = overrides or {}
+
+ def __call__(self, cmd, timeout=12, **kwargs):
+ self.calls.append(list(cmd))
+ for prefix, result in self.overrides.items():
+ if tuple(cmd[: len(prefix)]) == prefix:
+ return result(cmd) if callable(result) else result
+ return conftest.fake_run_cmd(cmd, timeout, **kwargs)
+
+ def find(self, *prefix):
+ return [call for call in self.calls if tuple(call[: len(prefix)]) == prefix]
+
+
+@pytest.fixture()
+def record(data_dirs, monkeypatch):
+ """Подменяет run_cmd и в core (vmops), и в app (обзор, страница машины)."""
+ import app
+
+ recorder = Recorder()
+ for module in (core, app):
+ monkeypatch.setattr(module, "run_cmd", recorder)
+ monkeypatch.setattr(vmops, "_samples", {})
+ return recorder
+
+
+@pytest.fixture()
+def inline_operations(monkeypatch):
+ """Фоновые операции выполняются сразу, в этом же потоке."""
+
+ def run_now(operation, worker):
+ core.update_operation(operation, status="running")
+ worker(operation)
+
+ monkeypatch.setattr(vmops, "run_operation", run_now)
+
+
+def shut_off(record):
+ record.overrides[("virsh", "domstate")] = conftest.fake_result("shut off")
+
+
+def location(response) -> str:
+ assert response.status_code == 303, response.text[:300]
+ return unquote(response.headers["location"])
+
+
+# ---------------------------------------------------------------- parsers
+def test_parse_domstats_fixture():
+ stats = vmops.parse_domstats(DOMSTATS)
+ assert list(stats) == ["web01"]
+ assert stats["web01"]["cpu.time"] == "48123456789"
+ assert stats["web01"]["balloon.rss"] == "1534208"
+ assert stats["web01"]["vcpu.current"] == "2"
+
+
+def test_parse_domstats_several_domains():
+ text = DOMSTATS + "Domain: 'db01'\n\nDomain: 'win 11'\n cpu.time=5\n vcpu.current=4\n"
+ stats = vmops.parse_domstats(text)
+ assert set(stats) == {"web01", "db01", "win 11"}
+ assert stats["db01"] == {}
+ assert stats["win 11"] == {"cpu.time": "5", "vcpu.current": "4"}
+
+
+def test_compute_stats_cpu_percent_from_two_samples():
+ samples: dict = {}
+ first = vmops.compute_stats(vmops.parse_domstats(DOMSTATS), 100.0, samples)
+ assert first["web01"]["cpu_pct"] is None # первый замер — сравнивать не с чем
+ assert first["web01"]["mem_used_mb"] == 1498 and first["web01"]["mem_total_mb"] == 2048
+ later = DOMSTATS.replace("cpu.time=48123456789", "cpu.time=49123456789") # +1 с процессорного времени
+ second = vmops.compute_stats(vmops.parse_domstats(later), 101.0, samples)
+ assert second["web01"]["cpu_pct"] == 50.0 # 1 с на 2 ядра за 1 с стены
+ burst = DOMSTATS.replace("cpu.time=48123456789", "cpu.time=99123456789")
+ third = vmops.compute_stats(vmops.parse_domstats(burst), 101.5, samples)
+ assert third["web01"]["cpu_pct"] == 100.0 # не больше 100 %
+
+
+def test_compute_stats_skips_shut_off_and_forgets_stale():
+ samples = {"old": (1.0, 5)}
+ stats = vmops.compute_stats({"db01": {}, "web01": {"cpu.time": "10", "balloon.current": "1024", "vcpu.current": "1"}}, 2.0, samples)
+ assert set(stats) == {"web01"}
+ assert stats["web01"]["mem_used_mb"] == 1 and stats["web01"]["mem_total_mb"] == 1
+ assert set(samples) == {"web01"}
+
+
+def test_parse_qemu_img_info():
+ info = vmops.parse_qemu_img_info((FIXTURES / "qemu-img-info.json").read_text())
+ assert info == {"virtual_size": 42949672960, "actual_size": 6443499520, "format": "qcow2"}
+ assert vmops.parse_qemu_img_info("not json") == {}
+ assert vmops.parse_qemu_img_info("[1, 2]") == {}
+
+
+def test_parse_virsh_list_titles():
+ titles = presenters.parse_virsh_list_titles((FIXTURES / "list-title.txt").read_text())
+ assert titles == {"web01": "Сайт компании", "win11": "Бухгалтерия 1С"}
+ assert presenters.parse_virsh_list_titles(" Id Name State\n---\n 1 web01 running") == {}
+ assert presenters.parse_virsh_list_titles("") == {}
+
+
+def test_parse_desc_output():
+ assert vmops.parse_desc_output(conftest.fake_result("Сайт компании\n")) == "Сайт компании"
+ assert vmops.parse_desc_output(conftest.fake_result("No title for domain: web01")) == ""
+ assert vmops.parse_desc_output(conftest.fake_result(ok=False, stderr="error: failed to get domain")) == ""
+
+
+def test_has_managed_save():
+ assert vmops.has_managed_save(conftest.DOMINFO + "Managed save: yes\n")
+ assert not vmops.has_managed_save(conftest.DOMINFO + "Managed save: no\n")
+ assert not vmops.has_managed_save(conftest.DOMINFO)
+
+
+def test_clean_notes():
+ assert vmops.clean_title(" Сайт \n компании ") == "Сайт компании"
+ assert len(vmops.clean_title("x" * 200)) == vmops.TITLE_MAX
+ assert vmops.clean_description("строка 1\r\nстрока 2 \r\n\r\n") == "строка 1\nстрока 2"
+
+
+def test_clone_names_and_targets(data_dirs):
+ assert vmops.suggest_clone_name("web01", {"web01"}) == "web01-copy"
+ assert vmops.suggest_clone_name("web01", {"web01", "web01-copy", "web01-copy2"}) == "web01-copy3"
+ disks = [
+ {"device": "disk", "source": "/var/lib/virtuality/images/web01.qcow2"},
+ {"device": "cdrom", "source": ""},
+ {"device": "disk", "source": "/var/lib/virtuality/images/data.img"},
+ ]
+ targets = vmops.clone_targets("web02", disks)
+ assert targets == [data_dirs["images"] / "web02.qcow2", data_dirs["images"] / "web02-disk2.img"]
+
+
+def test_explain_error():
+ assert "снимки" in vmops.explain_error("qemu-img: Can't resize an image which has snapshots", "x")
+ assert vmops.explain_error("", "запасной текст") == "запасной текст"
+
+
+# ---------------------------------------------------------------- live stats
+def test_live_stats_requires_auth(client):
+ assert client.get("/live/stats").status_code == 401
+
+
+def test_live_stats_json(logged_in, record):
+ first = logged_in.get("/live/stats").json()
+ assert first["ok"] is True
+ assert first["stats"]["web01"]["cpu_pct"] is None
+ assert first["stats"]["web01"]["mem_used_mb"] == 1498 and first["stats"]["web01"]["mem_total_mb"] == 2048
+ second = logged_in.get("/live/stats").json()
+ assert second["stats"]["web01"]["cpu_pct"] == 0.0 # cpu.time в подделке не растёт
+ assert record.find("virsh", "domstats") == [["virsh", "domstats", "--cpu-total", "--balloon", "--vcpu", "web01"]] * 2 # только активные машины, один вызов на запрос
+
+
+def test_live_stats_without_active_vms(logged_in, record):
+ record.overrides[("virsh", "list")] = conftest.fake_result("")
+ assert logged_in.get("/live/stats").json()["stats"] == {}
+ assert record.find("virsh", "domstats") == []
+
+
+# ---------------------------------------------------------------- pages
+def test_dashboard_shows_title_and_meters(logged_in, record):
+ html = logged_in.get("/").text
+ assert "Сайт компании" in html
+ assert 'data-live-stats="web01"' in html and 'data-live-stats="db01"' in html
+ assert "/vm/web01/ops/pause" in html and "/vm/web01/ops/hibernate" in html
+ assert "/vm/web01/clone" in html and "/vm/db01/clone" in html
+ assert record.find("virsh", "list", "--all", "--title")
+
+
+def test_dashboard_paused_vm_offers_resume(logged_in, record):
+ record.overrides[("virsh", "list")] = conftest.fake_result(" Id Name State\n-----------------------\n 2 web01 paused\n - db01 shut off")
+ html = logged_in.get("/").text
+ assert 'action="/vm/web01/ops/resume"' in html
+ assert 'action="/vm/web01/start"' not in html
+ assert 'action="/vm/db01/start"' in html
+
+
+def test_vm_page_shows_disks_notes_and_ops(logged_in, record):
+ html = logged_in.get("/vm/web01").text
+ assert "40 ГБ" in html and "занято 6.0 ГБ" in html
+ assert 'action="/vm/web01/disks/vda/resize"' in html and 'min="41"' in html
+ assert 'action="/vm/web01/disks/sda/resize"' not in html # привод не увеличивают
+ assert 'value="Сайт компании"' in html and "Nginx + база. Бэкап по пятницам." in html
+ assert "Приостановить" in html and "Спящий режим" in html and "Клонировать" in html
+ assert 'data-live-stats="web01"' in html # карточка «Нагрузка сейчас»
+ assert record.find("qemu-img", "info") == [["qemu-img", "info", "--output=json", "-U", str(core.IMAGES_DIR / "web01.qcow2")]]
+
+
+def test_vm_page_paused_and_saved_states(logged_in, record):
+ record.overrides[("virsh", "dominfo")] = conftest.fake_result(conftest.DOMINFO.replace("State: running", "State: paused"))
+ html = logged_in.get("/vm/web01").text
+ assert 'action="/vm/web01/ops/resume"' in html and "Продолжить" in html
+ record.overrides[("virsh", "dominfo")] = conftest.fake_result(conftest.DOMINFO.replace("State: running", "State: shut off") + "Managed save: yes\n")
+ html = logged_in.get("/vm/web01").text
+ assert "Машина в спящем режиме" in html and 'action="/vm/web01/ops/forget-save"' in html
+ assert 'data-live-stats="web01"' not in html # выключенная машина — нагрузки нет
+
+
+def test_vm_page_messages(logged_in, record):
+ html = logged_in.get("/vm/web01?disk_message=Диск+увеличен¬es_error=Не+вышло").text
+ assert "Диск увеличен" in html and "Не вышло" in html
+
+
+# ---------------------------------------------------------------- disk resize
+def test_disk_resize_shut_off_uses_qemu_img(logged_in, record):
+ shut_off(record)
+ response = logged_in.post("/vm/web01/disks/vda/resize", data={"size_gb": "50"}, follow_redirects=False)
+ assert location(response).startswith("/vm/web01?disk_message=Диск web01.qcow2 увеличен до 50 ГБ")
+ assert location(response).endswith("#storage")
+ assert record.find("qemu-img", "resize") == [["qemu-img", "resize", str(core.IMAGES_DIR / "web01.qcow2"), "50G"]]
+ assert record.find("virsh", "blockresize") == []
+
+
+def test_disk_resize_running_uses_blockresize(logged_in, record):
+ response = logged_in.post("/vm/web01/disks/vda/resize", data={"size_gb": "41"}, follow_redirects=False)
+ assert "disk_message" in location(response)
+ assert record.find("virsh", "blockresize") == [["virsh", "blockresize", "web01", "vda", "41G"]]
+ assert record.find("qemu-img", "resize") == []
+
+
+def test_disk_resize_only_grows(logged_in, record):
+ response = logged_in.post("/vm/web01/disks/vda/resize", data={"size_gb": "40"}, follow_redirects=False)
+ assert "не меньше 41 ГБ" in location(response)
+ response = logged_in.post("/vm/web01/disks/vda/resize", data={"size_gb": "999999"}, follow_redirects=False)
+ assert "Слишком большой" in location(response)
+ assert record.find("virsh", "blockresize") == [] and record.find("qemu-img", "resize") == []
+
+
+def test_disk_resize_rejects_unknown_or_cdrom(logged_in, record):
+ assert "Такого диска" in location(logged_in.post("/vm/web01/disks/vdz/resize", data={"size_gb": "50"}, follow_redirects=False))
+ assert "нельзя увеличить" in location(logged_in.post("/vm/web01/disks/sda/resize", data={"size_gb": "50"}, follow_redirects=False))
+ assert logged_in.post("/vm/web01/disks/../etc/resize", data={"size_gb": "50"}, follow_redirects=False).status_code in (400, 404)
+ assert logged_in.post("/vm/-bad/disks/vda/resize", data={"size_gb": "50"}, follow_redirects=False).status_code == 400
+
+
+def test_disk_resize_outside_storage_refused(logged_in, record):
+ record.overrides[("virsh", "domblklist")] = conftest.fake_result((FIXTURES / "domblklist-details.txt").read_text().replace("/var/lib/virtuality/images", "/root"))
+ assert "нельзя увеличить" in location(logged_in.post("/vm/web01/disks/vda/resize", data={"size_gb": "50"}, follow_redirects=False))
+
+
+def test_disk_resize_checks_free_space(logged_in, record, monkeypatch):
+ monkeypatch.setattr(vmops, "free_space", lambda path: 2 * GIB)
+ response = logged_in.post("/vm/web01/disks/vda/resize", data={"size_gb": "50"}, follow_redirects=False)
+ assert "свободно только 2.0 ГБ" in location(response)
+ assert record.find("virsh", "blockresize") == []
+
+
+def test_disk_resize_reports_tool_error(logged_in, record):
+ record.overrides[("virsh", "blockresize")] = conftest.fake_result(ok=False, stderr="error: internal error: Can't resize an image which has snapshots")
+ assert "удалите снимки" in location(logged_in.post("/vm/web01/disks/vda/resize", data={"size_gb": "50"}, follow_redirects=False))
+
+
+# ---------------------------------------------------------------- pause / resume / hibernate
+def test_ops_pause_and_resume(logged_in, record):
+ response = logged_in.post("/vm/web01/ops/pause", data={"next": "/"}, follow_redirects=False)
+ assert location(response).startswith("/?ops_message=Машина web01 приостановлена")
+ assert record.find("virsh", "suspend") == [["virsh", "suspend", "web01"]]
+ response = logged_in.post("/vm/web01/ops/resume", follow_redirects=False)
+ assert "ops_error=Продолжить можно только приостановленную" in location(response)
+ record.overrides[("virsh", "domstate")] = conftest.fake_result("paused")
+ response = logged_in.post("/vm/web01/ops/resume", data={"next": "http://evil.example/"}, follow_redirects=False)
+ assert location(response).startswith("/vm/web01?ops_message=") # чужой next не используется
+ assert record.find("virsh", "resume") == [["virsh", "resume", "web01"]]
+
+
+def test_ops_validation(logged_in, record):
+ assert logged_in.post("/vm/web01/ops/explode", follow_redirects=False).status_code == 400
+ assert logged_in.post("/vm/-bad/ops/pause", follow_redirects=False).status_code == 400
+ assert location(logged_in.post("/vm/ghost/ops/pause", follow_redirects=False)) == "/"
+ shut_off(record)
+ assert "ops_error=Приостановить можно только работающую" in location(logged_in.post("/vm/web01/ops/pause", follow_redirects=False))
+ record.overrides[("virsh", "suspend")] = conftest.fake_result(ok=False, stderr="error: Timed out during operation: cannot acquire state change lock")
+ record.overrides[("virsh", "domstate")] = conftest.fake_result("running")
+ assert "занята другой операцией" in location(logged_in.post("/vm/web01/ops/pause", follow_redirects=False))
+
+
+def test_ops_requires_login(client):
+ assert client.post("/vm/web01/ops/pause", follow_redirects=False).headers["location"] == "/login"
+
+
+def test_hibernate_runs_operation(logged_in, record, inline_operations, data_dirs):
+ response = logged_in.post("/vm/web01/ops/hibernate", data={"next": "/"}, follow_redirects=False)
+ assert location(response).startswith("/?ops_message=Машина web01 засыпает")
+ assert record.find("virsh", "managedsave") == [["virsh", "managedsave", "web01"]]
+ operations = core.list_operations(5)
+ assert operations[0]["type"] == "vm_hibernate" and operations[0]["status"] == "success" and operations[0]["vm_name"] == "web01"
+
+
+def test_hibernate_blocked(logged_in, record, monkeypatch):
+ shut_off(record)
+ assert "ops_error=В спящий режим можно" in location(logged_in.post("/vm/web01/ops/hibernate", follow_redirects=False))
+ record.overrides[("virsh", "domstate")] = conftest.fake_result("running")
+ monkeypatch.setattr(vmops, "free_space", lambda path: 0)
+ assert "свободного места" in location(logged_in.post("/vm/web01/ops/hibernate", follow_redirects=False))
+ assert record.find("virsh", "managedsave") == []
+
+
+def test_hibernate_failure_is_reported(logged_in, record, inline_operations):
+ record.overrides[("virsh", "managedsave")] = conftest.fake_result(ok=False, stderr="error: Requested operation is not valid: domain is not running")
+ logged_in.post("/vm/web01/ops/hibernate", follow_redirects=False)
+ operation = core.list_operations(1)[0]
+ assert operation["status"] == "error" and "выключена" in operation["message"]
+
+
+def test_forget_saved_state(logged_in, record):
+ shut_off(record)
+ response = logged_in.post("/vm/web01/ops/forget-save", follow_redirects=False)
+ assert "ops_message=Сохранённое состояние удалено" in location(response)
+ assert record.find("virsh", "managedsave-remove") == [["virsh", "managedsave-remove", "web01"]]
+
+
+# ---------------------------------------------------------------- notes
+def test_notes_save_running(logged_in, record):
+ response = logged_in.post("/vm/web01/notes/save", data={"title": " Сайт компании ", "description": "строка 1\r\nстрока 2"}, follow_redirects=False)
+ assert location(response) == "/vm/web01?notes_message=Заметки сохранены"
+ assert record.find("virsh", "desc") == [
+ ["virsh", "desc", "web01", "--title", "--new-desc=Сайт компании", "--config", "--live"],
+ ["virsh", "desc", "web01", "--new-desc=строка 1\nстрока 2", "--config", "--live"],
+ ]
+
+
+def test_notes_save_shut_off_and_dashes(logged_in, record):
+ shut_off(record)
+ logged_in.post("/vm/web01/notes/save", data={"title": "--edit", "description": ""}, follow_redirects=False)
+ calls = record.find("virsh", "desc")
+ assert calls[0] == ["virsh", "desc", "web01", "--title", "--new-desc=--edit", "--config"] # текст одним аргументом, без --live
+ assert calls[1] == ["virsh", "desc", "web01", "--new-desc=", "--config"] # пустое описание очищает
+
+
+def test_notes_save_error(logged_in, record):
+ record.overrides[("virsh", "desc")] = conftest.fake_result(ok=False, stderr="error: failed to get domain 'web01'")
+ assert "notes_error=" in location(logged_in.post("/vm/web01/notes/save", data={"title": "x"}, follow_redirects=False))
+ assert logged_in.post("/vm/-bad/notes/save", data={"title": "x"}, follow_redirects=False).status_code == 400
+
+
+# ---------------------------------------------------------------- clone
+def test_clone_page_blocked_while_running(logged_in, record):
+ response = logged_in.get("/vm/web01/clone")
+ assert response.status_code == 200
+ assert "Сначала выключите машину" in response.text
+ assert 'action="/vm/web01/shutdown"' in response.text
+ assert "disabled" in response.text
+
+
+def test_clone_page_ready(logged_in, record):
+ shut_off(record)
+ html = logged_in.get("/vm/web01/clone").text
+ assert "Сначала выключите" not in html
+ assert 'value="web01-copy"' in html
+ assert "6.0 ГБ" in html # нужно места — реальный размер диска
+ assert "web01.qcow2" in html
+
+
+def test_clone_validation(logged_in, record):
+ shut_off(record)
+ assert "Имя копии" in location(logged_in.post("/vm/web01/clone/start", data={"new_name": "bad name!"}, follow_redirects=False))
+ assert "уже есть" in location(logged_in.post("/vm/web01/clone/start", data={"new_name": "db01"}, follow_redirects=False))
+ assert "уже есть" in location(logged_in.post("/vm/web01/clone/start", data={"new_name": "WEB01"}, follow_redirects=False))
+ record.overrides[("virsh", "domstate")] = conftest.fake_result("running")
+ assert "Сначала выключите" in location(logged_in.post("/vm/web01/clone/start", data={"new_name": "web02"}, follow_redirects=False))
+ assert record.find("virt-clone") == []
+ assert logged_in.post("/vm/-bad/clone/start", data={"new_name": "web02"}, follow_redirects=False).status_code == 400
+
+
+def test_clone_checks_space_and_existing_file(logged_in, record, data_dirs, monkeypatch):
+ shut_off(record)
+ (data_dirs["images"] / "web02.qcow2").write_bytes(b"x")
+ assert "уже есть в хранилище" in location(logged_in.post("/vm/web01/clone/start", data={"new_name": "web02"}, follow_redirects=False))
+ monkeypatch.setattr(vmops, "free_space", lambda path: GIB)
+ assert "Не хватает места" in location(logged_in.post("/vm/web01/clone/start", data={"new_name": "web03"}, follow_redirects=False))
+
+
+def test_clone_starts_operation(logged_in, record, inline_operations, data_dirs):
+ shut_off(record)
+ response = logged_in.post("/vm/web01/clone/start", data={"new_name": "web02"}, follow_redirects=False)
+ assert location(response).startswith("/operations/")
+ operation = core.read_operation(location(response).split("/")[-1])
+ assert operation["status"] == "success" and operation["type"] == "vm_clone"
+ assert operation["vm_name"] == "web02" and operation["source_vm"] == "web01"
+ assert "web02 создана" in operation["message"]
+ assert record.find("virt-clone") == [["virt-clone", "--original", "web01", "--name", "web02", "--file", str(data_dirs["images"] / "web02.qcow2")]]
+ assert record.find("virsh", "pool-refresh") == [["virsh", "pool-refresh", "virtuality-images"]]
+ assert "virt-clone --original web01" in operation["log_tail"]
+
+
+def test_clone_failure_is_reported(logged_in, record, inline_operations):
+ shut_off(record)
+ record.overrides[("virt-clone",)] = conftest.fake_result(ok=False, stderr="ERROR Storage volume already exists")
+ response = logged_in.post("/vm/web01/clone/start", data={"new_name": "web02"}, follow_redirects=False)
+ operation = core.read_operation(location(response).split("/")[-1])
+ assert operation["status"] == "error" and "уже есть" in operation["message"]
+ assert record.find("virsh", "pool-refresh") == []
+
+
+def test_clone_progress_watcher(data_dirs):
+ target = data_dirs["images"] / "copy.qcow2"
+ target.write_bytes(b"\0" * (512 * 1024))
+ operation = core.new_operation("vm_clone", "test", vm_name="copy", source_vm="web01")
+
+ class OneTick:
+ def __init__(self):
+ self.ticks = 0
+
+ def wait(self, timeout):
+ self.ticks += 1
+ return self.ticks > 1
+
+ vmops._watch_clone_progress(operation, [target], 1024 * 1024, OneTick())
+ fresh = core.read_operation(operation["id"])
+ assert 3 <= fresh["progress"] <= 95 and "Копируем диски" in fresh["message"]
+ assert vmops._allocated_bytes(data_dirs["images"] / "missing.qcow2") == 0
+
+
+def test_clone_worker_stops_watcher(data_dirs, monkeypatch):
+ """Даже если virt-clone упал, поток прогресса останавливается."""
+ monkeypatch.setattr(core, "run_cmd", lambda cmd, timeout=12: conftest.fake_result(ok=False, stderr="boom"))
+ operation = core.new_operation("vm_clone", "test", vm_name="web02", source_vm="web01", targets=[str(data_dirs["images"] / "web02.qcow2")], expected_bytes=10)
+ before = threading.active_count()
+ vmops.clone_worker(operation)
+ assert threading.active_count() <= before
+ assert core.read_operation(operation["id"])["status"] == "error"
diff --git a/web/app.py b/web/app.py
index 9535f38..3d83625 100644
--- a/web/app.py
+++ b/web/app.py
@@ -28,7 +28,6 @@ import auth
import host_profile
import network_core
import presenters
-from features import backups, downloads, settings, setup, snapshots
import update_core
from network_core import NetworkError
@@ -2005,8 +2004,12 @@ def api_health(request: Request):
return {"system": system_summary(), "host_profile": host_profile.load_host_profile(), "services": {"libvirtd": service_state("libvirtd.service"), "virtlogd": service_state("virtlogd.service"), "web": service_state("virtuality-web.service")}, "vms": parse_virsh_list(), "pools": parse_pool_list(), "network": network_summary(), "virtuality_nat": network_core.network_context()}
+# Feature modules register template globals into app.templates, so they are imported once it exists.
+from features import backups, downloads, settings, setup, snapshots, vmops # noqa: E402
+
app.include_router(setup.router)
app.include_router(settings.router)
app.include_router(snapshots.router)
app.include_router(backups.router)
app.include_router(downloads.router)
+app.include_router(vmops.router)
diff --git a/web/features/vmops.py b/web/features/vmops.py
new file mode 100644
index 0000000..a97b755
--- /dev/null
+++ b/web/features/vmops.py
@@ -0,0 +1,590 @@
+"""Повседневные операции с виртуальной машиной, которых ждут от «простого
+Proxmox»: увеличение диска, клонирование, пауза и спящий режим, заметки
+(название и описание) и живая нагрузка процессора и памяти.
+
+Всё, что занимает больше пары секунд (клонирование, спящий режим), идёт как
+фоновая операция с прогрессом и журналом на странице /operations/{id}.
+"""
+import json
+import re
+import shutil
+import threading
+import time
+from pathlib import Path
+from typing import Any
+from urllib.parse import quote
+
+from fastapi import APIRouter, Form, Request
+from fastapi.responses import JSONResponse, RedirectResponse
+
+import core
+import presenters
+from core import (
+ IMAGES_POOL,
+ append_operation_log,
+ cmd_error,
+ finish_operation,
+ new_operation,
+ redirect_with_message,
+ render,
+ require_api_auth,
+ require_auth,
+ run_operation,
+ running_operations,
+ templates,
+ update_operation,
+ valid_vm_name,
+ vm_exists,
+ vm_state,
+)
+
+router = APIRouter()
+
+GIB = 1024 ** 3
+MAX_DISK_GB = 16 * 1024 # 16 ТБ — потолок формы, чтобы опечатка не сделала диск на петабайт
+TITLE_MAX = 80
+DESCRIPTION_MAX = 2000
+CLONE_TIMEOUT = 6 * 3600 # секунд: копия терабайтного диска на HDD идёт часами
+HIBERNATE_TIMEOUT = 1800
+SHUT_OFF_STATES = ("shut off", "shutoff", "shut-off")
+ACTIVE_STATES = ("running", "idle", "blocked")
+SAVE_DIR = Path("/var/lib/libvirt/qemu/save") # сюда libvirt кладёт память «спящих» машин
+RESIZABLE_FORMATS = ("qcow2", "raw")
+
+GUEST_GROW_HINT = "Если система ставилась из облачного образа, место внутри появится само. Иначе расширьте раздел в самой системе: в Linux — growpart или GParted, в Windows — «Управление дисками» → «Расширить том»."
+
+# Известные ошибки virsh/qemu-img/virt-clone → понятное объяснение (подстрока без учёта регистра).
+KNOWN_ERRORS = [
+ ("has snapshots", "У диска есть снимки — сначала удалите снимки машины, затем увеличьте диск."),
+ ("no space left", "На диске сервера закончилось место."),
+ ("cannot acquire state change lock", "Машина занята другой операцией. Подождите минуту и попробуйте снова."),
+ ("timed out during operation", "Машина занята другой операцией. Подождите минуту и попробуйте снова."),
+ ("already exists", "Машина или файл с таким именем уже есть."),
+ ("is not running", "Машина выключена, поэтому это действие сейчас недоступно."),
+ ("not paused", "Машина не приостановлена."),
+ ("already active", "Машина уже работает."),
+ ("guest agent", "Внутри машины нет гостевого агента, поэтому это действие недоступно."),
+ ("permission denied", "Нет прав на файл диска. Проверьте, что диск лежит в хранилище Virtuality."),
+]
+
+OPS = {
+ "pause": {
+ "cmd": ["virsh", "suspend"],
+ "states": ACTIVE_STATES,
+ "done": "Машина {name} приостановлена: она не тратит процессор, но остаётся в памяти сервера",
+ "wrong": "Приостановить можно только работающую машину",
+ "fail": "Не удалось приостановить машину",
+ },
+ "resume": {
+ "cmd": ["virsh", "resume"],
+ "states": ("paused",),
+ "done": "Машина {name} продолжает работу",
+ "wrong": "Продолжить можно только приостановленную машину",
+ "fail": "Не удалось продолжить работу машины",
+ },
+ "forget-save": {
+ "cmd": ["virsh", "managedsave-remove"],
+ "states": SHUT_OFF_STATES,
+ "done": "Сохранённое состояние удалено: при запуске машина {name} загрузится заново",
+ "wrong": "Сохранённое состояние есть только у выключенной машины",
+ "fail": "Не удалось удалить сохранённое состояние",
+ },
+}
+
+
+def run(cmd: list[str], timeout: int = 12) -> dict[str, Any]:
+ """Все команды идут через core.run_cmd — тесты подменяют его целиком."""
+ return core.run_cmd(cmd, timeout=timeout)
+
+
+def explain_error(raw: str, fallback: str) -> str:
+ low = (raw or "").lower()
+ for needle, text in KNOWN_ERRORS:
+ if needle in low:
+ return text
+ return raw or fallback
+
+
+def is_shut_off(state: str) -> bool:
+ return (state or "").strip().lower() in SHUT_OFF_STATES
+
+
+def redirect_tab(name: str, tab: str, key: str, message: str) -> RedirectResponse:
+ """Как redirect_with_message, но открывает нужную вкладку страницы машины."""
+ return RedirectResponse(url=f"/vm/{name}?{key}={quote(message)}#{tab}", status_code=303)
+
+
+def safe_next(value: str, name: str) -> str:
+ """Куда вернуть пользователя после действия: только обзор или страница машины."""
+ return value if value in ("/", f"/vm/{name}") else f"/vm/{name}"
+
+
+def _guard(request: Request, name: str):
+ """Общая проверка для всех маршрутов: вход, имя, существование машины."""
+ denied = require_auth(request)
+ if denied:
+ return denied
+ if not valid_vm_name(name):
+ return JSONResponse({"ok": False, "error": "Invalid VM name"}, status_code=400)
+ if not vm_exists(name):
+ return RedirectResponse(url="/", status_code=303)
+ return None
+
+
+# ---------------------------------------------------------------- parsers
+def parse_qemu_img_info(text: str) -> dict[str, Any]:
+ """`qemu-img info --output=json` → виртуальный и реальный размер, формат."""
+ try:
+ data = json.loads(text or "")
+ except ValueError:
+ return {}
+ if not isinstance(data, dict):
+ return {}
+ try:
+ return {"virtual_size": int(data.get("virtual-size") or 0), "actual_size": int(data.get("actual-size") or 0), "format": str(data.get("format") or "")}
+ except (TypeError, ValueError):
+ return {}
+
+
+def parse_domstats(text: str) -> dict[str, dict[str, str]]:
+ """`virsh domstats` → {имя: {поле: значение}}. Машины без статистики дают пустой словарь."""
+ stats: dict[str, dict[str, str]] = {}
+ current: str | None = None
+ for raw_line in (text or "").splitlines():
+ line = raw_line.strip()
+ match = re.fullmatch(r"Domain:\s*'(.+)'", line)
+ if match:
+ current = match.group(1)
+ stats[current] = {}
+ continue
+ if current is None or "=" not in line:
+ continue
+ key, value = line.split("=", 1)
+ stats[current][key.strip()] = value.strip()
+ return stats
+
+
+def parse_desc_output(result: dict[str, Any]) -> str:
+ """Вывод `virsh desc`: пусто, если названия/описания нет."""
+ if not result.get("ok"):
+ return ""
+ text = (result.get("stdout") or "").strip()
+ if re.match(r"^No (title|description) for domain", text):
+ return ""
+ return text
+
+
+def has_managed_save(dominfo: str) -> bool:
+ """Строка «Managed save: yes» в `virsh dominfo` — машина в спящем режиме."""
+ match = re.search(r"^Managed save:\s*(\S+)", dominfo or "", re.MULTILINE | re.IGNORECASE)
+ return bool(match) and match.group(1).lower() in ("yes", "on", "true")
+
+
+def _int(value: str | None) -> int:
+ try:
+ return int(value or 0)
+ except ValueError:
+ return 0
+
+
+def compute_stats(stats: dict[str, dict[str, str]], now: float, samples: dict[str, tuple[float, int]]) -> dict[str, dict[str, Any]]:
+ """Проценты процессора считаются по разнице cpu.time с прошлым замером той же машины.
+
+ Первый замер даёт cpu_pct=None (нечего сравнивать). Память — balloon.rss
+ (реально занятая на сервере), если нет — balloon.current.
+ """
+ result: dict[str, dict[str, Any]] = {}
+ for name, fields in stats.items():
+ cpu_time = _int(fields.get("cpu.time"))
+ vcpus = _int(fields.get("vcpu.current")) or _int(fields.get("vcpu.maximum")) or 1
+ mem_used_kib = _int(fields.get("balloon.rss")) or _int(fields.get("balloon.current"))
+ mem_total_kib = _int(fields.get("balloon.maximum")) or _int(fields.get("balloon.current"))
+ if not cpu_time and not mem_total_kib:
+ samples.pop(name, None)
+ continue # выключенная машина — статистики нет
+ cpu_pct = None
+ previous = samples.get(name)
+ if previous and now > previous[0] and cpu_time >= previous[1]:
+ cpu_pct = (cpu_time - previous[1]) / ((now - previous[0]) * 1e9) / vcpus * 100
+ cpu_pct = round(min(100.0, max(0.0, cpu_pct)), 1)
+ samples[name] = (now, cpu_time)
+ result[name] = {"cpu_pct": cpu_pct, "mem_used_mb": mem_used_kib // 1024, "mem_total_mb": mem_total_kib // 1024, "vcpus": vcpus}
+ for stale in set(samples) - set(stats):
+ samples.pop(stale, None)
+ return result
+
+
+# ---------------------------------------------------------------- live stats
+_samples: dict[str, tuple[float, int]] = {}
+_samples_lock = threading.Lock()
+
+
+def vm_stats(names: list[str]) -> dict[str, dict[str, Any]]:
+ """Нагрузка нескольких машин одним вызовом `virsh domstats`."""
+ if not names:
+ return {}
+ result = run(["virsh", "domstats", "--cpu-total", "--balloon", "--vcpu", *names], timeout=10)
+ if not result["ok"]:
+ return {}
+ with _samples_lock:
+ return compute_stats(parse_domstats(result["stdout"]), time.monotonic(), _samples)
+
+
+def active_vm_names() -> list[str]:
+ """Имена работающих и приостановленных машин (`virsh list --name`)."""
+ result = run(["virsh", "list", "--name"], timeout=8)
+ if not result["ok"]:
+ return []
+ return [line.strip() for line in result["stdout"].splitlines() if valid_vm_name(line.strip())]
+
+
+def all_vm_names() -> list[str]:
+ result = run(["virsh", "list", "--all", "--name"], timeout=8)
+ if not result["ok"]:
+ return []
+ return [line.strip() for line in result["stdout"].splitlines() if line.strip()]
+
+
+@router.get("/live/stats")
+def live_stats(request: Request):
+ denied = require_api_auth(request)
+ if denied:
+ return denied
+ return JSONResponse({"ok": True, "generated_at": core.utc_now(), "stats": vm_stats(active_vm_names())})
+
+
+# ---------------------------------------------------------------- titles & notes
+def vm_titles() -> dict[str, str]:
+ """Названия всех машин одним вызовом — для списка на обзоре."""
+ result = run(["virsh", "list", "--all", "--title"], timeout=10)
+ return presenters.parse_virsh_list_titles(result["stdout"]) if result["ok"] else {}
+
+
+def read_notes(name: str) -> dict[str, str]:
+ """Название и описание машины. Без флагов virsh отдаёт текущее значение:
+ у работающей машины — живое, у выключенной — из конфигурации."""
+ title = run(["virsh", "-q", "desc", name, "--title"], timeout=8)
+ description = run(["virsh", "-q", "desc", name], timeout=8)
+ return {"title": parse_desc_output(title), "description": parse_desc_output(description)}
+
+
+def clean_title(value: str) -> str:
+ return " ".join((value or "").split())[:TITLE_MAX].strip()
+
+
+def clean_description(value: str) -> str:
+ text = (value or "").replace("\r\n", "\n").replace("\r", "\n")
+ lines = [line.rstrip() for line in text.split("\n")]
+ return "\n".join(lines).strip()[:DESCRIPTION_MAX].strip()
+
+
+def write_notes(name: str, title: str, description: str) -> tuple[bool, str]:
+ """`virsh desc` в конфигурацию, а у работающей машины — ещё и вживую.
+ Текст передаётся как --new-desc=ТЕКСТ одним аргументом: так virsh не
+ примет описание вида «--edit» за свою опцию."""
+ flags = ["--config"] + (["--live"] if not is_shut_off(vm_state(name)) else [])
+ for extra, value in ((["--title"], title), ([], description)):
+ result = run(["virsh", "desc", name, *extra, f"--new-desc={value}", *flags], timeout=10)
+ if not result["ok"]:
+ return False, explain_error(cmd_error(result, "virsh desc завершился с ошибкой"), "Не удалось сохранить заметки")
+ return True, "Заметки сохранены"
+
+
+@router.post("/vm/{name}/notes/save")
+def notes_save(request: Request, name: str, title: str = Form(""), description: str = Form("")):
+ denied = _guard(request, name)
+ if denied:
+ return denied
+ ok, message = write_notes(name, clean_title(title), clean_description(description))
+ return redirect_with_message(f"/vm/{name}", "notes_message" if ok else "notes_error", message)
+
+
+# ---------------------------------------------------------------- disks
+def inside_storage(path: str) -> bool:
+ """Диск должен лежать в /var/lib/virtuality — чужие файлы панель не трогает."""
+ try:
+ resolved = Path(path).resolve()
+ except (OSError, RuntimeError):
+ return False
+ return Path(core.STORAGE_DIR).resolve() in resolved.parents
+
+
+def free_space(path: str | Path) -> int:
+ """Свободное место рядом с файлом диска (или в хранилище образов)."""
+ for candidate in (Path(path).parent, Path(core.IMAGES_DIR), Path("/")):
+ try:
+ return shutil.disk_usage(candidate).free
+ except OSError:
+ continue
+ return 0
+
+
+def disk_info(path: str) -> dict[str, Any]:
+ # -U (--force-share): иначе qemu-img не откроет диск работающей машины из-за блокировки.
+ result = run(["qemu-img", "info", "--output=json", "-U", path], timeout=20)
+ return parse_qemu_img_info(result["stdout"]) if result["ok"] else {}
+
+
+def vm_disks(name: str) -> list[dict[str, Any]]:
+ """Диски машины с размерами; у подходящих — флаг resizable и минимальный новый размер."""
+ result = run(["virsh", "domblklist", name, "--details"], timeout=10)
+ disks = presenters.parse_domblklist(result["stdout"]) if result["ok"] else []
+ items = []
+ for disk in disks:
+ item: dict[str, Any] = dict(disk, virtual_size=0, actual_size=0, format="", size_text="", used_text="", resizable=False, min_gb=0)
+ if disk["device"] == "disk" and disk["source"] and disk["type"] == "file":
+ info = disk_info(disk["source"])
+ virtual, actual = info.get("virtual_size", 0), info.get("actual_size", 0)
+ item.update(virtual_size=virtual, actual_size=actual, format=info.get("format", ""))
+ if virtual:
+ item["size_text"] = presenters.format_bytes(virtual)
+ item["used_text"] = presenters.format_bytes(actual) if actual else ""
+ item["min_gb"] = virtual // GIB + 1
+ item["resizable"] = item["format"] in RESIZABLE_FORMATS and inside_storage(disk["source"])
+ items.append(item)
+ return items
+
+
+def disks_context(name: str) -> dict[str, Any]:
+ """Данные для карточки «Диски» на странице машины (вызывается из шаблона)."""
+ disks = vm_disks(name)
+ first = next((d["source"] for d in disks if d["source"]), None)
+ return {"disks": disks, "free_text": presenters.format_bytes(free_space(first or core.IMAGES_DIR)), "max_gb": MAX_DISK_GB, "hint": GUEST_GROW_HINT}
+
+
+@router.post("/vm/{name}/disks/{target}/resize")
+def disk_resize(request: Request, name: str, target: str, size_gb: int = Form(...)):
+ denied = _guard(request, name)
+ if denied:
+ return denied
+ if not re.fullmatch(r"[a-z0-9]{1,16}", target or ""):
+ return JSONResponse({"ok": False, "error": "Invalid disk target"}, status_code=400)
+ disk = next((d for d in vm_disks(name) if d["target"] == target), None)
+ if not disk:
+ return redirect_tab(name, "storage", "disk_error", "Такого диска у машины нет")
+ if not disk["resizable"]:
+ return redirect_tab(name, "storage", "disk_error", f"Диск {disk['name']} нельзя увеличить из панели: он не в хранилище Virtuality или в необычном формате")
+ if size_gb < disk["min_gb"]:
+ return redirect_tab(name, "storage", "disk_error", f"Диск можно только увеличивать: укажите не меньше {disk['min_gb']} ГБ")
+ if size_gb > MAX_DISK_GB:
+ return redirect_tab(name, "storage", "disk_error", f"Слишком большой размер: не больше {MAX_DISK_GB // 1024} ТБ")
+ growth = size_gb * GIB - disk["virtual_size"]
+ free = free_space(disk["source"])
+ if growth > free:
+ return redirect_tab(name, "storage", "disk_error", f"На сервере свободно только {presenters.format_bytes(free)}: столько добавить диску не получится")
+ if is_shut_off(vm_state(name)):
+ result = run(["qemu-img", "resize", disk["source"], f"{size_gb}G"], timeout=120)
+ else:
+ result = run(["virsh", "blockresize", name, target, f"{size_gb}G"], timeout=60)
+ if not result["ok"]:
+ return redirect_tab(name, "storage", "disk_error", explain_error(cmd_error(result, "Не удалось увеличить диск"), "Не удалось увеличить диск"))
+ return redirect_tab(name, "storage", "disk_message", f"Диск {disk['name']} увеличен до {size_gb} ГБ. {GUEST_GROW_HINT}")
+
+
+# ---------------------------------------------------------------- clone
+def suggest_clone_name(name: str, existing: set[str]) -> str:
+ base = f"{name}-copy"[:60]
+ candidate, index = base, 2
+ while candidate in existing:
+ candidate = f"{base}{index}"
+ index += 1
+ return candidate
+
+
+def clone_targets(new_name: str, disks: list[dict[str, Any]]) -> list[Path]:
+ """Файлы дисков копии: DST.qcow2, DST-disk2.qcow2, … в порядке domblklist."""
+ targets = []
+ cloneable = [d for d in disks if d["device"] == "disk" and d["source"]]
+ for index, disk in enumerate(cloneable):
+ ext = Path(disk["source"]).suffix.lower()
+ ext = ext if ext in (".qcow2", ".img", ".raw") else ".qcow2"
+ suffix = "" if index == 0 else f"-disk{index + 1}"
+ targets.append(Path(core.IMAGES_DIR) / f"{new_name}{suffix}{ext}")
+ return targets
+
+
+def clone_context(name: str) -> dict[str, Any]:
+ state = vm_state(name)
+ disks = vm_disks(name)
+ cloneable = [d for d in disks if d["device"] == "disk" and d["source"]]
+ needed = sum(d["actual_size"] or d["virtual_size"] for d in cloneable)
+ free = free_space(core.IMAGES_DIR)
+ existing = set(all_vm_names())
+ blockers = []
+ if not is_shut_off(state):
+ blockers.append("running")
+ if not cloneable:
+ blockers.append("no_disks")
+ if needed > free:
+ blockers.append("space")
+ if any(op.get("source_vm") == name for op in running_operations("vm_clone")):
+ blockers.append("busy")
+ return {
+ "state": state, "is_shut_off": is_shut_off(state), "disks": cloneable, "needed_text": presenters.format_bytes(needed),
+ "free_text": presenters.format_bytes(free), "suggested": suggest_clone_name(name, existing), "blockers": blockers,
+ "vcpus": 0, "memory_mb": 0,
+ }
+
+
+def _allocated_bytes(path: Path) -> int:
+ try:
+ stat = path.stat()
+ except OSError:
+ return 0
+ blocks = getattr(stat, "st_blocks", None)
+ return blocks * 512 if blocks is not None else stat.st_size
+
+
+def _watch_clone_progress(operation: dict[str, Any], targets: list[Path], expected: int, stop: threading.Event) -> None:
+ """Пока virt-clone копирует, следим за ростом файлов копии и обновляем прогресс."""
+ while not stop.wait(1.0):
+ copied = sum(_allocated_bytes(target) for target in targets)
+ if expected > 0:
+ pct = min(95, max(3, int(copied * 95 / expected)))
+ update_operation(operation, progress=pct, message=f"Копируем диски: {presenters.format_bytes(copied)} из {presenters.format_bytes(expected)}")
+
+
+def clone_worker(operation: dict[str, Any]) -> None:
+ source, new_name = operation["source_vm"], operation["vm_name"]
+ targets = [Path(item) for item in operation.get("targets", [])]
+ cmd = ["virt-clone", "--original", source, "--name", new_name]
+ for target in targets:
+ cmd += ["--file", str(target)]
+ update_operation(operation, progress=3, message=f"Копируем диски машины {source}…", cmd=" ".join(cmd))
+ append_operation_log(operation["id"], " ".join(cmd))
+ stop = threading.Event()
+ watcher = threading.Thread(target=_watch_clone_progress, args=(operation, targets, int(operation.get("expected_bytes") or 0), stop), daemon=True)
+ watcher.start()
+ try:
+ result = run(cmd, timeout=CLONE_TIMEOUT)
+ finally:
+ stop.set()
+ watcher.join(timeout=5)
+ for stream in ("stdout", "stderr"):
+ if result.get(stream):
+ append_operation_log(operation["id"], result[stream])
+ if not result["ok"]:
+ for target in targets: # virt-clone обычно сам убирает недокопированные файлы, но подстрахуемся
+ try:
+ if target.exists() and not run(["virsh", "dominfo", new_name], timeout=8)["ok"]:
+ target.unlink()
+ except OSError:
+ pass
+ finish_operation(operation, False, explain_error(cmd_error(result, "virt-clone завершился с ошибкой"), "Не удалось клонировать машину"))
+ return
+ update_operation(operation, progress=97, message="Обновляем список дисков…")
+ refresh = run(["virsh", "pool-refresh", IMAGES_POOL], timeout=60)
+ if not refresh["ok"]:
+ append_operation_log(operation["id"], f"pool-refresh: {cmd_error(refresh, 'не удалось обновить хранилище (не страшно)')}")
+ finish_operation(operation, True, f"Машина {new_name} создана — это копия {source}. Запустите её и смените имя компьютера внутри системы.")
+
+
+@router.get("/vm/{name}/clone")
+def clone_page(request: Request, name: str):
+ denied = _guard(request, name)
+ if denied:
+ return denied
+ context = clone_context(name)
+ context.update(vm_name=name, error=request.query_params.get("clone_error", ""), new_name=request.query_params.get("new_name", "") or context["suggested"])
+ return render(request, "vm_clone.html", context)
+
+
+@router.post("/vm/{name}/clone/start")
+def clone_vm(request: Request, name: str, new_name: str = Form("")):
+ denied = _guard(request, name)
+ if denied:
+ return denied
+ new_name = (new_name or "").strip()
+
+ def fail(message: str):
+ return RedirectResponse(url=f"/vm/{name}/clone?clone_error={quote(message)}&new_name={quote(new_name)}", status_code=303)
+
+ if not valid_vm_name(new_name):
+ return fail("Имя копии: от 2 до 63 символов — латинские буквы, цифры, точка, дефис и подчёркивание, первый символ — буква или цифра")
+ if new_name.lower() == name.lower() or new_name in set(all_vm_names()) or vm_exists(new_name):
+ return fail(f"Машина с именем {new_name} уже есть — выберите другое имя")
+ context = clone_context(name)
+ if "running" in context["blockers"]:
+ return fail("Сначала выключите машину: копировать диски можно только у выключенной машины")
+ if "no_disks" in context["blockers"]:
+ return fail("У машины нет дисков, которые можно скопировать")
+ if "busy" in context["blockers"]:
+ return fail("Эта машина уже клонируется — дождитесь окончания в разделе «Задачи»")
+ if any(op.get("vm_name") == new_name for op in running_operations("vm_clone")):
+ return fail(f"Копия с именем {new_name} уже создаётся — выберите другое имя")
+ if "space" in context["blockers"]:
+ return fail(f"Не хватает места: копии нужно {context['needed_text']}, свободно {context['free_text']}")
+ targets = clone_targets(new_name, context["disks"])
+ for target in targets:
+ if target.exists():
+ return fail(f"Файл {target.name} уже есть в хранилище — выберите другое имя")
+ needed = sum(d["actual_size"] or d["virtual_size"] for d in context["disks"])
+ operation = new_operation("vm_clone", f"Клонирование {name} → {new_name}", vm_name=new_name, source_vm=name, targets=[str(t) for t in targets], expected_bytes=needed)
+ run_operation(operation, clone_worker)
+ return RedirectResponse(url=f"/operations/{operation['id']}", status_code=303)
+
+
+# ---------------------------------------------------------------- pause / resume / hibernate
+def hibernate_worker(operation: dict[str, Any]) -> None:
+ name = operation["vm_name"]
+ update_operation(operation, progress=10, message="Сохраняем память машины на диск сервера…")
+ result = run(["virsh", "managedsave", name], timeout=HIBERNATE_TIMEOUT)
+ for stream in ("stdout", "stderr"):
+ if result.get(stream):
+ append_operation_log(operation["id"], result[stream])
+ if not result["ok"]:
+ finish_operation(operation, False, explain_error(cmd_error(result, "virsh managedsave завершился с ошибкой"), "Не удалось перевести машину в спящий режим"))
+ return
+ finish_operation(operation, True, f"Машина {name} в спящем режиме. При запуске она продолжит работу с того же места.")
+
+
+def hibernate(name: str, state: str, target: str):
+ if state not in ACTIVE_STATES + ("paused",):
+ return redirect_with_message(target, "ops_error", "В спящий режим можно перевести только работающую или приостановленную машину")
+ if running_operations("vm_hibernate"):
+ return redirect_with_message(target, "ops_error", "Другая машина сейчас засыпает — подождите минуту")
+ info = presenters.parse_dominfo(run(["virsh", "dominfo", name], timeout=8).get("stdout", ""))
+ needed = info["memory_mb"] * 1024 * 1024
+ if needed and needed > free_space(SAVE_DIR / "x"):
+ return redirect_with_message(target, "ops_error", f"Для спящего режима нужно {presenters.format_bytes(needed)} свободного места на сервере, а его нет")
+ operation = new_operation("vm_hibernate", f"Спящий режим: {name}", vm_name=name)
+ run_operation(operation, hibernate_worker)
+ return redirect_with_message(target, "ops_message", f"Машина {name} засыпает: состояние сохраняется на диск, это займёт до минуты")
+
+
+@router.post("/vm/{name}/ops/{action}")
+def vm_ops(request: Request, name: str, action: str, next: str = Form("")):
+ denied = require_auth(request)
+ if denied:
+ return denied
+ if not valid_vm_name(name):
+ return JSONResponse({"ok": False, "error": "Invalid VM name"}, status_code=400)
+ if action != "hibernate" and action not in OPS:
+ return JSONResponse({"ok": False, "error": "Unsupported action"}, status_code=400)
+ if not vm_exists(name):
+ return RedirectResponse(url="/", status_code=303)
+ target = safe_next(next, name)
+ state = vm_state(name)
+ if action == "hibernate":
+ return hibernate(name, state, target)
+ spec = OPS[action]
+ if state not in spec["states"]:
+ return redirect_with_message(target, "ops_error", spec["wrong"])
+ result = run(spec["cmd"] + [name], timeout=30)
+ if not result["ok"]:
+ return redirect_with_message(target, "ops_error", explain_error(cmd_error(result, spec["fail"]), spec["fail"]))
+ return redirect_with_message(target, "ops_message", spec["done"].format(name=name))
+
+
+# Шаблоны берут данные напрямую (как update_notice() в base.html), чтобы
+# страница машины и обзор в app.py не менялись.
+TEMPLATE_GLOBALS = {"vmops_disks": disks_context, "vmops_notes": read_notes, "vmops_titles": vm_titles, "vmops_saved": has_managed_save}
+templates.env.globals.update(TEMPLATE_GLOBALS)
+
+# Обзор и страница машины пока рендерятся отдельным окружением шаблонов из app.py —
+# подключаем и его. Циклический импорт безопасен: app.py импортирует этот модуль
+# последними строками, когда templates уже создан.
+import app as _app # noqa: E402
+
+if getattr(_app, "templates", None) is not None and _app.templates is not templates:
+ _app.templates.env.globals.update(TEMPLATE_GLOBALS)
diff --git a/web/presenters.py b/web/presenters.py
index f8810eb..df53e55 100644
--- a/web/presenters.py
+++ b/web/presenters.py
@@ -249,3 +249,28 @@ def format_bytes(value: int | float) -> str:
return f"{value:.0f} {unit}" if value >= 10 or unit == "Б" else f"{value:.1f} {unit}"
value /= 1024
return f"{value:.0f} ТБ"
+
+
+def parse_virsh_list_titles(text: str) -> dict[str, str]:
+ """`virsh list --all --title` → {имя: название}. Колонки virsh выровнены
+ пробелами, поэтому название берём по смещению колонки Title из заголовка:
+ так не ломаются состояния с пробелом («shut off») и названия из нескольких слов."""
+ lines = (text or "").splitlines()
+ if not lines:
+ return {}
+ header = lines[0]
+ name_col, title_col = re.search(r"\bName\b", header), re.search(r"\bTitle\b", header)
+ if not name_col or not title_col:
+ return {}
+ titles: dict[str, str] = {}
+ for line in lines[1:]:
+ stripped = line.strip()
+ if not stripped or set(stripped) <= {"-"}:
+ continue
+ name_part = line[name_col.start():].split()
+ if not name_part:
+ continue
+ title = line[title_col.start():].strip()
+ if title:
+ titles[name_part[0]] = title
+ return titles
diff --git a/web/static/app.css b/web/static/app.css
index 3e80e13..a2007f3 100644
--- a/web/static/app.css
+++ b/web/static/app.css
@@ -630,3 +630,28 @@ details.advanced > .advanced-body { padding: 4px 16px 16px; }
.download-error { color: var(--danger); }
.catalog-item .meta code { padding: 1px 5px; border-radius: 5px; background: var(--surface-3); font: 12px/1.4 var(--mono); color: var(--text-2); }
@media (max-width: 640px) { .download-item, .catalog-item { flex-wrap: wrap; } .download-item .grow, .catalog-item .grow { flex-basis: calc(100% - 50px); } }
+
+/* ---------------------------------------------------------- vmops: live load, disks, notes */
+.list > * { min-width: 0; } /* длинное сообщение задачи обрезается, а не растягивает карточку на телефоне */
+.vm-title { color: var(--text-2); font-weight: 500; }
+.vm-meters { display: grid; gap: 3px; margin-top: 6px; max-width: 220px; }
+.vm-meter { display: grid; grid-template-columns: 14px minmax(0, 1fr) 46px; align-items: center; gap: 7px; color: var(--text-3); font-size: 11.5px; }
+.vm-meter .icon-sm { width: 14px; height: 14px; }
+.vm-meter .meter { display: block; margin: 0; height: 5px; }
+.vm-meter b { font-weight: 600; color: var(--text-2); text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; }
+.load-grid { display: grid; gap: 16px; }
+.load-row { display: grid; gap: 8px; }
+.load-row .row-between { font-size: 13px; }
+.load-row .row-between .subtle { display: inline-flex; align-items: center; gap: 6px; }
+.load-row .meter { margin-top: 0; }
+.disk-resize { display: flex; align-items: center; gap: 8px; flex: none; }
+.disk-resize .input { width: 84px; height: 32px; padding: 0 10px; border-radius: 8px 0 0 8px; font-size: 13px; }
+.disk-resize .addon { padding: 0 9px; border-radius: 0 8px 8px 0; font-size: 12px; }
+.disk-footer { display: block; }
+.disk-footer p { margin: 0; line-height: 1.5; }
+textarea.input { height: auto; min-height: 96px; padding: 9px 12px; resize: vertical; line-height: 1.45; }
+textarea.input:focus { border-color: var(--brand); outline: none; box-shadow: 0 0 0 4px var(--focus); }
+@media (max-width: 640px) {
+ .list-item.disk-item { flex-wrap: wrap; }
+ .disk-resize { width: 100%; justify-content: flex-end; }
+}
diff --git a/web/static/icons.svg b/web/static/icons.svg
index ce2bf98..d13d805 100644
--- a/web/static/icons.svg
+++ b/web/static/icons.svg
@@ -59,4 +59,6 @@
+
+
diff --git a/web/static/panel.js b/web/static/panel.js
index 38598b7..4e1c45a 100644
--- a/web/static/panel.js
+++ b/web/static/panel.js
@@ -198,6 +198,41 @@
setInterval(refreshLive, 5000);
}
+ /* ---------------------------------------------------- live VM load (CPU / RAM meters) */
+ const levelTone = (pct) => (pct >= 90 ? 'danger' : pct >= 75 ? 'warning' : 'success');
+ const formatMb = (mb) => (mb >= 1024 ? `${(mb / 1024).toFixed(mb >= 10240 ? 0 : 1)} ГБ` : `${Math.round(mb)} МБ`);
+ function applyStatMeter(root, key, pct, label) {
+ const bar = $(`[data-stat="${key}-bar"]`, root);
+ const meter = $(`[data-stat="${key}-meter"]`, root);
+ const text = $(`[data-stat="${key}"]`, root);
+ if (bar) bar.style.setProperty('--value', `${pct == null ? 0 : Math.round(pct)}%`);
+ if (meter) meter.className = `meter ${pct == null ? '' : levelTone(pct)}`;
+ if (text) text.textContent = label;
+ }
+ async function refreshStats() {
+ if (document.hidden) return;
+ try {
+ const response = await fetch('/live/stats', { cache: 'no-store', headers: { Accept: 'application/json' } });
+ if (!response.ok) return;
+ const payload = await response.json();
+ if (!payload.ok) return;
+ $$('[data-live-stats]').forEach((root) => {
+ const stat = payload.stats[root.dataset.liveStats];
+ root.hidden = !stat;
+ if (!stat) return;
+ const cpu = stat.cpu_pct == null ? null : Math.round(stat.cpu_pct);
+ const memPct = stat.mem_total_mb ? Math.min(100, Math.round((stat.mem_used_mb / stat.mem_total_mb) * 100)) : null;
+ applyStatMeter(root, 'cpu', cpu, cpu == null ? '…' : `${cpu}%`);
+ applyStatMeter(root, 'mem', memPct, stat.mem_total_mb ? (root.classList.contains('vm-meters') ? `${memPct}%` : `${formatMb(stat.mem_used_mb)} из ${formatMb(stat.mem_total_mb)}`) : '—');
+ });
+ } catch (_) {}
+ }
+ function wireStats() {
+ if (!$('[data-live-stats]')) return;
+ refreshStats();
+ setInterval(refreshStats, 5000);
+ }
+
/* ---------------------------------------------------- uploads */
function wireUploader(form) {
const input = $('input[type=file]', form);
@@ -468,6 +503,7 @@
wireTabs();
wireCopy();
wireLive();
+ wireStats();
$$('form[data-uploader]').forEach(wireUploader);
wireOperation();
wireLogs();
diff --git a/web/templates/_vm_disks.html b/web/templates/_vm_disks.html
new file mode 100644
index 0000000..ed2df64
--- /dev/null
+++ b/web/templates/_vm_disks.html
@@ -0,0 +1,27 @@
+{#- Карточка «Диски» на вкладке «Диски и загрузка»: размеры и форма увеличения. Ожидает vm из vm_detail.html. -#}
+{% from "_ui.html" import icon %}
+{% set dk = vmops_disks(vm.name) %}
+
+
+
+ {% for disk in dk.disks %}
+
+
{{ icon('disc-3' if disk.is_cdrom else 'hard-drive') }}
+
+
{{ disk.name }}
+
{{ disk.label }} {{ disk.target }}{% if disk.size_text %} · {{ disk.size_text }}{% if disk.used_text %}, занято {{ disk.used_text }}{% endif %}{% endif %}
+
+ {% if disk.resizable %}
+
+ {% endif %}
+
+ {% else %}
+
Дисков не найдено
+ {% endfor %}
+
+
+
diff --git a/web/templates/_vm_live_meters.html b/web/templates/_vm_live_meters.html
new file mode 100644
index 0000000..424d77b
--- /dev/null
+++ b/web/templates/_vm_live_meters.html
@@ -0,0 +1,6 @@
+{#- Две маленькие шкалы (процессор, память) в строке машины на обзоре; panel.js заполняет их из /live/stats. -#}
+{% from "_ui.html" import icon %}
+
+ {{ icon('cpu', 'icon-sm') }}—
+ {{ icon('memory-stick', 'icon-sm') }}—
+
diff --git a/web/templates/_vm_ops_alerts.html b/web/templates/_vm_ops_alerts.html
new file mode 100644
index 0000000..8a51940
--- /dev/null
+++ b/web/templates/_vm_ops_alerts.html
@@ -0,0 +1,6 @@
+{#- Сообщения после действий vmops (пауза, диски, заметки): читаются из адреса страницы. -#}
+{% from "_ui.html" import alert %}
+{% for key in ('ops', 'disk', 'notes') %}
+ {% if request.query_params.get(key ~ '_message') %}{{ alert('success', request.query_params.get(key ~ '_message')) }}{% endif %}
+ {% if request.query_params.get(key ~ '_error') %}{{ alert('danger', request.query_params.get(key ~ '_error')) }}{% endif %}
+{% endfor %}
diff --git a/web/templates/_vm_ops_cards.html b/web/templates/_vm_ops_cards.html
new file mode 100644
index 0000000..0be1257
--- /dev/null
+++ b/web/templates/_vm_ops_cards.html
@@ -0,0 +1,43 @@
+{#- Карточки на обзоре машины: нагрузка сейчас, спящий режим, заметки. Ожидает vm из vm_detail.html. -#}
+{% from "_ui.html" import icon %}
+{% set notes = vmops_notes(vm.name) %}
+{% set ops_s = (vm.info.state or 'shut off')|vm_state %}
+{% if ops_s.tone != 'neutral' %}
+
+
+
+
+
{{ icon('cpu', 'icon-sm') }}Процессор —
+
+
+
+
{{ icon('memory-stick', 'icon-sm') }}Память —
+
+
+
+
+{% endif %}
+{% if vmops_saved(vm.dominfo) %}
+
+
+
{{ icon('moon') }} Машина в спящем режиме При запуске она продолжит работу с того же места: открытые программы и окна останутся.
+
+
+
+{% endif %}
+
+
+
+
diff --git a/web/templates/_vm_ops_menu.html b/web/templates/_vm_ops_menu.html
new file mode 100644
index 0000000..2ac3c55
--- /dev/null
+++ b/web/templates/_vm_ops_menu.html
@@ -0,0 +1,15 @@
+{#- Пункты меню машины: пауза, продолжение, спящий режим, клонирование.
+ Перед include задайте ops_name (имя), ops_state (состояние virsh),
+ ops_next (куда вернуться после действия) и ops_hints (подсказки под пунктами). -#}
+{% from "_ui.html" import icon %}
+{%- set ops = ops_state|vm_state -%}
+{%- macro item(label, hint, hints) -%}{{ label }}{% if hints %}{{ hint }} {% endif %} {%- endmacro -%}
+{% if ops.running %}
+
+
+{% elif ops.raw == 'paused' %}
+
+
+{% endif %}
+
+
diff --git a/web/templates/dashboard.html b/web/templates/dashboard.html
index 45ab270..17d6467 100644
--- a/web/templates/dashboard.html
+++ b/web/templates/dashboard.html
@@ -2,6 +2,7 @@
{% from "_ui.html" import icon, badge, vm_badge, op_badge, meter, alert %}
{% set page_title = 'Обзор' %}
{% set running = vms|selectattr('state', 'in', ['running', 'idle', 'blocked'])|list|length %}
+{% set titles = vmops_titles() if vms else {} %}
{% block page_header %}