Снимки машин (точки восстановления) были добавлены
Новая страница /vm/{name}/snapshots и вкладка «Снимки» на странице машины:
список внутренних снимков libvirt с описанием, датой, состоянием и меткой
«Текущий», создание снимка с автоматическим именем snap-ГГГГММДД-ЧЧММ,
возврат к снимку и удаление. Создание и возврат идут как фоновые задачи
с журналом; страница сама обновляется, когда задача завершится.
Ограничения и ошибки объясняются по-русски: не больше 8 снимков на машину,
только диски qcow2, предупреждение для машин с UEFI; известные ошибки libvirt
переводятся, а исходный текст показывается рядом. Возврат повторяется
с --force, если libvirt этого требует.
Модуль web/features/snapshots.py, парсеры с тестами на образцах вывода virsh,
fake_run_cmd в тестах расширен командами snapshot-*, demo-сервер показывает
снимки и идущую задачу. В спрайт добавлены иконки camera и history.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg
Этот коммит содержится в:
@@ -35,12 +35,37 @@ DUMPXML = """<domain type='kvm'>
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
FIXTURES = ROOT / "tests" / "fixtures"
|
||||||
|
|
||||||
|
# Снимки web01: имя → (состояние, описание); список берётся из fixtures/snapshot-list.txt.
|
||||||
|
SNAPSHOTS = {"before-upd": ("shutoff", "Перед обновлением ядра"), "clean": ("running", "Чистая система после установки")}
|
||||||
|
|
||||||
|
|
||||||
def fake_result(stdout: str = "", ok: bool = True, stderr: str = "") -> dict:
|
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": ""}
|
return {"ok": ok, "code": 0 if ok else 1, "stdout": stdout, "stderr": stderr, "cmd": ""}
|
||||||
|
|
||||||
|
|
||||||
|
def option(cmd, name):
|
||||||
|
"""Значение опции virsh вида --name VALUE (или '')."""
|
||||||
|
return cmd[cmd.index(name) + 1] if name in cmd and cmd.index(name) + 1 < len(cmd) else ""
|
||||||
|
|
||||||
|
|
||||||
|
def fake_snapshot_xml(snap: str) -> str:
|
||||||
|
state, description = SNAPSHOTS.get(snap, ("shutoff", ""))
|
||||||
|
memory = "<memory snapshot='internal'/>" if state == "running" else "<memory snapshot='no'/>"
|
||||||
|
return f"<domainsnapshot><name>{snap}</name><description>{description}</description><state>{state}</state>{memory}<creationTime>1758794400</creationTime></domainsnapshot>"
|
||||||
|
|
||||||
|
|
||||||
def fake_run_cmd(cmd, timeout=12, **_kwargs):
|
def fake_run_cmd(cmd, timeout=12, **_kwargs):
|
||||||
joined = " ".join(cmd)
|
joined = " ".join(cmd)
|
||||||
|
if cmd[:2] == ["virsh", "snapshot-list"]:
|
||||||
|
return fake_result((FIXTURES / "snapshot-list.txt").read_text())
|
||||||
|
if cmd[:2] == ["virsh", "snapshot-current"]:
|
||||||
|
return fake_result("clean")
|
||||||
|
if cmd[:2] == ["virsh", "snapshot-dumpxml"]:
|
||||||
|
return fake_result(fake_snapshot_xml(option(cmd, "--snapshotname")))
|
||||||
|
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 cmd[:2] == ["virsh", "list"]:
|
||||||
return fake_result(" Id Name State\n-----------------------\n 1 web01 running\n - db01 shut off")
|
return fake_result(" Id Name State\n-----------------------\n 1 web01 running\n - db01 shut off")
|
||||||
if cmd[:2] == ["virsh", "dominfo"]:
|
if cmd[:2] == ["virsh", "dominfo"]:
|
||||||
@@ -72,9 +97,15 @@ def fake_run_cmd(cmd, timeout=12, **_kwargs):
|
|||||||
return fake_result()
|
return fake_result()
|
||||||
|
|
||||||
|
|
||||||
|
def feature_modules():
|
||||||
|
"""Загруженные модули web/features/* — им тоже подменяем run_cmd."""
|
||||||
|
return [module for name, module in sys.modules.items() if name.startswith("features.") and hasattr(module, "run_cmd")]
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def data_dirs(tmp_path, monkeypatch):
|
def data_dirs(tmp_path, monkeypatch):
|
||||||
import app
|
import app
|
||||||
|
import core
|
||||||
import host_profile
|
import host_profile
|
||||||
import network_core
|
import network_core
|
||||||
import update_core
|
import update_core
|
||||||
@@ -95,6 +126,7 @@ def data_dirs(tmp_path, monkeypatch):
|
|||||||
monkeypatch.setattr(app, "IMAGES_DIR", dirs["images"])
|
monkeypatch.setattr(app, "IMAGES_DIR", dirs["images"])
|
||||||
monkeypatch.setattr(app, "DISK_IMAGES_DIR", dirs["disk_images"])
|
monkeypatch.setattr(app, "DISK_IMAGES_DIR", dirs["disk_images"])
|
||||||
monkeypatch.setattr(app, "OPERATIONS_DIR", dirs["operations"])
|
monkeypatch.setattr(app, "OPERATIONS_DIR", dirs["operations"])
|
||||||
|
monkeypatch.setattr(core, "OPERATIONS_DIR", dirs["operations"])
|
||||||
monkeypatch.setattr(network_core, "CONFIG_DIR", dirs["config"])
|
monkeypatch.setattr(network_core, "CONFIG_DIR", dirs["config"])
|
||||||
monkeypatch.setattr(network_core, "NETWORK_DIR", dirs["network"])
|
monkeypatch.setattr(network_core, "NETWORK_DIR", dirs["network"])
|
||||||
monkeypatch.setattr(network_core, "NFT_DIR", dirs["nft"])
|
monkeypatch.setattr(network_core, "NFT_DIR", dirs["nft"])
|
||||||
@@ -108,7 +140,7 @@ def data_dirs(tmp_path, monkeypatch):
|
|||||||
monkeypatch.setattr(update_core, "STATE_FILE", dirs["update"] / "state.json")
|
monkeypatch.setattr(update_core, "STATE_FILE", dirs["update"] / "state.json")
|
||||||
monkeypatch.setattr(update_core, "LOG_FILE", dirs["update"] / "update.log")
|
monkeypatch.setattr(update_core, "LOG_FILE", dirs["update"] / "update.log")
|
||||||
monkeypatch.setattr(update_core, "SOURCE_DIR", ROOT)
|
monkeypatch.setattr(update_core, "SOURCE_DIR", ROOT)
|
||||||
for module in (app, network_core, host_profile):
|
for module in (app, network_core, host_profile, core, *feature_modules()):
|
||||||
monkeypatch.setattr(module, "run_cmd", fake_run_cmd)
|
monkeypatch.setattr(module, "run_cmd", fake_run_cmd)
|
||||||
return dirs
|
return dirs
|
||||||
|
|
||||||
|
|||||||
@@ -13,16 +13,44 @@ sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|||||||
import conftest # noqa: E402 (sets sys.path and env)
|
import conftest # noqa: E402 (sets sys.path and env)
|
||||||
|
|
||||||
import app # noqa: E402
|
import app # noqa: E402
|
||||||
|
import core # noqa: E402
|
||||||
import host_profile # noqa: E402
|
import host_profile # noqa: E402
|
||||||
import network_core # noqa: E402
|
import network_core # noqa: E402
|
||||||
import update_core # noqa: E402
|
import update_core # noqa: E402
|
||||||
|
|
||||||
|
# Снимки для демо: имя, дата, состояние, описание.
|
||||||
|
DEMO_SNAPSHOTS = [
|
||||||
|
("snap-20260925-1012", "2026-09-25 10:12:03 +0000", "shutoff", "Перед обновлением ядра"),
|
||||||
|
("snap-20260924-0900", "2026-09-24 09:00:41 +0000", "running", "Настроен nginx и сертификаты"),
|
||||||
|
("snap-20260918-1830", "2026-09-18 18:30:12 +0000", "running", "Чистая система после установки"),
|
||||||
|
("clean", "2026-09-10 12:05:00 +0000", "shutoff", ""),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def demo_run_cmd(cmd, timeout=12, **kwargs):
|
||||||
|
"""Как conftest.fake_run_cmd, но со списком снимков побогаче."""
|
||||||
|
if cmd[:2] == ["virsh", "snapshot-list"]:
|
||||||
|
rows = "\n".join(f" {name:<20} {when} {state}" for name, when, state, _ in DEMO_SNAPSHOTS)
|
||||||
|
return conftest.fake_result(" Name Creation Time State\n" + "-" * 62 + "\n" + rows)
|
||||||
|
if cmd[:2] == ["virsh", "snapshot-current"]:
|
||||||
|
return conftest.fake_result("snap-20260924-0900")
|
||||||
|
if cmd[:2] == ["virsh", "snapshot-create-as"] and "uefi" in conftest.option(cmd, "--description").lower():
|
||||||
|
# Описание со словом «uefi» показывает, как выглядит ошибка libvirt.
|
||||||
|
return conftest.fake_result(ok=False, stderr="error: unsupported configuration: internal snapshots of a VM with pflash based firmware are not supported")
|
||||||
|
if cmd[:2] == ["virsh", "snapshot-dumpxml"]:
|
||||||
|
snap = conftest.option(cmd, "--snapshotname")
|
||||||
|
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"<domainsnapshot><name>{snap}</name><description>{description}</description><state>{state}</state><memory snapshot='{memory}'/></domainsnapshot>")
|
||||||
|
return conftest.fake_run_cmd(cmd, timeout, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
def seed(root: Path) -> None:
|
def seed(root: Path) -> None:
|
||||||
dirs = {name: root / name for name in ("iso", "images", "disk-images", "operations", "network", "config", "nft", "update")}
|
dirs = {name: root / name for name in ("iso", "images", "disk-images", "operations", "network", "config", "nft", "update")}
|
||||||
for path in dirs.values():
|
for path in dirs.values():
|
||||||
path.mkdir(parents=True, exist_ok=True)
|
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"]
|
app.ISO_DIR, app.IMAGES_DIR, app.DISK_IMAGES_DIR, app.OPERATIONS_DIR = dirs["iso"], dirs["images"], dirs["disk-images"], dirs["operations"]
|
||||||
|
core.OPERATIONS_DIR = dirs["operations"]
|
||||||
network_core.CONFIG_DIR, network_core.NETWORK_DIR, network_core.NFT_DIR = dirs["config"], dirs["network"], dirs["nft"]
|
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.PORT_FORWARDS_FILE = dirs["network"] / "port_forwards.json"
|
||||||
network_core.NAT_XML_FILE = dirs["network"] / "virtuality-nat.xml"
|
network_core.NAT_XML_FILE = dirs["network"] / "virtuality-nat.xml"
|
||||||
@@ -32,8 +60,8 @@ def seed(root: Path) -> None:
|
|||||||
host_profile.PROFILE_FILE = dirs["config"] / "host_profile.json"
|
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.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]
|
update_core.SOURCE_DIR = Path(__file__).resolve().parents[1]
|
||||||
for module in (app, network_core, host_profile):
|
for module in (app, network_core, host_profile, core, *conftest.feature_modules()):
|
||||||
module.run_cmd = conftest.fake_run_cmd
|
module.run_cmd = demo_run_cmd
|
||||||
app.auth.verify_password = lambda user, password: True
|
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)):
|
for name, size in (("ubuntu-26.04-live-server-amd64.iso", 3), ("debian-13.7.0-amd64-netinst.iso", 1)):
|
||||||
@@ -49,6 +77,9 @@ def seed(root: Path) -> None:
|
|||||||
op_id = str(uuid.uuid4())
|
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.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 ...")
|
app.append_operation_log(op_id, "virt-install --name web01 --memory 2048 ...")
|
||||||
|
# Идущий снимок машины db01 — видно на /vm/db01/snapshots.
|
||||||
|
snap_op = core.new_operation("snapshot_create", "Снимок машины db01", vm_name="db01", snapshot="snap-20260925-1500", vm_running=True)
|
||||||
|
core.update_operation(snap_op, status="running", progress=15, message="Сохраняем состояние машины и памяти — она на несколько секунд замрёт…")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -0,0 +1,285 @@
|
|||||||
|
"""Снимки (точки восстановления) машины: парсеры и маршруты /vm/{name}/snapshots."""
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import unquote
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import conftest
|
||||||
|
import core
|
||||||
|
from features import snapshots
|
||||||
|
|
||||||
|
FIXTURES = Path(__file__).resolve().parent / "fixtures"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- parsers
|
||||||
|
def test_parse_snapshot_list_fixture():
|
||||||
|
rows = snapshots.parse_snapshot_list((FIXTURES / "snapshot-list.txt").read_text())
|
||||||
|
assert [row["name"] for row in rows] == ["before-upd", "clean"] # новые сверху
|
||||||
|
assert rows[0] == {"name": "before-upd", "created_at": "2026-09-25 10:12:03", "tz": "+0000", "state": "shutoff"}
|
||||||
|
assert rows[1]["state"] == "running"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_snapshot_list_without_timezone_and_garbage():
|
||||||
|
text = " Name Creation Time State\n------\n snap1 2026-01-02 03:04:05 running\nerror: something\n\n"
|
||||||
|
rows = snapshots.parse_snapshot_list(text)
|
||||||
|
assert rows == [{"name": "snap1", "created_at": "2026-01-02 03:04:05", "tz": "", "state": "running"}]
|
||||||
|
assert snapshots.parse_snapshot_list("") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_snapshot_xml():
|
||||||
|
meta = snapshots.parse_snapshot_xml(conftest.fake_snapshot_xml("clean"))
|
||||||
|
assert meta["description"] == "Чистая система после установки"
|
||||||
|
assert meta["state"] == "running" and meta["with_memory"] is True
|
||||||
|
assert snapshots.parse_snapshot_xml(conftest.fake_snapshot_xml("before-upd"))["with_memory"] is False
|
||||||
|
assert snapshots.parse_snapshot_xml("<broken") == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_inspect_domain_xml_qcow2_fixture():
|
||||||
|
info = snapshots.inspect_domain_xml((FIXTURES / "dumpxml-web01.xml").read_text())
|
||||||
|
assert info["disks"] == [{"target": "vda", "format": "qcow2"}] # cdrom пропущен
|
||||||
|
assert info["unsupported"] == [] and info["uefi"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_inspect_domain_xml_raw_and_uefi():
|
||||||
|
xml = """<domain type='kvm'><name>x</name>
|
||||||
|
<os firmware='efi'><type arch='x86_64'>hvm</type><loader readonly='yes' type='pflash'>/usr/share/OVMF/OVMF_CODE.fd</loader><nvram>/var/lib/libvirt/qemu/nvram/x.fd</nvram></os>
|
||||||
|
<devices>
|
||||||
|
<disk type='file' device='disk'><driver name='qemu' type='raw'/><source file='/x.img'/><target dev='vda' bus='virtio'/></disk>
|
||||||
|
<disk type='file' device='disk'><driver name='qemu' type='qcow2'/><source file='/y.qcow2'/><target dev='vdb' bus='virtio'/></disk>
|
||||||
|
</devices></domain>"""
|
||||||
|
info = snapshots.inspect_domain_xml(xml)
|
||||||
|
assert info["unsupported"] == [{"target": "vda", "format": "raw"}]
|
||||||
|
assert info["uefi"] is True
|
||||||
|
assert snapshots.inspect_domain_xml("<nope") == {"disks": [], "unsupported": [], "uefi": False}
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_when():
|
||||||
|
now = datetime(2026, 9, 25, 12, 0, 0)
|
||||||
|
assert snapshots.format_when("2026-09-25 10:12:03", now) == "Сегодня, 10:12"
|
||||||
|
assert snapshots.format_when("2026-09-24 09:00:41", now) == "Вчера, 09:00"
|
||||||
|
assert snapshots.format_when("2026-03-08 18:30:00", now) == "8 марта 2026, 18:30"
|
||||||
|
assert snapshots.format_when("garbage", now) == "garbage"
|
||||||
|
|
||||||
|
|
||||||
|
def test_explain_error_translates_known_libvirt_messages():
|
||||||
|
text = snapshots.explain_error("error: unsupported configuration: internal snapshots of a VM with pflash based firmware are not supported")
|
||||||
|
assert text.startswith("Для машин с UEFI-прошивкой") and "libvirt:" in text
|
||||||
|
assert snapshots.explain_error("error: internal snapshot for disk vda unsupported for storage type raw").startswith("Внутренние снимки работают только с дисками qcow2")
|
||||||
|
assert snapshots.explain_error("error: Timed out during operation: cannot acquire state change lock").startswith("Машина занята")
|
||||||
|
assert snapshots.explain_error("error: something odd\nsecond line") == "something odd"
|
||||||
|
assert "не объяснил" in snapshots.explain_error("")
|
||||||
|
|
||||||
|
|
||||||
|
def test_next_snapshot_name_and_description():
|
||||||
|
now = datetime(2026, 9, 25, 10, 12)
|
||||||
|
assert snapshots.next_snapshot_name([], now) == "snap-20260925-1012"
|
||||||
|
assert snapshots.next_snapshot_name(["snap-20260925-1012"], now) == "snap-20260925-1012-2"
|
||||||
|
assert snapshots.next_snapshot_name(["snap-20260925-1012", "snap-20260925-1012-2"], now) == "snap-20260925-1012-3"
|
||||||
|
assert core.valid_label(snapshots.next_snapshot_name([], now))
|
||||||
|
assert snapshots.clean_description(" --force\x00 перед\nобновлением ") == "force перед обновлением"
|
||||||
|
assert len(snapshots.clean_description("x" * 500)) == snapshots.DESCRIPTION_MAX
|
||||||
|
|
||||||
|
|
||||||
|
def test_snapshot_state_labels():
|
||||||
|
assert snapshots.snapshot_state("running") == {"label": "Работала", "tone": "success"}
|
||||||
|
assert snapshots.snapshot_state("shutoff")["label"] == "Была выключена"
|
||||||
|
assert snapshots.snapshot_state("weird") == {"label": "weird", "tone": "neutral"}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- helpers for routes
|
||||||
|
@pytest.fixture()
|
||||||
|
def recorder(monkeypatch):
|
||||||
|
"""run_cmd, который записывает команды virsh и выполняет операции сразу (без потоков)."""
|
||||||
|
calls: list[list[str]] = []
|
||||||
|
|
||||||
|
def run(cmd, timeout=12, **kwargs):
|
||||||
|
calls.append(list(cmd))
|
||||||
|
return conftest.fake_run_cmd(cmd, timeout, **kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr(snapshots, "run_cmd", run)
|
||||||
|
monkeypatch.setattr(snapshots, "run_operation", lambda operation, worker: worker(operation))
|
||||||
|
return calls
|
||||||
|
|
||||||
|
|
||||||
|
def find(calls, *prefix):
|
||||||
|
return [cmd for cmd in calls if cmd[: len(prefix)] == list(prefix)]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- routes
|
||||||
|
def test_snapshots_page_requires_login(client):
|
||||||
|
response = client.get("/vm/web01/snapshots", follow_redirects=False)
|
||||||
|
assert response.status_code == 303 and response.headers["location"] == "/login"
|
||||||
|
|
||||||
|
|
||||||
|
def test_snapshots_page_renders_list(logged_in):
|
||||||
|
html = logged_in.get("/vm/web01/snapshots").text
|
||||||
|
assert "Перед обновлением ядра" in html and "Чистая система после установки" in html
|
||||||
|
assert "Текущий" in html and "Была выключена" in html and "Работала" in html
|
||||||
|
assert 'action="/vm/web01/snapshots/before-upd/revert"' in html
|
||||||
|
assert 'action="/vm/web01/snapshots/clean/delete"' in html
|
||||||
|
assert "2 из 8" in html
|
||||||
|
assert "snap-" in html # предложенное имя
|
||||||
|
assert "shut off" not in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_snapshots_page_unknown_vm_redirects_home(logged_in):
|
||||||
|
response = logged_in.get("/vm/ghost/snapshots", follow_redirects=False)
|
||||||
|
assert response.status_code == 303 and response.headers["location"] == "/"
|
||||||
|
assert logged_in.get("/vm/-bad/snapshots", follow_redirects=False).status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_vm_page_links_to_snapshots_tab(logged_in):
|
||||||
|
assert 'href="/vm/web01/snapshots"' in logged_in.get("/vm/web01").text
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_snapshot_runs_virsh_and_reports_operation(logged_in, data_dirs, recorder):
|
||||||
|
response = logged_in.post("/vm/web01/snapshots/create", data={"description": "Перед экспериментом"}, follow_redirects=False)
|
||||||
|
assert response.status_code == 303
|
||||||
|
location = response.headers["location"]
|
||||||
|
assert location.startswith("/vm/web01/snapshots?op=")
|
||||||
|
cmd = find(recorder, "virsh", "snapshot-create-as")[0]
|
||||||
|
assert cmd[2:6] == ["--domain", "web01", "--name", cmd[5]] and cmd[5].startswith("snap-")
|
||||||
|
assert cmd[6:] == ["--description", "Перед экспериментом", "--atomic"]
|
||||||
|
operation = core.read_operation(location.split("op=", 1)[1])
|
||||||
|
assert operation["status"] == "success" and operation["type"] == "snapshot_create" and operation["vm_name"] == "web01"
|
||||||
|
assert "создан" in operation["message"]
|
||||||
|
html = logged_in.get(location).text
|
||||||
|
assert "создан" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_snapshot_without_description_omits_option(logged_in, data_dirs, recorder):
|
||||||
|
logged_in.post("/vm/web01/snapshots/create", data={"description": " "}, follow_redirects=False)
|
||||||
|
cmd = find(recorder, "virsh", "snapshot-create-as")[0]
|
||||||
|
assert "--description" not in cmd and cmd[-1] == "--atomic"
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_snapshot_limit(logged_in, data_dirs, recorder, monkeypatch):
|
||||||
|
rows = "\n".join(f" snap-{i:02d} 2026-09-{i + 1:02d} 10:00:00 +0000 shutoff" for i in range(8))
|
||||||
|
original = snapshots.run_cmd
|
||||||
|
|
||||||
|
def run(cmd, timeout=12, **kwargs):
|
||||||
|
if cmd[:2] == ["virsh", "snapshot-list"]:
|
||||||
|
return conftest.fake_result(" Name Creation Time State\n---\n" + rows)
|
||||||
|
return original(cmd, timeout, **kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr(snapshots, "run_cmd", run)
|
||||||
|
response = logged_in.post("/vm/web01/snapshots/create", data={"description": "x"}, follow_redirects=False)
|
||||||
|
assert response.status_code == 303
|
||||||
|
assert "максимум" in unquote(response.headers["location"])
|
||||||
|
assert not find(recorder, "virsh", "snapshot-create-as")
|
||||||
|
html = logged_in.get("/vm/web01/snapshots").text
|
||||||
|
assert "8 из 8" in html and "максимум" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_snapshot_rejects_raw_disk(logged_in, data_dirs, recorder, monkeypatch):
|
||||||
|
original = snapshots.run_cmd
|
||||||
|
|
||||||
|
def run(cmd, timeout=12, **kwargs):
|
||||||
|
if cmd[:2] == ["virsh", "dumpxml"]:
|
||||||
|
return conftest.fake_result("<domain><devices><disk device='disk'><driver type='raw'/><target dev='vda'/></disk></devices></domain>")
|
||||||
|
return original(cmd, timeout, **kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr(snapshots, "run_cmd", run)
|
||||||
|
response = logged_in.post("/vm/web01/snapshots/create", data={"description": "x"}, follow_redirects=False)
|
||||||
|
assert "qcow2" in unquote(response.headers["location"])
|
||||||
|
assert not find(recorder, "virsh", "snapshot-create-as")
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_snapshot_surfaces_libvirt_error(logged_in, data_dirs, recorder, monkeypatch):
|
||||||
|
original = snapshots.run_cmd
|
||||||
|
|
||||||
|
def run(cmd, timeout=12, **kwargs):
|
||||||
|
if cmd[:2] == ["virsh", "snapshot-create-as"]:
|
||||||
|
return conftest.fake_result(ok=False, stderr="error: unsupported configuration: internal snapshots of a VM with pflash based firmware are not supported")
|
||||||
|
return original(cmd, timeout, **kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr(snapshots, "run_cmd", run)
|
||||||
|
response = logged_in.post("/vm/web01/snapshots/create", data={}, follow_redirects=False)
|
||||||
|
location = response.headers["location"]
|
||||||
|
operation = core.read_operation(location.split("op=", 1)[1])
|
||||||
|
assert operation["status"] == "error"
|
||||||
|
assert operation["message"].startswith("Для машин с UEFI-прошивкой") and "pflash" in operation["message"]
|
||||||
|
assert "pflash" in operation["log_tail"]
|
||||||
|
html = logged_in.get(location).text
|
||||||
|
assert "Для машин с UEFI-прошивкой" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_snapshot_waits_for_active_operation(logged_in, data_dirs, recorder):
|
||||||
|
core.new_operation("snapshot_create", "Снимок машины web01", vm_name="web01", snapshot="snap-x")
|
||||||
|
response = logged_in.post("/vm/web01/snapshots/create", data={}, follow_redirects=False)
|
||||||
|
assert "предыдущей операции" in unquote(response.headers["location"])
|
||||||
|
assert not find(recorder, "virsh", "snapshot-create-as")
|
||||||
|
html = logged_in.get("/vm/web01/snapshots").text
|
||||||
|
assert 'data-watch-operation=' in html and "/api/operations/" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_revert_snapshot(logged_in, data_dirs, recorder):
|
||||||
|
response = logged_in.post("/vm/web01/snapshots/clean/revert", follow_redirects=False)
|
||||||
|
assert response.status_code == 303
|
||||||
|
assert find(recorder, "virsh", "snapshot-revert") == [["virsh", "snapshot-revert", "--domain", "web01", "--snapshotname", "clean"]]
|
||||||
|
operation = core.read_operation(response.headers["location"].split("op=", 1)[1])
|
||||||
|
assert operation["status"] == "success" and "возвращена" in operation["message"] and "работает" in operation["message"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_revert_retries_with_force(logged_in, data_dirs, recorder, monkeypatch):
|
||||||
|
original = snapshots.run_cmd
|
||||||
|
|
||||||
|
def run(cmd, timeout=12, **kwargs):
|
||||||
|
if cmd[:2] == ["virsh", "snapshot-revert"] and "--force" not in cmd:
|
||||||
|
recorder.append(list(cmd))
|
||||||
|
return conftest.fake_result(ok=False, stderr="error: revert requires force: snapshot was created with a different configuration")
|
||||||
|
return original(cmd, timeout, **kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr(snapshots, "run_cmd", run)
|
||||||
|
response = logged_in.post("/vm/web01/snapshots/clean/revert", follow_redirects=False)
|
||||||
|
reverts = find(recorder, "virsh", "snapshot-revert")
|
||||||
|
assert len(reverts) == 2 and reverts[1][-1] == "--force"
|
||||||
|
assert core.read_operation(response.headers["location"].split("op=", 1)[1])["status"] == "success"
|
||||||
|
|
||||||
|
|
||||||
|
def test_revert_reports_error(logged_in, data_dirs, recorder, monkeypatch):
|
||||||
|
original = snapshots.run_cmd
|
||||||
|
|
||||||
|
def run(cmd, timeout=12, **kwargs):
|
||||||
|
if cmd[:2] == ["virsh", "snapshot-revert"]:
|
||||||
|
return conftest.fake_result(ok=False, stderr="error: Domain snapshot not found: no domain snapshot with matching name 'clean'")
|
||||||
|
return original(cmd, timeout, **kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr(snapshots, "run_cmd", run)
|
||||||
|
response = logged_in.post("/vm/web01/snapshots/clean/revert", follow_redirects=False)
|
||||||
|
operation = core.read_operation(response.headers["location"].split("op=", 1)[1])
|
||||||
|
assert operation["status"] == "error" and operation["message"].startswith("Такого снимка уже нет")
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_snapshot(logged_in, data_dirs, recorder):
|
||||||
|
response = logged_in.post("/vm/web01/snapshots/before-upd/delete", follow_redirects=False)
|
||||||
|
assert response.status_code == 303
|
||||||
|
assert "удалён" in unquote(response.headers["location"])
|
||||||
|
assert find(recorder, "virsh", "snapshot-delete") == [["virsh", "snapshot-delete", "--domain", "web01", "--snapshotname", "before-upd"]]
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_snapshot_error(logged_in, data_dirs, recorder, monkeypatch):
|
||||||
|
original = snapshots.run_cmd
|
||||||
|
|
||||||
|
def run(cmd, timeout=12, **kwargs):
|
||||||
|
if cmd[:2] == ["virsh", "snapshot-delete"]:
|
||||||
|
return conftest.fake_result(ok=False, stderr="error: Timed out during operation: cannot acquire state change lock")
|
||||||
|
return original(cmd, timeout, **kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr(snapshots, "run_cmd", run)
|
||||||
|
response = logged_in.post("/vm/web01/snapshots/before-upd/delete", follow_redirects=False)
|
||||||
|
assert "snapshot_error=" in response.headers["location"] and "занята" in unquote(response.headers["location"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_snapshot_routes_validate_names(logged_in, data_dirs, recorder):
|
||||||
|
assert logged_in.post("/vm/web01/snapshots/-bad/revert", follow_redirects=False).status_code == 400
|
||||||
|
assert logged_in.post("/vm/web01/snapshots/..%2Fx/delete", follow_redirects=False).status_code in (400, 404)
|
||||||
|
assert logged_in.post("/vm/-bad/snapshots/create", data={}, follow_redirects=False).status_code == 400
|
||||||
|
assert not find(recorder, "virsh", "snapshot-delete") and not find(recorder, "virsh", "snapshot-revert")
|
||||||
|
|
||||||
|
|
||||||
|
def test_snapshot_routes_require_login(client):
|
||||||
|
for path in ("/vm/web01/snapshots/create", "/vm/web01/snapshots/clean/revert", "/vm/web01/snapshots/clean/delete"):
|
||||||
|
response = client.post(path, follow_redirects=False)
|
||||||
|
assert response.status_code == 303 and response.headers["location"] == "/login"
|
||||||
@@ -2016,3 +2016,7 @@ def api_health(request: Request):
|
|||||||
if not get_current_user(request):
|
if not get_current_user(request):
|
||||||
return JSONResponse({"ok": False, "error": "Unauthorized"}, status_code=401)
|
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()}
|
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.snapshots as snapshots # noqa: E402
|
||||||
|
app.include_router(snapshots.router)
|
||||||
|
|||||||
@@ -0,0 +1,344 @@
|
|||||||
|
"""«Снимки» (точки восстановления) виртуальной машины — внутренние снимки
|
||||||
|
libvirt через `virsh snapshot-*`.
|
||||||
|
|
||||||
|
Снимок хранится внутри qcow2-диска машины. У работающей машины в снимок
|
||||||
|
попадает и память (машина на несколько секунд замирает), у выключенной —
|
||||||
|
только диск. Создание и возврат выполняются как фоновые операции.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Form, Request
|
||||||
|
from fastapi.responses import JSONResponse, RedirectResponse
|
||||||
|
|
||||||
|
import presenters
|
||||||
|
from core import (
|
||||||
|
append_operation_log,
|
||||||
|
cmd_error,
|
||||||
|
finish_operation,
|
||||||
|
list_operations,
|
||||||
|
new_operation,
|
||||||
|
read_operation,
|
||||||
|
redirect_with_message,
|
||||||
|
render,
|
||||||
|
require_auth,
|
||||||
|
run_cmd,
|
||||||
|
run_operation,
|
||||||
|
update_operation,
|
||||||
|
valid_label,
|
||||||
|
valid_vm_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
MAX_SNAPSHOTS = 8
|
||||||
|
DESCRIPTION_MAX = 200
|
||||||
|
SNAPSHOT_TIMEOUT = 1800 # секунд: снимок памяти большой машины идёт минуты
|
||||||
|
OPERATION_TYPES = ("snapshot_create", "snapshot_revert")
|
||||||
|
|
||||||
|
SNAPSHOT_STATES = {
|
||||||
|
"running": ("Работала", "success"),
|
||||||
|
"paused": ("Была на паузе", "warning"),
|
||||||
|
"shutoff": ("Была выключена", "neutral"),
|
||||||
|
"disk-snapshot": ("Только диск", "info"),
|
||||||
|
}
|
||||||
|
|
||||||
|
MONTHS = ["января", "февраля", "марта", "апреля", "мая", "июня", "июля", "августа", "сентября", "октября", "ноября", "декабря"]
|
||||||
|
|
||||||
|
# Известные ошибки libvirt → понятное объяснение (подстрока ищется без учёта регистра).
|
||||||
|
KNOWN_ERRORS = [
|
||||||
|
("pflash", "Для машин с UEFI-прошивкой эта версия libvirt не умеет делать внутренние снимки."),
|
||||||
|
("nvram", "Для машин с UEFI-прошивкой эта версия libvirt не умеет делать внутренние снимки."),
|
||||||
|
("unsupported for storage type", "Внутренние снимки работают только с дисками qcow2."),
|
||||||
|
("does not support snapshot", "Внутренние снимки работают только с дисками qcow2."),
|
||||||
|
("internal snapshot for disk", "Внутренние снимки работают только с дисками qcow2."),
|
||||||
|
("cannot acquire state change lock", "Машина занята другой операцией. Подождите минуту и попробуйте снова."),
|
||||||
|
("timed out during operation", "Машина занята другой операцией. Подождите минуту и попробуйте снова."),
|
||||||
|
("snapshot not found", "Такого снимка уже нет — возможно, его удалили."),
|
||||||
|
("already exists", "Снимок с таким именем уже есть."),
|
||||||
|
("no space left", "На диске сервера закончилось место."),
|
||||||
|
("is not running", "Машина выключена, поэтому это действие сейчас недоступно."),
|
||||||
|
]
|
||||||
|
|
||||||
|
_ROW_RE = re.compile(r"^\s*(\S+)\s+(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2}:\d{2})(?:\s+([+-]\d{4}))?\s+(\S+)\s*$")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- parsers
|
||||||
|
def parse_snapshot_list(text: str) -> list[dict[str, Any]]:
|
||||||
|
"""Таблица `virsh snapshot-list` → список снимков, новые сверху."""
|
||||||
|
rows = []
|
||||||
|
for line in (text or "").splitlines():
|
||||||
|
match = _ROW_RE.match(line)
|
||||||
|
if not match:
|
||||||
|
continue
|
||||||
|
name, date, time, tz, state = match.groups()
|
||||||
|
rows.append({"name": name, "created_at": f"{date} {time}", "tz": tz or "", "state": state})
|
||||||
|
return sorted(rows, key=lambda row: row["created_at"], reverse=True)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_snapshot_xml(text: str) -> dict[str, Any]:
|
||||||
|
"""`virsh snapshot-dumpxml` → описание, состояние, есть ли память."""
|
||||||
|
try:
|
||||||
|
root = ET.fromstring(text or "")
|
||||||
|
except ET.ParseError:
|
||||||
|
return {}
|
||||||
|
memory = root.find("memory")
|
||||||
|
parent = root.find("parent/name")
|
||||||
|
return {
|
||||||
|
"description": (root.findtext("description") or "").strip(),
|
||||||
|
"state": (root.findtext("state") or "").strip(),
|
||||||
|
"with_memory": memory is not None and memory.get("snapshot", "no") not in ("no", ""),
|
||||||
|
"parent": (parent.text or "").strip() if parent is not None else "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def inspect_domain_xml(text: str) -> dict[str, Any]:
|
||||||
|
"""`virsh dumpxml` → форматы дисков и признак UEFI (для проверок перед снимком)."""
|
||||||
|
info: dict[str, Any] = {"disks": [], "unsupported": [], "uefi": False}
|
||||||
|
try:
|
||||||
|
root = ET.fromstring(text or "")
|
||||||
|
except ET.ParseError:
|
||||||
|
return info
|
||||||
|
for disk in root.iter("disk"):
|
||||||
|
if disk.get("device", "disk") != "disk":
|
||||||
|
continue
|
||||||
|
driver = disk.find("driver")
|
||||||
|
target = disk.find("target")
|
||||||
|
fmt = (driver.get("type") if driver is not None else "") or ""
|
||||||
|
dev = (target.get("dev") if target is not None else "") or "?"
|
||||||
|
info["disks"].append({"target": dev, "format": fmt or "неизвестно"})
|
||||||
|
if fmt and fmt != "qcow2":
|
||||||
|
info["unsupported"].append({"target": dev, "format": fmt})
|
||||||
|
os_node = root.find("os")
|
||||||
|
if os_node is not None:
|
||||||
|
loader = os_node.find("loader")
|
||||||
|
info["uefi"] = os_node.get("firmware") == "efi" or os_node.find("nvram") is not None or (loader is not None and loader.get("type") == "pflash")
|
||||||
|
return info
|
||||||
|
|
||||||
|
|
||||||
|
def snapshot_state(state: str) -> dict[str, str]:
|
||||||
|
label, tone = SNAPSHOT_STATES.get((state or "").strip().lower(), (state or "—", "neutral"))
|
||||||
|
return {"label": label, "tone": tone}
|
||||||
|
|
||||||
|
|
||||||
|
def format_when(created_at: str, now: datetime | None = None) -> str:
|
||||||
|
"""'2026-09-25 10:12:03' → 'Сегодня, 10:12' / 'Вчера, 10:12' / '25 сентября 2026, 10:12'."""
|
||||||
|
try:
|
||||||
|
moment = datetime.strptime(created_at, "%Y-%m-%d %H:%M:%S")
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return created_at or "—"
|
||||||
|
today = (now or datetime.now()).date()
|
||||||
|
clock = moment.strftime("%H:%M")
|
||||||
|
if moment.date() == today:
|
||||||
|
return f"Сегодня, {clock}"
|
||||||
|
if moment.date() == today - timedelta(days=1):
|
||||||
|
return f"Вчера, {clock}"
|
||||||
|
return f"{moment.day} {MONTHS[moment.month - 1]} {moment.year}, {clock}"
|
||||||
|
|
||||||
|
|
||||||
|
def explain_error(raw: str) -> str:
|
||||||
|
"""Текст ошибки libvirt → понятная фраза; технический текст остаётся в скобках."""
|
||||||
|
text = re.sub(r"^error:\s*", "", (raw or "").strip(), flags=re.IGNORECASE)
|
||||||
|
first = text.splitlines()[0] if text else ""
|
||||||
|
low = first.lower()
|
||||||
|
for needle, explanation in KNOWN_ERRORS:
|
||||||
|
if needle in low:
|
||||||
|
return f"{explanation} (libvirt: {first})"
|
||||||
|
return first or "libvirt не выполнил команду и не объяснил причину."
|
||||||
|
|
||||||
|
|
||||||
|
def next_snapshot_name(existing: list[str], now: datetime | None = None) -> str:
|
||||||
|
stamp = (now or datetime.now()).strftime("snap-%Y%m%d-%H%M")
|
||||||
|
name, counter = stamp, 2
|
||||||
|
while name in existing:
|
||||||
|
name, counter = f"{stamp}-{counter}", counter + 1
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def clean_description(text: str) -> str:
|
||||||
|
text = re.sub(r"[\x00-\x1f\x7f]+", " ", text or "")
|
||||||
|
return re.sub(r"\s+", " ", text).strip().lstrip("-").strip()[:DESCRIPTION_MAX]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- virsh
|
||||||
|
def current_snapshot(vm: str) -> str:
|
||||||
|
result = run_cmd(["virsh", "snapshot-current", "--domain", vm, "--name"], timeout=10)
|
||||||
|
return result["stdout"].strip() if result["ok"] else ""
|
||||||
|
|
||||||
|
|
||||||
|
def list_snapshots(vm: str) -> list[dict[str, Any]]:
|
||||||
|
result = run_cmd(["virsh", "snapshot-list", "--domain", vm], timeout=20)
|
||||||
|
rows = parse_snapshot_list(result["stdout"]) if result["ok"] else []
|
||||||
|
current = current_snapshot(vm) if rows else ""
|
||||||
|
for row in rows:
|
||||||
|
xml = run_cmd(["virsh", "snapshot-dumpxml", "--domain", vm, "--snapshotname", row["name"]], timeout=10)
|
||||||
|
meta = parse_snapshot_xml(xml["stdout"]) if xml["ok"] else {}
|
||||||
|
row["description"] = meta.get("description", "")
|
||||||
|
row["with_memory"] = meta.get("with_memory", row["state"] == "running")
|
||||||
|
row["is_current"] = row["name"] == current
|
||||||
|
row["state_info"] = snapshot_state(row["state"])
|
||||||
|
row["when"] = format_when(row["created_at"])
|
||||||
|
row["title"] = row["description"] or row["name"]
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def domain_checks(vm: str) -> dict[str, Any]:
|
||||||
|
result = run_cmd(["virsh", "dumpxml", vm], timeout=10)
|
||||||
|
return inspect_domain_xml(result["stdout"] if result["ok"] else "")
|
||||||
|
|
||||||
|
|
||||||
|
def vm_operations(vm: str) -> list[dict[str, Any]]:
|
||||||
|
return [op for op in list_operations(50) if op.get("type") in OPERATION_TYPES and op.get("vm_name") == vm]
|
||||||
|
|
||||||
|
|
||||||
|
def active_operations(vm: str) -> list[dict[str, Any]]:
|
||||||
|
return [op for op in vm_operations(vm) if op.get("status") in ("queued", "running")]
|
||||||
|
|
||||||
|
|
||||||
|
def create_blockers(vm: str, snapshots: list[dict[str, Any]], checks: dict[str, Any]) -> list[str]:
|
||||||
|
"""Почему снимок сейчас нельзя создать (пустой список — можно)."""
|
||||||
|
reasons = []
|
||||||
|
if len(snapshots) >= MAX_SNAPSHOTS:
|
||||||
|
reasons.append(f"Уже {MAX_SNAPSHOTS} снимков — это максимум для одной машины. Удалите ненужный снимок, чтобы создать новый.")
|
||||||
|
if checks.get("unsupported"):
|
||||||
|
disks = ", ".join(f"{d['target']} ({d['format']})" for d in checks["unsupported"])
|
||||||
|
reasons.append(f"Внутренние снимки работают только с дисками qcow2, а у этой машины диск другого формата: {disks}.")
|
||||||
|
if active_operations(vm):
|
||||||
|
reasons.append("Дождитесь окончания предыдущей операции со снимками этой машины.")
|
||||||
|
return reasons
|
||||||
|
|
||||||
|
|
||||||
|
def _log_result(operation: dict[str, Any], result: dict[str, Any]) -> None:
|
||||||
|
for stream in ("stdout", "stderr"):
|
||||||
|
if result.get(stream):
|
||||||
|
append_operation_log(operation["id"], result[stream])
|
||||||
|
|
||||||
|
|
||||||
|
def create_worker(operation: dict[str, Any]) -> None:
|
||||||
|
vm, snap, description = operation["vm_name"], operation["snapshot"], operation.get("description", "")
|
||||||
|
cmd = ["virsh", "snapshot-create-as", "--domain", vm, "--name", snap]
|
||||||
|
if description:
|
||||||
|
cmd += ["--description", description]
|
||||||
|
cmd.append("--atomic")
|
||||||
|
append_operation_log(operation["id"], "$ " + " ".join(cmd))
|
||||||
|
update_operation(operation, progress=15, message="Сохраняем состояние машины и памяти — она на несколько секунд замрёт…" if operation.get("vm_running") else "Сохраняем состояние диска…", cmd=" ".join(cmd))
|
||||||
|
result = run_cmd(cmd, timeout=SNAPSHOT_TIMEOUT)
|
||||||
|
_log_result(operation, result)
|
||||||
|
if result["ok"]:
|
||||||
|
finish_operation(operation, True, f"Снимок «{snap}» создан")
|
||||||
|
else:
|
||||||
|
finish_operation(operation, False, explain_error(cmd_error(result, "virsh завершился с ошибкой")))
|
||||||
|
|
||||||
|
|
||||||
|
def revert_worker(operation: dict[str, Any]) -> None:
|
||||||
|
vm, snap = operation["vm_name"], operation["snapshot"]
|
||||||
|
cmd = ["virsh", "snapshot-revert", "--domain", vm, "--snapshotname", snap]
|
||||||
|
append_operation_log(operation["id"], "$ " + " ".join(cmd))
|
||||||
|
update_operation(operation, progress=15, message="Возвращаем машину к сохранённому состоянию…", cmd=" ".join(cmd))
|
||||||
|
result = run_cmd(cmd, timeout=SNAPSHOT_TIMEOUT)
|
||||||
|
_log_result(operation, result)
|
||||||
|
if not result["ok"] and "force" in (result.get("stderr") or "").lower():
|
||||||
|
# libvirt просит --force, когда конфигурация машины изменилась после снимка: пользователь уже подтвердил возврат.
|
||||||
|
append_operation_log(operation["id"], "libvirt требует подтверждения (--force): повторяем принудительно.")
|
||||||
|
cmd.append("--force")
|
||||||
|
append_operation_log(operation["id"], "$ " + " ".join(cmd))
|
||||||
|
result = run_cmd(cmd, timeout=SNAPSHOT_TIMEOUT)
|
||||||
|
_log_result(operation, result)
|
||||||
|
if not result["ok"]:
|
||||||
|
finish_operation(operation, False, explain_error(cmd_error(result, "virsh завершился с ошибкой")))
|
||||||
|
return
|
||||||
|
state = presenters.vm_state(run_cmd(["virsh", "domstate", vm], timeout=8)["stdout"])
|
||||||
|
finish_operation(operation, True, f"Машина возвращена к снимку «{snap}». Сейчас она {'работает' if state['running'] else 'выключена'}.")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- routes
|
||||||
|
def _guard(request: Request, name: str):
|
||||||
|
"""Редирект на /login, 400 для плохого имени, редирект на / для несуществующей машины — или None + dominfo."""
|
||||||
|
auth_redirect = require_auth(request)
|
||||||
|
if auth_redirect:
|
||||||
|
return auth_redirect, ""
|
||||||
|
if not valid_vm_name(name):
|
||||||
|
return JSONResponse({"ok": False, "error": "Invalid VM name"}, status_code=400), ""
|
||||||
|
dominfo = run_cmd(["virsh", "dominfo", name], timeout=8)
|
||||||
|
if not dominfo["ok"]:
|
||||||
|
return RedirectResponse(url="/", status_code=303), ""
|
||||||
|
return None, dominfo["stdout"]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/vm/{name}/snapshots")
|
||||||
|
def snapshots_page(request: Request, name: str):
|
||||||
|
blocked, dominfo = _guard(request, name)
|
||||||
|
if blocked:
|
||||||
|
return blocked
|
||||||
|
info = presenters.parse_dominfo(dominfo)
|
||||||
|
snapshots = list_snapshots(name)
|
||||||
|
checks = domain_checks(name)
|
||||||
|
running = presenters.vm_state(info["state"])["running"]
|
||||||
|
op_id = request.query_params.get("op", "")
|
||||||
|
operation = read_operation(op_id) if op_id else None
|
||||||
|
if operation and (operation.get("vm_name") != name or operation.get("type") not in OPERATION_TYPES):
|
||||||
|
operation = None
|
||||||
|
return render(request, "vm_snapshots.html", {
|
||||||
|
"vm": {"name": name, "info": info, "dominfo": dominfo},
|
||||||
|
"vm_running": running,
|
||||||
|
"snapshots": snapshots,
|
||||||
|
"max_snapshots": MAX_SNAPSHOTS,
|
||||||
|
"checks": checks,
|
||||||
|
"blockers": create_blockers(name, snapshots, checks),
|
||||||
|
"suggested_name": next_snapshot_name([s["name"] for s in snapshots]),
|
||||||
|
"description_max": DESCRIPTION_MAX,
|
||||||
|
"operation": operation,
|
||||||
|
"active_operations": active_operations(name),
|
||||||
|
"snapshot_message": request.query_params.get("snapshot_message", ""),
|
||||||
|
"snapshot_error": request.query_params.get("snapshot_error", ""),
|
||||||
|
"raw_list": run_cmd(["virsh", "snapshot-list", "--domain", name], timeout=20)["stdout"],
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/vm/{name}/snapshots/create")
|
||||||
|
def snapshot_create(request: Request, name: str, description: str = Form("")):
|
||||||
|
blocked, dominfo = _guard(request, name)
|
||||||
|
if blocked:
|
||||||
|
return blocked
|
||||||
|
snapshots = list_snapshots(name)
|
||||||
|
blockers = create_blockers(name, snapshots, domain_checks(name))
|
||||||
|
if blockers:
|
||||||
|
return redirect_with_message(f"/vm/{name}/snapshots", "snapshot_error", blockers[0])
|
||||||
|
running = presenters.vm_state(presenters.parse_dominfo(dominfo)["state"])["running"]
|
||||||
|
snap = next_snapshot_name([s["name"] for s in snapshots])
|
||||||
|
if not valid_label(snap):
|
||||||
|
return redirect_with_message(f"/vm/{name}/snapshots", "snapshot_error", "Не удалось подобрать имя снимка.")
|
||||||
|
operation = new_operation("snapshot_create", f"Снимок машины {name}", vm_name=name, snapshot=snap, description=clean_description(description), vm_running=running)
|
||||||
|
run_operation(operation, create_worker)
|
||||||
|
return RedirectResponse(url=f"/vm/{name}/snapshots?op={operation['id']}", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/vm/{name}/snapshots/{snap}/revert")
|
||||||
|
def snapshot_revert(request: Request, name: str, snap: str):
|
||||||
|
blocked, _ = _guard(request, name)
|
||||||
|
if blocked:
|
||||||
|
return blocked
|
||||||
|
if not valid_label(snap):
|
||||||
|
return JSONResponse({"ok": False, "error": "Invalid snapshot name"}, status_code=400)
|
||||||
|
if active_operations(name):
|
||||||
|
return redirect_with_message(f"/vm/{name}/snapshots", "snapshot_error", "Дождитесь окончания предыдущей операции со снимками этой машины.")
|
||||||
|
operation = new_operation("snapshot_revert", f"Возврат машины {name} к снимку {snap}", vm_name=name, snapshot=snap)
|
||||||
|
run_operation(operation, revert_worker)
|
||||||
|
return RedirectResponse(url=f"/vm/{name}/snapshots?op={operation['id']}", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/vm/{name}/snapshots/{snap}/delete")
|
||||||
|
def snapshot_delete(request: Request, name: str, snap: str):
|
||||||
|
blocked, _ = _guard(request, name)
|
||||||
|
if blocked:
|
||||||
|
return blocked
|
||||||
|
if not valid_label(snap):
|
||||||
|
return JSONResponse({"ok": False, "error": "Invalid snapshot name"}, status_code=400)
|
||||||
|
if active_operations(name):
|
||||||
|
return redirect_with_message(f"/vm/{name}/snapshots", "snapshot_error", "Дождитесь окончания предыдущей операции со снимками этой машины.")
|
||||||
|
result = run_cmd(["virsh", "snapshot-delete", "--domain", name, "--snapshotname", snap], timeout=180)
|
||||||
|
if not result["ok"]:
|
||||||
|
return redirect_with_message(f"/vm/{name}/snapshots", "snapshot_error", "Не удалось удалить снимок: " + explain_error(cmd_error(result, "virsh завершился с ошибкой")))
|
||||||
|
return redirect_with_message(f"/vm/{name}/snapshots", "snapshot_message", f"Снимок «{snap}» удалён")
|
||||||
@@ -57,4 +57,6 @@
|
|||||||
<symbol id="i-wrench" viewBox="0 0 24 24"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.106-3.105c.32-.322.863-.22.983.218a6 6 0 0 1-8.259 7.057l-7.91 7.91a1 1 0 0 1-2.999-3l7.91-7.91a6 6 0 0 1 7.057-8.259c.438.12.54.662.219.984z" /></symbol>
|
<symbol id="i-wrench" viewBox="0 0 24 24"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.106-3.105c.32-.322.863-.22.983.218a6 6 0 0 1-8.259 7.057l-7.91 7.91a1 1 0 0 1-2.999-3l7.91-7.91a6 6 0 0 1 7.057-8.259c.438.12.54.662.219.984z" /></symbol>
|
||||||
<symbol id="i-circle-dot" viewBox="0 0 24 24"><circle cx="12" cy="12" r="1" /><circle cx="12" cy="12" r="10" /></symbol>
|
<symbol id="i-circle-dot" viewBox="0 0 24 24"><circle cx="12" cy="12" r="1" /><circle cx="12" cy="12" r="10" /></symbol>
|
||||||
<symbol id="i-plug" viewBox="0 0 24 24"><path d="M12 22v-5" /><path d="M15 8V2" /><path d="M17 8a1 1 0 0 1 1 1v4a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4V9a1 1 0 0 1 1-1z" /><path d="M9 8V2" /></symbol>
|
<symbol id="i-plug" viewBox="0 0 24 24"><path d="M12 22v-5" /><path d="M15 8V2" /><path d="M17 8a1 1 0 0 1 1 1v4a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4V9a1 1 0 0 1 1-1z" /><path d="M9 8V2" /></symbol>
|
||||||
|
<symbol id="i-camera" viewBox="0 0 24 24"><path d="M13.997 4a2 2 0 0 1 1.76 1.05l.486.9A2 2 0 0 0 18.003 7H20a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2h1.997a2 2 0 0 0 1.759-1.048l.489-.904A2 2 0 0 1 10.004 4z" /><circle cx="12" cy="13" r="3" /></symbol>
|
||||||
|
<symbol id="i-history" viewBox="0 0 24 24"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" /><path d="M3 3v5h5" /><path d="M12 7v5l4 2" /></symbol>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
До Ширина: | Высота: | Размер: 10 KiB После Ширина: | Высота: | Размер: 11 KiB |
@@ -53,6 +53,7 @@
|
|||||||
<button type="button" data-tab="overview" role="tab">{{ icon('layout-dashboard', 'icon-sm') }}Обзор</button>
|
<button type="button" data-tab="overview" role="tab">{{ icon('layout-dashboard', 'icon-sm') }}Обзор</button>
|
||||||
<button type="button" data-tab="resources" role="tab">{{ icon('cpu', 'icon-sm') }}Ресурсы</button>
|
<button type="button" data-tab="resources" role="tab">{{ icon('cpu', 'icon-sm') }}Ресурсы</button>
|
||||||
<button type="button" data-tab="storage" role="tab">{{ icon('hard-drive', 'icon-sm') }}Диски и загрузка</button>
|
<button type="button" data-tab="storage" role="tab">{{ icon('hard-drive', 'icon-sm') }}Диски и загрузка</button>
|
||||||
|
<a href="/vm/{{ vm.name }}/snapshots">{{ icon('camera', 'icon-sm') }}Снимки</a>
|
||||||
<button type="button" data-tab="expert" role="tab">{{ icon('terminal', 'icon-sm') }}Для специалистов</button>
|
<button type="button" data-tab="expert" role="tab">{{ icon('terminal', 'icon-sm') }}Для специалистов</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% from "_ui.html" import icon, badge, vm_badge, op_badge, empty, alert %}
|
||||||
|
{% set page_title = 'Снимки — ' ~ vm.name %}
|
||||||
|
{% set s = vm.info.state|vm_state %}
|
||||||
|
{% set count = snapshots|length %}
|
||||||
|
|
||||||
|
{% block page_header %}
|
||||||
|
<div class="page-header" data-live-vm="{{ vm.name }}" data-running="{{ '1' if s.running else '0' }}" data-reload-on-change>
|
||||||
|
<div>
|
||||||
|
<div class="eyebrow"><a href="/">{{ icon('arrow-left', 'icon-sm') }} Все машины</a></div>
|
||||||
|
<div class="title-row">
|
||||||
|
<span class="vm-avatar {{ 'on' if s.running else '' }}" style="width:48px;height:48px">{{ icon('monitor', 'icon-lg') }}<span class="status-dot {{ s.tone }}" data-live="dot"></span></span>
|
||||||
|
<div>
|
||||||
|
<div class="row"><h1>{{ vm.name }}</h1>{{ vm_badge(vm.info.state, 'lg') }}</div>
|
||||||
|
<p class="subtle mt-1">{{ vm.info.vcpus }} {{ plural(vm.info.vcpus, 'ядро', 'ядра', 'ядер') }} · {{ vm.info.memory_mb|format_mb }} памяти · {{ count }} {{ plural(count, 'снимок', 'снимка', 'снимков') }} из {{ max_snapshots }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="page-actions">
|
||||||
|
<a class="btn btn-primary{{ ' disabled' if blockers else '' }}" href="#create" {% if blockers %}aria-disabled="true"{% endif %}>{{ icon('camera') }}Создать снимок</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block alerts %}
|
||||||
|
{% if snapshot_message %}{{ alert('success', snapshot_message) }}{% endif %}
|
||||||
|
{% if snapshot_error %}{{ alert('danger', snapshot_error) }}{% endif %}
|
||||||
|
{% if operation and operation.status == 'success' %}{{ alert('success', operation.message) }}{% endif %}
|
||||||
|
{% if operation and operation.status == 'error' %}{{ alert('danger', operation.message, title='Не получилось: ' ~ operation.title|lower) }}{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<nav class="tabs">
|
||||||
|
<a href="/vm/{{ vm.name }}#overview">{{ icon('layout-dashboard', 'icon-sm') }}Обзор</a>
|
||||||
|
<a href="/vm/{{ vm.name }}#resources">{{ icon('cpu', 'icon-sm') }}Ресурсы</a>
|
||||||
|
<a href="/vm/{{ vm.name }}#storage">{{ icon('hard-drive', 'icon-sm') }}Диски и загрузка</a>
|
||||||
|
<a href="/vm/{{ vm.name }}/snapshots" class="active" aria-current="page">{{ icon('camera', 'icon-sm') }}Снимки<span class="tag">{{ count }}</span></a>
|
||||||
|
<a href="/vm/{{ vm.name }}#expert">{{ icon('terminal', 'icon-sm') }}Для специалистов</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<section class="grid grid-main">
|
||||||
|
<div class="stack">
|
||||||
|
{% for op in active_operations %}
|
||||||
|
<div class="alert info" role="status" data-watch-operation="{{ op.id }}">
|
||||||
|
<span class="spinner" style="margin-top:1px"></span>
|
||||||
|
<div class="grow">
|
||||||
|
<strong>{{ op.title }}</strong>
|
||||||
|
<span>{{ op.message }}</span>
|
||||||
|
<div class="progress indeterminate mt-2" style="max-width:360px"><span></span></div>
|
||||||
|
</div>
|
||||||
|
<a class="btn btn-sm" href="/operations/{{ op.id }}">Подробнее{{ icon('chevron-right', 'icon-sm') }}</a>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<article class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<div><h2>Точки восстановления</h2><p>Сохранённые состояния машины, к которым можно вернуться</p></div>
|
||||||
|
<span class="tag nowrap" title="Не больше {{ max_snapshots }} снимков на одну машину">{{ count }} из {{ max_snapshots }}</span>
|
||||||
|
</div>
|
||||||
|
{% if snapshots %}
|
||||||
|
<div class="list">
|
||||||
|
{% for snap in snapshots %}
|
||||||
|
<div class="list-item">
|
||||||
|
<span class="list-icon {{ 'brand' if snap.is_current else '' }}">{{ icon('camera') }}</span>
|
||||||
|
<div class="grow">
|
||||||
|
<div class="row" style="gap:8px">
|
||||||
|
<span class="title truncate">{{ snap.title }}</span>
|
||||||
|
{% if snap.is_current %}{{ badge('Текущий', 'brand', dot=False, attrs='title="Машина сейчас продолжает работу от этого снимка"') }}{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="meta">{{ snap.when }} · {{ snap.state_info.label }}{% if snap.with_memory %} — сохранена вместе с памятью{% endif %}{% if snap.description %} · <span class="mono">{{ snap.name }}</span>{% endif %}</div>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="/vm/{{ vm.name }}/snapshots/{{ snap.name }}/revert" class="inline-form"
|
||||||
|
data-confirm="Машина {{ vm.name }} вернётся в состояние на момент «{{ snap.when|lower }}». Всё, что изменилось после этого — файлы, программы, настройки — будет потеряно без возможности восстановления. {% if snap.with_memory %}После возврата машина будет работать, как в момент снимка.{% else %}После возврата машина будет выключена.{% endif %}"
|
||||||
|
data-confirm-title="Вернуться к этому снимку?" data-confirm-button="Вернуться">
|
||||||
|
<button class="btn btn-sm">{{ icon('history', 'icon-sm') }}<span class="hide-sm">Вернуться</span></button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/vm/{{ vm.name }}/snapshots/{{ snap.name }}/delete" class="inline-form"
|
||||||
|
data-confirm="Снимок «{{ snap.title }}» будет удалён. Сама машина не изменится — пропадёт только возможность вернуться к этому состоянию."
|
||||||
|
data-confirm-title="Удалить снимок?" data-confirm-button="Удалить" data-toast="Снимок удалён" data-toast-tone="warning">
|
||||||
|
<button class="btn btn-sm btn-icon btn-ghost" aria-label="Удалить снимок" title="Удалить снимок">{{ icon('trash-2', 'icon-sm') }}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
{{ empty('camera', 'Снимков пока нет', 'Сделайте снимок перед обновлением или рискованной настройкой. Если что-то пойдёт не так, машину можно будет вернуть назад за минуту.') }}
|
||||||
|
{% endif %}
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<details class="advanced">
|
||||||
|
<summary>{{ icon('terminal', 'icon-sm') }}Для специалистов<span class="chev">{{ icon('chevron-down', 'icon-sm') }}</span></summary>
|
||||||
|
<div class="advanced-body stack-sm">
|
||||||
|
<p class="subtle small">Это внутренние снимки libvirt (<code>virsh snapshot-create-as … --atomic</code>): они хранятся внутри qcow2-файла диска. Возврат — <code>virsh snapshot-revert</code>, удаление — <code>virsh snapshot-delete</code>.</p>
|
||||||
|
<div>
|
||||||
|
<div class="terminal-head"><i></i><i></i><i></i><span style="margin-left:6px">virsh snapshot-list --domain {{ vm.name }}</span></div>
|
||||||
|
<pre class="terminal">{{ raw_list or '—' }}</pre>
|
||||||
|
</div>
|
||||||
|
{% if checks.disks %}
|
||||||
|
<p class="subtle small">Диски: {% for d in checks.disks %}{{ d.target }} — {{ d.format }}{{ ', ' if not loop.last }}{% endfor %}{% if checks.uefi %} · прошивка UEFI{% endif %}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside class="stack">
|
||||||
|
<article class="card" id="create">
|
||||||
|
<div class="card-header"><div><h2>Создать снимок</h2><p>Запомнить состояние машины прямо сейчас</p></div></div>
|
||||||
|
<form method="post" action="/vm/{{ vm.name }}/snapshots/create" data-busy="Создаём снимок…">
|
||||||
|
<div class="card-body stack-sm">
|
||||||
|
<div class="field">
|
||||||
|
<label for="description">Описание</label>
|
||||||
|
<input class="input" id="description" name="description" maxlength="{{ description_max }}" placeholder="Например: перед обновлением до Ubuntu 26.04" {% if blockers %}disabled{% endif %}>
|
||||||
|
<span class="hint">Необязательно, но так проще узнать снимок в списке. Имя снимка задаётся автоматически: <span class="mono">{{ suggested_name }}</span></span>
|
||||||
|
</div>
|
||||||
|
{% if blockers %}
|
||||||
|
{% for reason in blockers %}{{ alert('warning', reason) }}{% endfor %}
|
||||||
|
{% elif vm_running %}
|
||||||
|
{{ alert('info', 'Машина работает, поэтому в снимок попадёт и содержимое памяти. На несколько секунд машина замрёт — так и должно быть. После возврата она продолжит работу с того же места.', title='Снимок работающей машины') }}
|
||||||
|
{% else %}
|
||||||
|
{{ alert('info', 'Машина выключена, поэтому в снимок попадёт только диск. Это быстро и не занимает лишнего места.', title='Снимок выключенной машины') }}
|
||||||
|
{% endif %}
|
||||||
|
{% if checks.uefi and not blockers %}
|
||||||
|
{{ alert('warning', 'У этой машины прошивка UEFI. Некоторые версии libvirt не умеют делать для таких машин внутренние снимки — если так, мы покажем ответ libvirt.') }}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="card-footer">
|
||||||
|
<span class="subtle small" style="margin-right:auto">{{ count }} из {{ max_snapshots }}</span>
|
||||||
|
<button type="submit" class="btn btn-primary" {% if blockers %}disabled{% endif %}>{{ icon('camera') }}Создать снимок</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="card">
|
||||||
|
<div class="card-header"><div class="row">{{ icon('sparkles') }}<h2>Что важно знать</h2></div></div>
|
||||||
|
<div class="checklist">
|
||||||
|
<div class="check-item">{{ icon('hard-drive') }}<div><strong>Это не резервная копия</strong><span>Снимок хранится внутри диска машины. Если диск повредится, пропадут и снимки. Для защиты данных делайте резервные копии.</span></div></div>
|
||||||
|
<div class="check-item">{{ icon('history') }}<div><strong>Возврат стирает изменения</strong><span>Всё, что появилось после снимка, будет потеряно. Перед возвратом сохраните нужное.</span></div></div>
|
||||||
|
<div class="check-item">{{ icon('layers') }}<div><strong>Не больше {{ max_snapshots }} снимков</strong><span>Каждый снимок занимает место и немного замедляет диск. Удаляйте те, что уже не нужны.</span></div></div>
|
||||||
|
<div class="check-item">{{ icon('info') }}<div><strong>Только диски qcow2</strong><span>Все машины, созданные в Virtuality, подходят. Для машин с UEFI поддержка зависит от версии libvirt.</span></div></div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</aside>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
{% if active_operations %}
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var ids = Array.prototype.map.call(document.querySelectorAll('[data-watch-operation]'), function (el) { return el.dataset.watchOperation; });
|
||||||
|
var timer = setInterval(async function () {
|
||||||
|
for (var i = 0; i < ids.length; i++) {
|
||||||
|
try {
|
||||||
|
var response = await fetch('/api/operations/' + ids[i], { cache: 'no-store' });
|
||||||
|
var data = await response.json();
|
||||||
|
if (data.ok && (data.operation.status === 'success' || data.operation.status === 'error')) {
|
||||||
|
clearInterval(timer);
|
||||||
|
location.replace(location.pathname + '?op=' + ids[i]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
Ссылка в новой задаче
Block a user