Операции, прерванные перезапуском панели, закрываются при старте; снимки: исправления по ревью

- core.interrupt_orphaned_operations() при запуске панели, active_operations_for() по всем задачам
- снимки: имена с пробелами, снимки вне панели без кнопок, --force только на «revert requires force»,
  память на паузе учитывается, защита от двойного нажатия, надпись «Создаём снимок…»

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg
Этот коммит содержится в:
Claude
2026-09-25 15:05:49 +00:00
родитель 2b7223f311
Коммит eb7b71f478
6 изменённых файлов: 96 добавлений и 10 удалений
+7
Просмотреть файл
@@ -2007,6 +2007,13 @@ def api_health(request: Request):
# Feature modules register template globals into app.templates, so they are imported once it exists.
from features import backups, downloads, settings, setup, snapshots, vmops # noqa: E402
@app.on_event("startup")
def close_orphaned_operations() -> None:
import core
core.interrupt_orphaned_operations()
app.include_router(setup.router)
app.include_router(settings.router)
app.include_router(snapshots.router)
+35
Просмотреть файл
@@ -271,6 +271,41 @@ def list_operations(limit: int = 25) -> list[dict[str, Any]]:
return operations
def interrupt_orphaned_operations() -> int:
"""Operations run in threads of the panel process: after a restart the ones still marked
queued/running will never finish. Close them so they do not block the next attempt."""
if not OPERATIONS_DIR.exists():
return 0
count = 0
for path in OPERATIONS_DIR.glob("*.json"):
try:
data = json.loads(path.read_text())
except (OSError, ValueError):
continue
if data.get("status") not in ("queued", "running"):
continue
data.pop("log_tail", None)
message = "Прервано: панель перезапускалась во время операции. Проверьте состояние машины и запустите действие снова."
update_operation(data, status="error", progress=100, message=message, finished_at=utc_now(), interrupted=True)
append_operation_log(data["id"], message)
count += 1
return count
def active_operations_for(vm_name: str, kinds: tuple[str, ...] | set[str] | None = None) -> list[dict[str, Any]]:
"""Unfinished operations of one machine, over the whole operations directory (not only the latest N)."""
ensure_operations_dir()
found = []
for path in OPERATIONS_DIR.glob("*.json"):
try:
data = json.loads(path.read_text())
except (OSError, ValueError):
continue
if data.get("vm_name") == vm_name and data.get("status") in ("queued", "running") and (kinds is None or data.get("type") in kinds):
found.append(data)
return found
def running_operations(kind: str | None = None) -> list[dict[str, Any]]:
return [op for op in list_operations(50) if op.get("status") in ("queued", "running") and (kind is None or op.get("type") == kind)]
+24 -7
Просмотреть файл
@@ -6,6 +6,7 @@ libvirt через `virsh snapshot-*`.
только диск. Создание и возврат выполняются как фоновые операции.
"""
import re
import threading
import xml.etree.ElementTree as ET
from datetime import datetime, timedelta
from typing import Any
@@ -15,6 +16,7 @@ from fastapi.responses import JSONResponse, RedirectResponse
import presenters
from core import (
active_operations_for,
append_operation_log,
cmd_error,
finish_operation,
@@ -62,7 +64,7 @@ KNOWN_ERRORS = [
("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*$")
_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
@@ -181,6 +183,8 @@ def list_snapshots(vm: str) -> list[dict[str, Any]]:
row["state_info"] = snapshot_state(row["state"])
row["when"] = format_when(row["created_at"])
row["title"] = row["description"] or row["name"]
# Snapshots made outside the panel (virsh, virt-manager) may have names the panel's URLs do not accept.
row["manageable"] = valid_label(row["name"])
return rows
@@ -194,7 +198,7 @@ def vm_operations(vm: str) -> list[dict[str, Any]]:
def active_operations(vm: str) -> list[dict[str, Any]]:
return [op for op in vm_operations(vm) if op.get("status") in ("queued", "running")]
return active_operations_for(vm, OPERATION_TYPES)
def create_blockers(vm: str, snapshots: list[dict[str, Any]], checks: dict[str, Any]) -> list[str]:
@@ -239,7 +243,7 @@ def revert_worker(operation: dict[str, Any]) -> None:
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():
if not result["ok"] and "revert requires force" in (result.get("stderr") or "").lower():
# libvirt просит --force, когда конфигурация машины изменилась после снимка: пользователь уже подтвердил возврат.
append_operation_log(operation["id"], "libvirt требует подтверждения (--force): повторяем принудительно.")
cmd.append("--force")
@@ -275,7 +279,7 @@ def snapshots_page(request: Request, name: str):
info = presenters.parse_dominfo(dominfo)
snapshots = list_snapshots(name)
checks = domain_checks(name)
running = presenters.vm_state(info["state"])["running"]
running = vm_active(info["state"])
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):
@@ -297,8 +301,21 @@ def snapshots_page(request: Request, name: str):
})
def vm_active(state: str) -> bool:
"""Running or paused: libvirt saves the memory of both into the snapshot."""
return presenters.vm_state(state)["running"] or "paus" in (state or "").lower()
_create_lock = threading.Lock()
@router.post("/vm/{name}/snapshots/create")
def snapshot_create(request: Request, name: str, description: str = Form("")):
with _create_lock: # two quick clicks must not both pass the checks below
return _snapshot_create(request, name, description)
def _snapshot_create(request: Request, name: str, description: str):
blocked, dominfo = _guard(request, name)
if blocked:
return blocked
@@ -306,7 +323,7 @@ def snapshot_create(request: Request, name: str, description: str = Form("")):
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"]
running = vm_active(presenters.parse_dominfo(dominfo)["state"])
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", "Не удалось подобрать имя снимка.")
@@ -321,7 +338,7 @@ def snapshot_revert(request: Request, name: str, snap: str):
if blocked:
return blocked
if not valid_label(snap):
return JSONResponse({"ok": False, "error": "Invalid snapshot name"}, status_code=400)
return redirect_with_message(f"/vm/{name}/snapshots", "snapshot_error", "Этот снимок создан вне панели и имеет необычное имя — управляйте им через virsh.")
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)
@@ -335,7 +352,7 @@ def snapshot_delete(request: Request, name: str, snap: str):
if blocked:
return blocked
if not valid_label(snap):
return JSONResponse({"ok": False, "error": "Invalid snapshot name"}, status_code=400)
return redirect_with_message(f"/vm/{name}/snapshots", "snapshot_error", "Этот снимок создан вне панели и имеет необычное имя — управляйте им через virsh.")
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)
+6 -2
Просмотреть файл
@@ -69,6 +69,7 @@
</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>
{% if snap.manageable %}
<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="Вернуться">
@@ -79,6 +80,9 @@
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>
{% else %}
<span class="subtle small" title="Снимок создан вне панели — управляйте им через virsh">Создан вне панели</span>
{% endif %}
</div>
{% endfor %}
</div>
@@ -105,7 +109,7 @@
<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="Создаём снимок…">
<form method="post" action="/vm/{{ vm.name }}/snapshots/create">
<div class="card-body stack-sm">
<div class="field">
<label for="description">Описание</label>
@@ -125,7 +129,7 @@
</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>
<button type="submit" class="btn btn-primary" data-busy="Создаём снимок…" {% if blockers %}disabled{% endif %}>{{ icon('camera') }}Создать снимок</button>
</div>
</form>
</article>