From 34684634af33535e6a7a32482d5c83a35643f47f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 15:00:41 +0000 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=BE=D0=B2=D1=81=D0=B5=D0=B4=D0=BD?= =?UTF-8?q?=D0=B5=D0=B2=D0=BD=D1=8B=D0=B5=20=D0=BE=D0=BF=D0=B5=D1=80=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D0=B8=20=D1=81=20=D0=BC=D0=B0=D1=88=D0=B8=D0=BD?= =?UTF-8?q?=D0=BE=D0=B9=20=D0=B1=D1=8B=D0=BB=D0=B8=20=D0=B4=D0=BE=D0=B1?= =?UTF-8?q?=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B:=20=D0=B4=D0=B8=D1=81?= =?UTF-8?q?=D0=BA,=20=D0=BA=D0=BB=D0=BE=D0=BD,=20=D0=BF=D0=B0=D1=83=D0=B7?= =?UTF-8?q?=D0=B0,=20=D0=B7=D0=B0=D0=BC=D0=B5=D1=82=D0=BA=D0=B8,=20=D0=BD?= =?UTF-8?q?=D0=B0=D0=B3=D1=80=D1=83=D0=B7=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Модуль web/features/vmops.py (router подключён двумя строками в app.py): - Увеличение диска на вкладке «Диски и загрузка»: размер из qemu-img info, только рост, проверка свободного места; выключенная машина — qemu-img resize, работающая — virsh blockresize. Диски вне /var/lib/virtuality не трогаются. - Клонирование: страница /vm/{name}/clone с проверками (машина выключена, хватает места, имя свободно), фоновая операция virt-clone с явными --file в хранилище образов, прогресс по росту файлов копии, pool-refresh после. - Пауза, продолжение, спящий режим (managedsave как операция) и удаление сохранённого состояния — /vm/{name}/ops/{action}; пункты меню на обзоре и странице машины, кнопка «Продолжить» для приостановленной машины. - Заметки: название и описание через virsh desc (--new-desc=ТЕКСТ одним аргументом, чтобы текст вида «--edit» не стал опцией); название видно под именем машины на обзоре (virsh list --all --title, парсер в presenters). - Живая нагрузка: /live/stats по virsh domstats для всех активных машин одним вызовом, CPU % по разнице cpu.time между опросами; шкалы в строках обзора и карточка «Нагрузка сейчас» на странице машины (panel.js, 5 с). Тесты для всех парсеров и маршрутов (tests/test_vmops.py), демо-данные в dev_server.py (три машины в разных состояниях, живые шкалы), значки notebook-pen и expand в спрайте. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg --- tests/conftest.py | 37 +- tests/dev_server.py | 73 +++- tests/fixtures/list-title.txt | 6 + tests/test_vmops.py | 437 +++++++++++++++++++++ web/app.py | 4 + web/features/__init__.py | 0 web/features/vmops.py | 590 +++++++++++++++++++++++++++++ web/presenters.py | 25 ++ web/static/app.css | 25 ++ web/static/icons.svg | 2 + web/static/panel.js | 36 ++ web/templates/_vm_disks.html | 27 ++ web/templates/_vm_live_meters.html | 6 + web/templates/_vm_ops_alerts.html | 6 + web/templates/_vm_ops_cards.html | 43 +++ web/templates/_vm_ops_menu.html | 15 + web/templates/dashboard.html | 10 +- web/templates/vm_clone.html | 71 ++++ web/templates/vm_detail.html | 16 +- 19 files changed, 1410 insertions(+), 19 deletions(-) create mode 100644 tests/fixtures/list-title.txt create mode 100644 tests/test_vmops.py create mode 100644 web/features/__init__.py create mode 100644 web/features/vmops.py create mode 100644 web/templates/_vm_disks.html create mode 100644 web/templates/_vm_live_meters.html create mode 100644 web/templates/_vm_ops_alerts.html create mode 100644 web/templates/_vm_ops_cards.html create mode 100644 web/templates/_vm_ops_menu.html create mode 100644 web/templates/vm_clone.html diff --git a/tests/conftest.py b/tests/conftest.py index 0491550..510fbe3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -35,6 +35,12 @@ DUMPXML = """ """ +FIXTURES = ROOT / "tests" / "fixtures" + +# Заметки машин для `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": ""} @@ -42,6 +48,10 @@ def fake_result(stdout: str = "", ok: bool = True, stderr: str = "") -> dict: def fake_run_cmd(cmd, timeout=12, **_kwargs): joined = " ".join(cmd) 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" 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") @@ -49,6 +59,20 @@ def fake_run_cmd(cmd, timeout=12, **_kwargs): return fake_result(DUMPXML) 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((FIXTURES / "qemu-img-info.json").read_text()) 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"]: @@ -75,6 +99,7 @@ def fake_run_cmd(cmd, timeout=12, **_kwargs): @pytest.fixture() def data_dirs(tmp_path, monkeypatch): import app + import core import host_profile import network_core import update_core @@ -91,10 +116,12 @@ def data_dirs(tmp_path, monkeypatch): } for path in dirs.values(): path.mkdir() - monkeypatch.setattr(app, "ISO_DIR", dirs["iso"]) - monkeypatch.setattr(app, "IMAGES_DIR", dirs["images"]) - monkeypatch.setattr(app, "DISK_IMAGES_DIR", dirs["disk_images"]) - monkeypatch.setattr(app, "OPERATIONS_DIR", dirs["operations"]) + for module in (app, core): + monkeypatch.setattr(module, "ISO_DIR", dirs["iso"]) + monkeypatch.setattr(module, "IMAGES_DIR", dirs["images"]) + monkeypatch.setattr(module, "DISK_IMAGES_DIR", dirs["disk_images"]) + monkeypatch.setattr(module, "OPERATIONS_DIR", dirs["operations"]) + 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"]) @@ -108,7 +135,7 @@ def data_dirs(tmp_path, monkeypatch): monkeypatch.setattr(update_core, "STATE_FILE", dirs["update"] / "state.json") monkeypatch.setattr(update_core, "LOG_FILE", dirs["update"] / "update.log") monkeypatch.setattr(update_core, "SOURCE_DIR", ROOT) - for module in (app, network_core, host_profile): + for module in (app, core, network_core, host_profile): monkeypatch.setattr(module, "run_cmd", fake_run_cmd) return dirs diff --git a/tests/dev_server.py b/tests/dev_server.py index 9df2e62..28598d9 100644 --- a/tests/dev_server.py +++ b/tests/dev_server.py @@ -6,6 +6,7 @@ import json import sys import tempfile +import time import uuid from pathlib import Path @@ -13,16 +14,78 @@ sys.path.insert(0, str(Path(__file__).resolve().parent)) import conftest # noqa: E402 (sets sys.path and env) import app # noqa: E402 +import core # noqa: E402 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_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) + def seed(root: Path) -> None: dirs = {name: root / name for name in ("iso", "images", "disk-images", "operations", "network", "config", "nft", "update")} 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, core.STORAGE_DIR = dirs["iso"], dirs["images"], dirs["disk-images"], dirs["operations"], root + 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.NAT_XML_FILE = dirs["network"] / "virtuality-nat.xml" @@ -32,8 +95,8 @@ def seed(root: Path) -> None: host_profile.PROFILE_FILE = dirs["config"] / "host_profile.json" update_core.STATE_DIR, update_core.STATE_FILE, update_core.LOG_FILE = dirs["update"], dirs["update"] / "state.json", dirs["update"] / "update.log" update_core.SOURCE_DIR = Path(__file__).resolve().parents[1] - for module in (app, network_core, host_profile): - module.run_cmd = conftest.fake_run_cmd + for module in (app, core, network_core, host_profile): + module.run_cmd = demo_run_cmd app.auth.verify_password = lambda user, password: True for name, size in (("ubuntu-26.04-live-server-amd64.iso", 3), ("debian-13.7.0-amd64-netinst.iso", 1)): @@ -49,6 +112,12 @@ def seed(root: Path) -> None: op_id = str(uuid.uuid4()) app.write_operation({"id": op_id, "type": "vm_create", "title": title, "status": status, "progress": progress, "message": "Готово" if status == "success" else "qemu-img: 64%" if status == "running" else "virt-install завершился с ошибкой: 1", "created_at": app.utc_now(), "updated_at": app.utc_now(), "created_by": "tester"}) app.append_operation_log(op_id, "virt-install --name web01 --memory 2048 ...") + # Завершённое клонирование 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_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 c911878..e65c34a 100644 --- a/web/app.py +++ b/web/app.py @@ -2016,3 +2016,7 @@ def api_health(request: Request): if not get_current_user(request): return JSONResponse({"ok": False, "error": "Unauthorized"}, status_code=401) return {"system": system_summary(), "host_profile": host_profile.load_host_profile(), "services": {"libvirtd": service_state("libvirtd.service"), "virtlogd": service_state("virtlogd.service"), "cockpit": service_state("cockpit.socket"), "dashboard": service_state("virtuality-console-dashboard.service"), "web": service_state("virtuality-web.service")}, "vms": parse_virsh_list(), "pools": parse_pool_list(), "network": network_summary(), "virtuality_nat": network_core.network_context()} + + +import features.vmops as vmops # noqa: E402 +app.include_router(vmops.router) diff --git a/web/features/__init__.py b/web/features/__init__.py new file mode 100644 index 0000000..e69de29 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 a8ec65e..4e84109 100644 --- a/web/presenters.py +++ b/web/presenters.py @@ -251,3 +251,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 33b98fa..259fb22 100644 --- a/web/static/app.css +++ b/web/static/app.css @@ -584,3 +584,28 @@ details.advanced > .advanced-body { padding: 4px 16px 16px; } .hide-sm { display: none !important; } } @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation: none !important; transition: none !important; } } + +/* ---------------------------------------------------------- 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 12ffba4..5046e6f 100644 --- a/web/static/icons.svg +++ b/web/static/icons.svg @@ -57,4 +57,6 @@ + + diff --git a/web/static/panel.js b/web/static/panel.js index 5581d03..5e29ff3 100644 --- a/web/static/panel.js +++ b/web/static/panel.js @@ -196,6 +196,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); @@ -466,6 +501,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' %} +
+

Нагрузка сейчас

Обновляется каждые 5 секунд

{{ icon('activity', 'icon-sm') }}
+
+
+
{{ icon('cpu', 'icon-sm') }}Процессор—
+
+
+
+
{{ icon('memory-stick', 'icon-sm') }}Память—
+
+
+
+
+{% endif %} +{% if vmops_saved(vm.dominfo) %} +
+
+
{{ icon('moon') }}
Машина в спящем режиме
При запуске она продолжит работу с того же места: открытые программы и окна останутся.
+
+
+
+{% endif %} +
+

Заметки

Название видно в списке машин

{{ icon('notebook-pen', 'icon-sm') }}
+
+
+
+ + +
+
+ + +
+
+ +
+
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 %} +{{ icon('copy') }}{{ item('Клонировать', 'Точная копия с новым именем', ops_hints) }} + 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 %}