Бэкапы VM с ротацией и ночным расписанием были добавлены
Страница /backups: ручной бэкап любой VM (сжатый qcow2, работающая VM ставится на паузу на время копирования), восстановление в выключенную VM, удаление копий и настройка ночного расписания. Ротация хранит заданное число копий. Установщик создаёт virtuality-backup.timer, ночной запуск ведёт журнал /var/log/virtuality/backup.log. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PKUoZwHMCHnytfswFVRUHw
Этот коммит содержится в:
@@ -11,6 +11,8 @@ VENV_DIR="/opt/virtuality/venv"
|
||||
SERVICE_FILE="/etc/systemd/system/virtuality-web.service"
|
||||
AUTO_UPDATE_SERVICE_FILE="/etc/systemd/system/virtuality-auto-update.service"
|
||||
AUTO_UPDATE_TIMER_FILE="/etc/systemd/system/virtuality-auto-update.timer"
|
||||
BACKUP_SERVICE_FILE="/etc/systemd/system/virtuality-backup.service"
|
||||
BACKUP_TIMER_FILE="/etc/systemd/system/virtuality-backup.timer"
|
||||
PORT="${VIRTUALITY_WEB_PORT:-8088}"
|
||||
AUTH_USER="${VIRTUALITY_AUTH_USER:-${SUDO_USER:-viktor}}"
|
||||
LOG_DIR="/var/log/virtuality"
|
||||
@@ -286,6 +288,33 @@ EOF
|
||||
else
|
||||
warn "auto_update_check.sh не найден, автообновление пропущено"
|
||||
fi
|
||||
cat > "$BACKUP_SERVICE_FILE" <<EOF
|
||||
[Unit]
|
||||
Description=Virtuality scheduled VM backups
|
||||
After=libvirtd.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=root
|
||||
Group=root
|
||||
ExecStart=${VENV_DIR}/bin/python ${APP_DIR}/backup_core.py --scheduled
|
||||
EOF
|
||||
cat > "$BACKUP_TIMER_FILE" <<EOF
|
||||
[Unit]
|
||||
Description=Run Virtuality VM backups nightly at 04:30 Moscow time
|
||||
|
||||
[Timer]
|
||||
OnCalendar=*-*-* 01:30:00 UTC
|
||||
AccuracySec=5min
|
||||
Persistent=true
|
||||
Unit=virtuality-backup.service
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
EOF
|
||||
run_logged "systemd daemon-reload выполнен для бэкапов" systemctl daemon-reload
|
||||
run_logged "virtuality-backup.timer включён" systemctl enable --now virtuality-backup.timer
|
||||
ok "Ночные бэкапы VM: расписание настраивается на странице /backups"
|
||||
|
||||
step "Запускаем Virtuality Web Panel"
|
||||
run_logged "virtuality-web.service включён и запущен" systemctl enable --now virtuality-web.service
|
||||
|
||||
+103
-1
@@ -37,6 +37,7 @@ from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from itsdangerous import BadSignature, URLSafeSerializer
|
||||
|
||||
import backup_core
|
||||
import cloud_images
|
||||
import host_profile
|
||||
import metrics
|
||||
@@ -309,7 +310,7 @@ def progress_from_line(current: int, line: str) -> int:
|
||||
return current
|
||||
|
||||
|
||||
def run_operation_worker(operation_id: str, cmd: list[str], progress_fn=None, start_message: str = "virt-install запущен", success_message: str = "VM создана успешно", refresh_pool: bool = True) -> None:
|
||||
def run_operation_worker(operation_id: str, cmd: list[str], progress_fn=None, start_message: str = "virt-install запущен", success_message: str = "VM создана успешно", refresh_pool: bool = True, on_success=None) -> None:
|
||||
operation = read_operation(operation_id)
|
||||
if not operation:
|
||||
return
|
||||
@@ -333,6 +334,13 @@ def run_operation_worker(operation_id: str, cmd: list[str], progress_fn=None, st
|
||||
if refresh_pool:
|
||||
run_cmd(["virsh", "pool-refresh", "virtuality-images"], timeout=20)
|
||||
cache_invalidate()
|
||||
if on_success is not None:
|
||||
try:
|
||||
result_note = on_success()
|
||||
if result_note:
|
||||
append_operation_log(operation_id, str(result_note))
|
||||
except Exception as exc:
|
||||
append_operation_log(operation_id, f"Пост-обработка завершилась с ошибкой: {exc}")
|
||||
update_operation(fresh, status="success", progress=100, exit_code=exit_code, message=success_message, finished_at=utc_now())
|
||||
else:
|
||||
append_operation_log(operation_id, f"Команда завершилась с ошибкой. Exit code: {exit_code}")
|
||||
@@ -971,6 +979,11 @@ def is_vm_template(name: str) -> bool:
|
||||
return name in load_vm_templates()
|
||||
|
||||
|
||||
def qemu_convert_progress(current: int, line: str) -> int:
|
||||
"""Адаптер для run_operation_worker: qemu-img convert -p пишет '(42.00/100%)'."""
|
||||
return disk_convert_progress(line, current)
|
||||
|
||||
|
||||
def wget_progress(current: int, line: str) -> int:
|
||||
# wget --progress=dot:giga пишет строки вида " 512000K .......... 42% 10.5M 2m30s"
|
||||
match = re.search(r"(\d{1,3})%", line)
|
||||
@@ -1760,6 +1773,95 @@ def vm_create_cloud_submit(request: Request, name: str = Form(...), image_name:
|
||||
return RedirectResponse(url=f"/operations/{operation_id}", status_code=303)
|
||||
|
||||
|
||||
@app.get("/backups", response_class=HTMLResponse)
|
||||
def backups_page(request: Request, error: str | None = None):
|
||||
auth_redirect = require_auth(request)
|
||||
if auth_redirect:
|
||||
return auth_redirect
|
||||
return templates.TemplateResponse("backups.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "vms": parse_virsh_list(), "backups": backup_core.list_backups(), "schedule": backup_core.load_schedule(), "error": error or request.query_params.get("backup_error", ""), "message": request.query_params.get("backup_message", "")})
|
||||
|
||||
|
||||
@app.post("/backups/create")
|
||||
def backup_create(request: Request, vm_name: str = Form(...), csrf_token: str = Form("")):
|
||||
auth_redirect = require_auth(request)
|
||||
if auth_redirect:
|
||||
return auth_redirect
|
||||
csrf_fail = require_csrf(request, csrf_token)
|
||||
if csrf_fail:
|
||||
return csrf_fail
|
||||
if not valid_vm_name(vm_name) or not vm_exists(vm_name):
|
||||
return RedirectResponse(url="/backups?backup_error=VM не найдена", status_code=303)
|
||||
disk_path = backup_core.first_disk_of(vm_name)
|
||||
if not disk_path:
|
||||
return RedirectResponse(url="/backups?backup_error=У VM не найден qcow2-диск для бэкапа", status_code=303)
|
||||
target = backup_core.new_backup_path(vm_name)
|
||||
if target is None:
|
||||
return RedirectResponse(url="/backups?backup_error=Некорректное имя VM", status_code=303)
|
||||
keep = backup_core.load_schedule()["keep"]
|
||||
cmd = ["bash", "-c", backup_core.build_backup_script(vm_name, disk_path, target)]
|
||||
operation_id = str(uuid.uuid4())
|
||||
operation = {"id": operation_id, "type": "vm_backup", "title": f"Бэкап VM {vm_name}", "status": "queued", "progress": 0, "message": "Операция поставлена в очередь", "created_at": utc_now(), "updated_at": utc_now(), "created_by": AUTH_USER, "vm_name": vm_name, "disk_path": disk_path, "target": str(target), "cmd": " ".join(cmd)}
|
||||
|
||||
def rotate_after_backup():
|
||||
removed = backup_core.rotate_backups(vm_name, keep)
|
||||
return f"Ротация удалила старые бэкапы: {', '.join(removed)}" if removed else ""
|
||||
|
||||
start_background_operation(operation, cmd, progress_fn=qemu_convert_progress, start_message=f"Бэкап диска VM {vm_name}", success_message=f"Бэкап VM {vm_name} создан", refresh_pool=False, on_success=rotate_after_backup)
|
||||
return RedirectResponse(url=f"/operations/{operation_id}", status_code=303)
|
||||
|
||||
|
||||
@app.post("/backups/{vm_name}/{filename}/restore")
|
||||
def backup_restore(request: Request, vm_name: str, filename: str, csrf_token: str = Form("")):
|
||||
auth_redirect = require_auth(request)
|
||||
if auth_redirect:
|
||||
return auth_redirect
|
||||
csrf_fail = require_csrf(request, csrf_token)
|
||||
if csrf_fail:
|
||||
return csrf_fail
|
||||
backup_file = backup_core.backup_path(vm_name, filename)
|
||||
if backup_file is None or not backup_file.exists():
|
||||
return RedirectResponse(url="/backups?backup_error=Бэкап не найден", status_code=303)
|
||||
if not vm_exists(vm_name):
|
||||
return RedirectResponse(url="/backups?backup_error=VM не существует — восстановление возможно только в существующую VM", status_code=303)
|
||||
if "running" in vm_runtime_state(vm_name):
|
||||
return RedirectResponse(url="/backups?backup_error=Сначала выключи VM — восстановление перезаписывает её диск", status_code=303)
|
||||
disk_path = backup_core.first_disk_of(vm_name)
|
||||
if not disk_path:
|
||||
return RedirectResponse(url="/backups?backup_error=У VM не найден qcow2-диск", status_code=303)
|
||||
cmd = ["bash", "-c", backup_core.build_restore_script(disk_path, backup_file)]
|
||||
operation_id = str(uuid.uuid4())
|
||||
operation = {"id": operation_id, "type": "vm_restore", "title": f"Восстановление VM {vm_name} из {filename}", "status": "queued", "progress": 0, "message": "Операция поставлена в очередь", "created_at": utc_now(), "updated_at": utc_now(), "created_by": AUTH_USER, "vm_name": vm_name, "backup": filename, "disk_path": disk_path, "cmd": " ".join(cmd)}
|
||||
start_background_operation(operation, cmd, progress_fn=qemu_convert_progress, start_message=f"Восстановление диска VM {vm_name}", success_message=f"VM {vm_name} восстановлена из бэкапа", refresh_pool=False)
|
||||
return RedirectResponse(url=f"/operations/{operation_id}", status_code=303)
|
||||
|
||||
|
||||
@app.post("/backups/{vm_name}/{filename}/delete")
|
||||
def backup_delete(request: Request, vm_name: str, filename: str, csrf_token: str = Form("")):
|
||||
auth_redirect = require_auth(request)
|
||||
if auth_redirect:
|
||||
return auth_redirect
|
||||
csrf_fail = require_csrf(request, csrf_token)
|
||||
if csrf_fail:
|
||||
return csrf_fail
|
||||
backup_file = backup_core.backup_path(vm_name, filename)
|
||||
if backup_file and backup_file.exists():
|
||||
backup_file.unlink()
|
||||
return RedirectResponse(url="/backups?backup_message=Бэкап удалён", status_code=303)
|
||||
|
||||
|
||||
@app.post("/backups/schedule")
|
||||
def backup_schedule_save(request: Request, enabled: str = Form("0"), keep: int = Form(5), vms: list[str] = Form([]), csrf_token: str = Form("")):
|
||||
auth_redirect = require_auth(request)
|
||||
if auth_redirect:
|
||||
return auth_redirect
|
||||
csrf_fail = require_csrf(request, csrf_token)
|
||||
if csrf_fail:
|
||||
return csrf_fail
|
||||
config = backup_core.save_schedule(enabled == "1", vms, keep)
|
||||
state = "включено" if config["enabled"] else "выключено"
|
||||
return RedirectResponse(url=f"/backups?backup_message=Расписание сохранено: {state}, VM: {len(config['vms'])}, хранить копий: {config['keep']}", status_code=303)
|
||||
|
||||
|
||||
def safe_network_template(request: Request, error: str | None = None, status_code: int = 200, diagnostics: dict[str, Any] | None = None):
|
||||
try:
|
||||
ctx = network_core.network_context()
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Бэкапы дисков VM: сжатые qcow2-копии в /var/lib/virtuality/backups.
|
||||
|
||||
Без внешних зависимостей — модуль используется и веб-панелью, и ночным
|
||||
systemd-таймером (запуск: python3 backup_core.py --scheduled).
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
BACKUPS_DIR = Path("/var/lib/virtuality/backups")
|
||||
SCHEDULE_FILE = Path("/var/lib/virtuality/config/backup_schedule.json")
|
||||
SCHEDULE_LOCK = threading.Lock()
|
||||
LOG_FILE = Path("/var/log/virtuality/backup.log")
|
||||
DEFAULT_KEEP = 5
|
||||
|
||||
|
||||
def valid_vm_name(name: str) -> bool:
|
||||
return bool(re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]{1,62}", name or ""))
|
||||
|
||||
|
||||
def valid_backup_filename(name: str) -> bool:
|
||||
return bool(re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]{0,120}\.qcow2", name or ""))
|
||||
|
||||
|
||||
def backup_dir_for(vm_name: str) -> Path | None:
|
||||
if not valid_vm_name(vm_name):
|
||||
return None
|
||||
path = (BACKUPS_DIR / vm_name).resolve()
|
||||
if BACKUPS_DIR.resolve() not in path.parents:
|
||||
return None
|
||||
return path
|
||||
|
||||
|
||||
def backup_path(vm_name: str, filename: str) -> Path | None:
|
||||
vm_dir = backup_dir_for(vm_name)
|
||||
if vm_dir is None or not valid_backup_filename(filename):
|
||||
return None
|
||||
path = (vm_dir / filename).resolve()
|
||||
if vm_dir not in path.parents:
|
||||
return None
|
||||
return path
|
||||
|
||||
|
||||
def new_backup_path(vm_name: str) -> Path | None:
|
||||
vm_dir = backup_dir_for(vm_name)
|
||||
if vm_dir is None:
|
||||
return None
|
||||
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
return vm_dir / f"{vm_name}_{stamp}.qcow2"
|
||||
|
||||
|
||||
def list_backups() -> list[dict[str, Any]]:
|
||||
backups = []
|
||||
if not BACKUPS_DIR.exists():
|
||||
return backups
|
||||
for vm_dir in sorted(BACKUPS_DIR.iterdir()):
|
||||
if not vm_dir.is_dir() or not valid_vm_name(vm_dir.name):
|
||||
continue
|
||||
for item in sorted(vm_dir.glob("*.qcow2"), reverse=True):
|
||||
try:
|
||||
stat = item.stat()
|
||||
except OSError:
|
||||
continue
|
||||
backups.append({
|
||||
"vm": vm_dir.name,
|
||||
"name": item.name,
|
||||
"path": str(item),
|
||||
"size": f"{stat.st_size / 1024 / 1024:.0f} MB",
|
||||
"created": datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M"),
|
||||
})
|
||||
return backups
|
||||
|
||||
|
||||
def rotate_backups(vm_name: str, keep: int) -> list[str]:
|
||||
"""Удаляет старые бэкапы сверх keep. Возвращает имена удалённых файлов."""
|
||||
vm_dir = backup_dir_for(vm_name)
|
||||
if vm_dir is None or not vm_dir.exists() or keep < 1:
|
||||
return []
|
||||
files = sorted(vm_dir.glob("*.qcow2"), key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
removed = []
|
||||
for stale in files[keep:]:
|
||||
try:
|
||||
stale.unlink()
|
||||
removed.append(stale.name)
|
||||
except OSError:
|
||||
pass
|
||||
return removed
|
||||
|
||||
|
||||
def load_schedule() -> dict[str, Any]:
|
||||
try:
|
||||
data = json.loads(SCHEDULE_FILE.read_text())
|
||||
if isinstance(data, dict):
|
||||
return {
|
||||
"enabled": bool(data.get("enabled", False)),
|
||||
"vms": [name for name in data.get("vms", []) if isinstance(name, str) and valid_vm_name(name)],
|
||||
"keep": max(1, min(30, int(data.get("keep", DEFAULT_KEEP)))),
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
return {"enabled": False, "vms": [], "keep": DEFAULT_KEEP}
|
||||
|
||||
|
||||
def save_schedule(enabled: bool, vms: list[str], keep: int) -> dict[str, Any]:
|
||||
config = {
|
||||
"enabled": bool(enabled),
|
||||
"vms": sorted({name for name in vms if valid_vm_name(name)}),
|
||||
"keep": max(1, min(30, int(keep))),
|
||||
}
|
||||
with SCHEDULE_LOCK:
|
||||
SCHEDULE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp_path = SCHEDULE_FILE.with_suffix(".json.tmp")
|
||||
tmp_path.write_text(json.dumps(config, ensure_ascii=False, indent=2))
|
||||
tmp_path.replace(SCHEDULE_FILE)
|
||||
return config
|
||||
|
||||
|
||||
def build_backup_script(vm_name: str, disk_path: str, target: Path) -> str:
|
||||
"""Скрипт бэкапа: пауза работающей VM на время копии, сжатый qcow2, resume.
|
||||
|
||||
Для выключенной VM virsh suspend/resume тихо провалятся — это нормально.
|
||||
"""
|
||||
part = f"{target}.part"
|
||||
return (
|
||||
"set -uo pipefail; "
|
||||
f"mkdir -p {target.parent}; "
|
||||
f"virsh suspend {vm_name} >/dev/null 2>&1 && RESUME=1 || RESUME=0; "
|
||||
f"qemu-img convert -p -c -O qcow2 {disk_path} {part}; STATUS=$?; "
|
||||
f"[ \"$RESUME\" = 1 ] && virsh resume {vm_name} >/dev/null 2>&1; "
|
||||
f"[ $STATUS -ne 0 ] && rm -f {part} && exit $STATUS; "
|
||||
f"mv {part} {target}"
|
||||
)
|
||||
|
||||
|
||||
def build_restore_script(disk_path: str, backup_file: Path) -> str:
|
||||
part = f"{disk_path}.restore"
|
||||
return (
|
||||
"set -euo pipefail; "
|
||||
f"qemu-img convert -p -O qcow2 {backup_file} {part}; "
|
||||
f"mv {part} {disk_path}"
|
||||
)
|
||||
|
||||
|
||||
# ---- CLI-режим для ночного systemd-таймера ----
|
||||
|
||||
def _log(message: str) -> None:
|
||||
LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
stamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
with LOG_FILE.open("a", encoding="utf-8") as handle:
|
||||
handle.write(f"[{stamp}] {message}\n")
|
||||
|
||||
|
||||
def _run(cmd: list[str], timeout: int = 60) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, check=False)
|
||||
|
||||
|
||||
def first_disk_of(vm_name: str) -> str | None:
|
||||
result = _run(["virsh", "domblklist", vm_name, "--details"])
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
for line in result.stdout.splitlines()[2:]:
|
||||
parts = line.split()
|
||||
if len(parts) >= 4 and parts[1] == "disk" and parts[3].endswith(".qcow2"):
|
||||
return parts[3]
|
||||
return None
|
||||
|
||||
|
||||
def run_scheduled_backups() -> int:
|
||||
config = load_schedule()
|
||||
if not config["enabled"]:
|
||||
_log("Расписание выключено — выходим.")
|
||||
return 0
|
||||
if not config["vms"]:
|
||||
_log("Расписание включено, но список VM пуст.")
|
||||
return 0
|
||||
failures = 0
|
||||
for vm_name in config["vms"]:
|
||||
disk = first_disk_of(vm_name)
|
||||
if not disk:
|
||||
_log(f"{vm_name}: qcow2-диск не найден, пропуск.")
|
||||
failures += 1
|
||||
continue
|
||||
target = new_backup_path(vm_name)
|
||||
if target is None:
|
||||
_log(f"{vm_name}: некорректное имя VM, пропуск.")
|
||||
failures += 1
|
||||
continue
|
||||
_log(f"{vm_name}: бэкап {disk} → {target}")
|
||||
result = subprocess.run(["bash", "-c", build_backup_script(vm_name, disk, target)], capture_output=True, text=True, check=False)
|
||||
if result.returncode == 0:
|
||||
removed = rotate_backups(vm_name, config["keep"])
|
||||
_log(f"{vm_name}: готово" + (f", ротация удалила: {', '.join(removed)}" if removed else ""))
|
||||
else:
|
||||
failures += 1
|
||||
_log(f"{vm_name}: ошибка бэкапа (код {result.returncode}): {result.stderr.strip()[:400]}")
|
||||
return 1 if failures else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if "--scheduled" in sys.argv:
|
||||
sys.exit(run_scheduled_backups())
|
||||
print("Использование: backup_core.py --scheduled", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
@@ -15,6 +15,7 @@
|
||||
<a data-icon="☁" class="{{ 'active' if path.startswith('/cloud-images') else '' }}" href="/cloud-images">Cloud</a>
|
||||
</div>
|
||||
<a data-icon="⇄" class="{{ 'active' if path.startswith('/network') else '' }}" href="/network">Сеть</a>
|
||||
<a data-icon="⛁" class="{{ 'active' if path.startswith('/backups') else '' }}" href="/backups">Бэкапы</a>
|
||||
<a data-icon="⚙" class="{{ 'active' if path.startswith('/operations') else '' }}" href="/operations">Операции</a>
|
||||
<a data-icon="≡" class="{{ 'active' if path.startswith('/logs') else '' }}" href="/logs">Журналы</a>
|
||||
<a data-icon="↻" class="{{ 'active' if path.startswith('/update') else '' }}" href="/update">Обновления</a>
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Бэкапы — {{ app_name }}</title>
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="v-layout">
|
||||
{% include "_sidebar.html" %}
|
||||
|
||||
<main class="v-main">
|
||||
<header class="v-topbar">
|
||||
<div>
|
||||
<div class="v-title">Бэкапы</div>
|
||||
<div class="v-subtitle">Сжатые копии дисков VM в /var/lib/virtuality/backups с ротацией и ночным расписанием</div>
|
||||
</div>
|
||||
<div class="top-meta">
|
||||
<span>user: {{ user }}</span>
|
||||
<a class="link-pill" href="/">← Дашборд</a>
|
||||
<form method="post" action="/logout" class="logout-form"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="ghost">Выйти</button></form>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{% if error %}<div class="alert danger">{{ error }}</div>{% endif %}
|
||||
{% if message %}<div class="alert success">{{ message }}</div>{% endif %}
|
||||
|
||||
<section class="grid two">
|
||||
<article class="card">
|
||||
<div class="card-head">
|
||||
<h2>Сделать бэкап сейчас</h2>
|
||||
<span class="pill">qemu-img convert -c</span>
|
||||
</div>
|
||||
<form method="post" action="/backups/create" class="form-grid">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label>
|
||||
<span>Виртуальная машина</span>
|
||||
<select name="vm_name" {% if vms %}required{% endif %}>
|
||||
{% for vm in vms %}
|
||||
<option value="{{ vm.name }}">{{ vm.name }} — {{ vm.state }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<button class="primary wide" {% if not vms %}disabled{% endif %}>Создать бэкап</button>
|
||||
{% if not vms %}<div class="alert danger">VM пока нет.</div>{% endif %}
|
||||
</form>
|
||||
<p class="muted small-note">Работающая VM ставится на паузу на время копирования диска и продолжает работу автоматически. Бэкап сжимается — занимает заметно меньше исходного диска.</p>
|
||||
</article>
|
||||
|
||||
<article class="card">
|
||||
<div class="card-head">
|
||||
<h2>Ночное расписание</h2>
|
||||
<span class="pill">{{ 'включено' if schedule.enabled else 'выключено' }}</span>
|
||||
</div>
|
||||
<form method="post" action="/backups/schedule" class="form-grid">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label class="check-row">
|
||||
<input type="checkbox" name="enabled" value="1" {% if schedule.enabled %}checked{% endif %}>
|
||||
<span>Делать бэкапы каждую ночь (04:30 по Москве)</span>
|
||||
</label>
|
||||
<label>
|
||||
<span>Хранить копий на VM</span>
|
||||
<input type="number" name="keep" min="1" max="30" value="{{ schedule.keep }}" required>
|
||||
</label>
|
||||
<div class="label">Какие VM бэкапить</div>
|
||||
{% for vm in vms %}
|
||||
<label class="check-row">
|
||||
<input type="checkbox" name="vms" value="{{ vm.name }}" {% if vm.name in schedule.vms %}checked{% endif %}>
|
||||
<span>{{ vm.name }}</span>
|
||||
</label>
|
||||
{% else %}
|
||||
<div class="muted small-note">VM пока нет — расписание можно настроить после их создания.</div>
|
||||
{% endfor %}
|
||||
<button class="primary wide">Сохранить расписание</button>
|
||||
</form>
|
||||
<p class="muted small-note">Старые копии сверх лимита удаляются автоматически после каждого успешного бэкапа. Журнал ночных бэкапов: /var/log/virtuality/backup.log.</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card-head">
|
||||
<h2>Сохранённые бэкапы</h2>
|
||||
<span class="pill">{{ backups|length }} файлов</span>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>VM</th><th>Файл</th><th>Размер</th><th>Создан</th><th>Действия</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for backup in backups %}
|
||||
<tr>
|
||||
<td class="strong">{{ backup.vm }}</td>
|
||||
<td>{{ backup.name }}</td>
|
||||
<td>{{ backup.size }}</td>
|
||||
<td>{{ backup.created }}</td>
|
||||
<td class="actions">
|
||||
<form method="post" action="/backups/{{ backup.vm }}/{{ backup.name }}/restore" onsubmit="return confirm('Восстановить VM {{ backup.vm }} из бэкапа {{ backup.name }}? Текущий диск VM будет перезаписан.');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button class="ghost">Восстановить</button>
|
||||
</form>
|
||||
<form method="post" action="/backups/{{ backup.vm }}/{{ backup.name }}/delete" onsubmit="return confirm('Удалить бэкап {{ backup.name }}?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button class="danger">Удалить</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="5" class="muted">Бэкапов пока нет. Сделай первый бэкап кнопкой слева или включи ночное расписание.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="muted small-note">Восстановление перезаписывает первый диск VM содержимым бэкапа — VM должна быть выключена.</p>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
<script src="/static/panel.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
Ссылка в новой задаче
Block a user