Операции, прерванные перезапуском панели, закрываются при старте; снимки: исправления по ревью
- 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
Этот коммит содержится в:
@@ -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)
|
||||
|
||||
@@ -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)]
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
Ссылка в новой задаче
Block a user