Merge branch 'feature/snapshots' into claude/production-system-image-smcxyr
# Conflicts: # web/app.py
Этот коммит содержится в:
+2
-1
@@ -28,7 +28,7 @@ import auth
|
||||
import host_profile
|
||||
import network_core
|
||||
import presenters
|
||||
from features import settings, setup
|
||||
from features import settings, setup, snapshots
|
||||
import update_core
|
||||
from network_core import NetworkError
|
||||
|
||||
@@ -2023,3 +2023,4 @@ def api_health(request: Request):
|
||||
|
||||
app.include_router(setup.router)
|
||||
app.include_router(settings.router)
|
||||
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-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 |
@@ -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>
|
||||
|
||||
|
||||
@@ -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