Снимки машин (точки восстановления) были добавлены

Новая страница /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
Этот коммит содержится в:
Claude
2026-09-25 14:30:25 +00:00
родитель 1e29134fb3
Коммит 4198c18ff4
9 изменённых файлов: 869 добавлений и 3 удалений
+33 -1
Просмотреть файл
@@ -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:
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):
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"]:
return fake_result(" Id Name State\n-----------------------\n 1 web01 running\n - db01 shut off")
if cmd[:2] == ["virsh", "dominfo"]:
@@ -72,9 +97,15 @@ def fake_run_cmd(cmd, timeout=12, **_kwargs):
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()
def data_dirs(tmp_path, monkeypatch):
import app
import core
import host_profile
import network_core
import update_core
@@ -95,6 +126,7 @@ def data_dirs(tmp_path, monkeypatch):
monkeypatch.setattr(app, "IMAGES_DIR", dirs["images"])
monkeypatch.setattr(app, "DISK_IMAGES_DIR", dirs["disk_images"])
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, "NETWORK_DIR", dirs["network"])
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, "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, network_core, host_profile, core, *feature_modules()):
monkeypatch.setattr(module, "run_cmd", fake_run_cmd)
return dirs
+33 -2
Просмотреть файл
@@ -13,16 +13,44 @@ 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
# Снимки для демо: имя, дата, состояние, описание.
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:
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.OPERATIONS_DIR = dirs["operations"]
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 +60,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, network_core, host_profile, core, *conftest.feature_modules()):
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 +77,9 @@ 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 ...")
# Идущий снимок машины 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__":
+285
Просмотреть файл
@@ -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"
+4
Просмотреть файл
@@ -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.snapshots as snapshots # noqa: E402
app.include_router(snapshots.router)
Просмотреть файл
+344
Просмотреть файл
@@ -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}» удалён")
+2
Просмотреть файл
@@ -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-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-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>

До

Ширина:  |  Высота:  |  Размер: 10 KiB

После

Ширина:  |  Высота:  |  Размер: 11 KiB

+1
Просмотреть файл
@@ -53,6 +53,7 @@
<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="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>
</nav>
+167
Просмотреть файл
@@ -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 %}