Merge branch 'feature/vmops' into claude/production-system-image-smcxyr

# Conflicts:
#	tests/conftest.py
#	tests/dev_server.py
#	web/app.py
#	web/static/app.css
#	web/static/icons.svg
Этот коммит содержится в:
Claude
2026-09-25 15:03:48 +00:00
родитель a43f96883e 34684634af
Коммит 2b7223f311
19 изменённых файлов: 1400 добавлений и 22 удалений
+4 -1
Просмотреть файл
@@ -28,7 +28,6 @@ import auth
import host_profile
import network_core
import presenters
from features import backups, downloads, settings, setup, snapshots
import update_core
from network_core import NetworkError
@@ -2005,8 +2004,12 @@ def api_health(request: Request):
return {"system": system_summary(), "host_profile": host_profile.load_host_profile(), "services": {"libvirtd": service_state("libvirtd.service"), "virtlogd": service_state("virtlogd.service"), "web": service_state("virtuality-web.service")}, "vms": parse_virsh_list(), "pools": parse_pool_list(), "network": network_summary(), "virtuality_nat": network_core.network_context()}
# 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.include_router(setup.router)
app.include_router(settings.router)
app.include_router(snapshots.router)
app.include_router(backups.router)
app.include_router(downloads.router)
app.include_router(vmops.router)
+590
Просмотреть файл
@@ -0,0 +1,590 @@
"""Повседневные операции с виртуальной машиной, которых ждут от «простого
Proxmox»: увеличение диска, клонирование, пауза и спящий режим, заметки
(название и описание) и живая нагрузка процессора и памяти.
Всё, что занимает больше пары секунд (клонирование, спящий режим), идёт как
фоновая операция с прогрессом и журналом на странице /operations/{id}.
"""
import json
import re
import shutil
import threading
import time
from pathlib import Path
from typing import Any
from urllib.parse import quote
from fastapi import APIRouter, Form, Request
from fastapi.responses import JSONResponse, RedirectResponse
import core
import presenters
from core import (
IMAGES_POOL,
append_operation_log,
cmd_error,
finish_operation,
new_operation,
redirect_with_message,
render,
require_api_auth,
require_auth,
run_operation,
running_operations,
templates,
update_operation,
valid_vm_name,
vm_exists,
vm_state,
)
router = APIRouter()
GIB = 1024 ** 3
MAX_DISK_GB = 16 * 1024 # 16 ТБ — потолок формы, чтобы опечатка не сделала диск на петабайт
TITLE_MAX = 80
DESCRIPTION_MAX = 2000
CLONE_TIMEOUT = 6 * 3600 # секунд: копия терабайтного диска на HDD идёт часами
HIBERNATE_TIMEOUT = 1800
SHUT_OFF_STATES = ("shut off", "shutoff", "shut-off")
ACTIVE_STATES = ("running", "idle", "blocked")
SAVE_DIR = Path("/var/lib/libvirt/qemu/save") # сюда libvirt кладёт память «спящих» машин
RESIZABLE_FORMATS = ("qcow2", "raw")
GUEST_GROW_HINT = "Если система ставилась из облачного образа, место внутри появится само. Иначе расширьте раздел в самой системе: в Linux — growpart или GParted, в Windows — «Управление дисками» → «Расширить том»."
# Известные ошибки virsh/qemu-img/virt-clone → понятное объяснение (подстрока без учёта регистра).
KNOWN_ERRORS = [
("has snapshots", "У диска есть снимки — сначала удалите снимки машины, затем увеличьте диск."),
("no space left", "На диске сервера закончилось место."),
("cannot acquire state change lock", "Машина занята другой операцией. Подождите минуту и попробуйте снова."),
("timed out during operation", "Машина занята другой операцией. Подождите минуту и попробуйте снова."),
("already exists", "Машина или файл с таким именем уже есть."),
("is not running", "Машина выключена, поэтому это действие сейчас недоступно."),
("not paused", "Машина не приостановлена."),
("already active", "Машина уже работает."),
("guest agent", "Внутри машины нет гостевого агента, поэтому это действие недоступно."),
("permission denied", "Нет прав на файл диска. Проверьте, что диск лежит в хранилище Virtuality."),
]
OPS = {
"pause": {
"cmd": ["virsh", "suspend"],
"states": ACTIVE_STATES,
"done": "Машина {name} приостановлена: она не тратит процессор, но остаётся в памяти сервера",
"wrong": "Приостановить можно только работающую машину",
"fail": "Не удалось приостановить машину",
},
"resume": {
"cmd": ["virsh", "resume"],
"states": ("paused",),
"done": "Машина {name} продолжает работу",
"wrong": "Продолжить можно только приостановленную машину",
"fail": "Не удалось продолжить работу машины",
},
"forget-save": {
"cmd": ["virsh", "managedsave-remove"],
"states": SHUT_OFF_STATES,
"done": "Сохранённое состояние удалено: при запуске машина {name} загрузится заново",
"wrong": "Сохранённое состояние есть только у выключенной машины",
"fail": "Не удалось удалить сохранённое состояние",
},
}
def run(cmd: list[str], timeout: int = 12) -> dict[str, Any]:
"""Все команды идут через core.run_cmd — тесты подменяют его целиком."""
return core.run_cmd(cmd, timeout=timeout)
def explain_error(raw: str, fallback: str) -> str:
low = (raw or "").lower()
for needle, text in KNOWN_ERRORS:
if needle in low:
return text
return raw or fallback
def is_shut_off(state: str) -> bool:
return (state or "").strip().lower() in SHUT_OFF_STATES
def redirect_tab(name: str, tab: str, key: str, message: str) -> RedirectResponse:
"""Как redirect_with_message, но открывает нужную вкладку страницы машины."""
return RedirectResponse(url=f"/vm/{name}?{key}={quote(message)}#{tab}", status_code=303)
def safe_next(value: str, name: str) -> str:
"""Куда вернуть пользователя после действия: только обзор или страница машины."""
return value if value in ("/", f"/vm/{name}") else f"/vm/{name}"
def _guard(request: Request, name: str):
"""Общая проверка для всех маршрутов: вход, имя, существование машины."""
denied = require_auth(request)
if denied:
return denied
if not valid_vm_name(name):
return JSONResponse({"ok": False, "error": "Invalid VM name"}, status_code=400)
if not vm_exists(name):
return RedirectResponse(url="/", status_code=303)
return None
# ---------------------------------------------------------------- parsers
def parse_qemu_img_info(text: str) -> dict[str, Any]:
"""`qemu-img info --output=json` → виртуальный и реальный размер, формат."""
try:
data = json.loads(text or "")
except ValueError:
return {}
if not isinstance(data, dict):
return {}
try:
return {"virtual_size": int(data.get("virtual-size") or 0), "actual_size": int(data.get("actual-size") or 0), "format": str(data.get("format") or "")}
except (TypeError, ValueError):
return {}
def parse_domstats(text: str) -> dict[str, dict[str, str]]:
"""`virsh domstats` → {имя: {поле: значение}}. Машины без статистики дают пустой словарь."""
stats: dict[str, dict[str, str]] = {}
current: str | None = None
for raw_line in (text or "").splitlines():
line = raw_line.strip()
match = re.fullmatch(r"Domain:\s*'(.+)'", line)
if match:
current = match.group(1)
stats[current] = {}
continue
if current is None or "=" not in line:
continue
key, value = line.split("=", 1)
stats[current][key.strip()] = value.strip()
return stats
def parse_desc_output(result: dict[str, Any]) -> str:
"""Вывод `virsh desc`: пусто, если названия/описания нет."""
if not result.get("ok"):
return ""
text = (result.get("stdout") or "").strip()
if re.match(r"^No (title|description) for domain", text):
return ""
return text
def has_managed_save(dominfo: str) -> bool:
"""Строка «Managed save: yes» в `virsh dominfo` — машина в спящем режиме."""
match = re.search(r"^Managed save:\s*(\S+)", dominfo or "", re.MULTILINE | re.IGNORECASE)
return bool(match) and match.group(1).lower() in ("yes", "on", "true")
def _int(value: str | None) -> int:
try:
return int(value or 0)
except ValueError:
return 0
def compute_stats(stats: dict[str, dict[str, str]], now: float, samples: dict[str, tuple[float, int]]) -> dict[str, dict[str, Any]]:
"""Проценты процессора считаются по разнице cpu.time с прошлым замером той же машины.
Первый замер даёт cpu_pct=None (нечего сравнивать). Память — balloon.rss
(реально занятая на сервере), если нет — balloon.current.
"""
result: dict[str, dict[str, Any]] = {}
for name, fields in stats.items():
cpu_time = _int(fields.get("cpu.time"))
vcpus = _int(fields.get("vcpu.current")) or _int(fields.get("vcpu.maximum")) or 1
mem_used_kib = _int(fields.get("balloon.rss")) or _int(fields.get("balloon.current"))
mem_total_kib = _int(fields.get("balloon.maximum")) or _int(fields.get("balloon.current"))
if not cpu_time and not mem_total_kib:
samples.pop(name, None)
continue # выключенная машина — статистики нет
cpu_pct = None
previous = samples.get(name)
if previous and now > previous[0] and cpu_time >= previous[1]:
cpu_pct = (cpu_time - previous[1]) / ((now - previous[0]) * 1e9) / vcpus * 100
cpu_pct = round(min(100.0, max(0.0, cpu_pct)), 1)
samples[name] = (now, cpu_time)
result[name] = {"cpu_pct": cpu_pct, "mem_used_mb": mem_used_kib // 1024, "mem_total_mb": mem_total_kib // 1024, "vcpus": vcpus}
for stale in set(samples) - set(stats):
samples.pop(stale, None)
return result
# ---------------------------------------------------------------- live stats
_samples: dict[str, tuple[float, int]] = {}
_samples_lock = threading.Lock()
def vm_stats(names: list[str]) -> dict[str, dict[str, Any]]:
"""Нагрузка нескольких машин одним вызовом `virsh domstats`."""
if not names:
return {}
result = run(["virsh", "domstats", "--cpu-total", "--balloon", "--vcpu", *names], timeout=10)
if not result["ok"]:
return {}
with _samples_lock:
return compute_stats(parse_domstats(result["stdout"]), time.monotonic(), _samples)
def active_vm_names() -> list[str]:
"""Имена работающих и приостановленных машин (`virsh list --name`)."""
result = run(["virsh", "list", "--name"], timeout=8)
if not result["ok"]:
return []
return [line.strip() for line in result["stdout"].splitlines() if valid_vm_name(line.strip())]
def all_vm_names() -> list[str]:
result = run(["virsh", "list", "--all", "--name"], timeout=8)
if not result["ok"]:
return []
return [line.strip() for line in result["stdout"].splitlines() if line.strip()]
@router.get("/live/stats")
def live_stats(request: Request):
denied = require_api_auth(request)
if denied:
return denied
return JSONResponse({"ok": True, "generated_at": core.utc_now(), "stats": vm_stats(active_vm_names())})
# ---------------------------------------------------------------- titles & notes
def vm_titles() -> dict[str, str]:
"""Названия всех машин одним вызовом — для списка на обзоре."""
result = run(["virsh", "list", "--all", "--title"], timeout=10)
return presenters.parse_virsh_list_titles(result["stdout"]) if result["ok"] else {}
def read_notes(name: str) -> dict[str, str]:
"""Название и описание машины. Без флагов virsh отдаёт текущее значение:
у работающей машины — живое, у выключенной — из конфигурации."""
title = run(["virsh", "-q", "desc", name, "--title"], timeout=8)
description = run(["virsh", "-q", "desc", name], timeout=8)
return {"title": parse_desc_output(title), "description": parse_desc_output(description)}
def clean_title(value: str) -> str:
return " ".join((value or "").split())[:TITLE_MAX].strip()
def clean_description(value: str) -> str:
text = (value or "").replace("\r\n", "\n").replace("\r", "\n")
lines = [line.rstrip() for line in text.split("\n")]
return "\n".join(lines).strip()[:DESCRIPTION_MAX].strip()
def write_notes(name: str, title: str, description: str) -> tuple[bool, str]:
"""`virsh desc` в конфигурацию, а у работающей машины — ещё и вживую.
Текст передаётся как --new-desc=ТЕКСТ одним аргументом: так virsh не
примет описание вида «--edit» за свою опцию."""
flags = ["--config"] + (["--live"] if not is_shut_off(vm_state(name)) else [])
for extra, value in ((["--title"], title), ([], description)):
result = run(["virsh", "desc", name, *extra, f"--new-desc={value}", *flags], timeout=10)
if not result["ok"]:
return False, explain_error(cmd_error(result, "virsh desc завершился с ошибкой"), "Не удалось сохранить заметки")
return True, "Заметки сохранены"
@router.post("/vm/{name}/notes/save")
def notes_save(request: Request, name: str, title: str = Form(""), description: str = Form("")):
denied = _guard(request, name)
if denied:
return denied
ok, message = write_notes(name, clean_title(title), clean_description(description))
return redirect_with_message(f"/vm/{name}", "notes_message" if ok else "notes_error", message)
# ---------------------------------------------------------------- disks
def inside_storage(path: str) -> bool:
"""Диск должен лежать в /var/lib/virtuality — чужие файлы панель не трогает."""
try:
resolved = Path(path).resolve()
except (OSError, RuntimeError):
return False
return Path(core.STORAGE_DIR).resolve() in resolved.parents
def free_space(path: str | Path) -> int:
"""Свободное место рядом с файлом диска (или в хранилище образов)."""
for candidate in (Path(path).parent, Path(core.IMAGES_DIR), Path("/")):
try:
return shutil.disk_usage(candidate).free
except OSError:
continue
return 0
def disk_info(path: str) -> dict[str, Any]:
# -U (--force-share): иначе qemu-img не откроет диск работающей машины из-за блокировки.
result = run(["qemu-img", "info", "--output=json", "-U", path], timeout=20)
return parse_qemu_img_info(result["stdout"]) if result["ok"] else {}
def vm_disks(name: str) -> list[dict[str, Any]]:
"""Диски машины с размерами; у подходящих — флаг resizable и минимальный новый размер."""
result = run(["virsh", "domblklist", name, "--details"], timeout=10)
disks = presenters.parse_domblklist(result["stdout"]) if result["ok"] else []
items = []
for disk in disks:
item: dict[str, Any] = dict(disk, virtual_size=0, actual_size=0, format="", size_text="", used_text="", resizable=False, min_gb=0)
if disk["device"] == "disk" and disk["source"] and disk["type"] == "file":
info = disk_info(disk["source"])
virtual, actual = info.get("virtual_size", 0), info.get("actual_size", 0)
item.update(virtual_size=virtual, actual_size=actual, format=info.get("format", ""))
if virtual:
item["size_text"] = presenters.format_bytes(virtual)
item["used_text"] = presenters.format_bytes(actual) if actual else ""
item["min_gb"] = virtual // GIB + 1
item["resizable"] = item["format"] in RESIZABLE_FORMATS and inside_storage(disk["source"])
items.append(item)
return items
def disks_context(name: str) -> dict[str, Any]:
"""Данные для карточки «Диски» на странице машины (вызывается из шаблона)."""
disks = vm_disks(name)
first = next((d["source"] for d in disks if d["source"]), None)
return {"disks": disks, "free_text": presenters.format_bytes(free_space(first or core.IMAGES_DIR)), "max_gb": MAX_DISK_GB, "hint": GUEST_GROW_HINT}
@router.post("/vm/{name}/disks/{target}/resize")
def disk_resize(request: Request, name: str, target: str, size_gb: int = Form(...)):
denied = _guard(request, name)
if denied:
return denied
if not re.fullmatch(r"[a-z0-9]{1,16}", target or ""):
return JSONResponse({"ok": False, "error": "Invalid disk target"}, status_code=400)
disk = next((d for d in vm_disks(name) if d["target"] == target), None)
if not disk:
return redirect_tab(name, "storage", "disk_error", "Такого диска у машины нет")
if not disk["resizable"]:
return redirect_tab(name, "storage", "disk_error", f"Диск {disk['name']} нельзя увеличить из панели: он не в хранилище Virtuality или в необычном формате")
if size_gb < disk["min_gb"]:
return redirect_tab(name, "storage", "disk_error", f"Диск можно только увеличивать: укажите не меньше {disk['min_gb']} ГБ")
if size_gb > MAX_DISK_GB:
return redirect_tab(name, "storage", "disk_error", f"Слишком большой размер: не больше {MAX_DISK_GB // 1024} ТБ")
growth = size_gb * GIB - disk["virtual_size"]
free = free_space(disk["source"])
if growth > free:
return redirect_tab(name, "storage", "disk_error", f"На сервере свободно только {presenters.format_bytes(free)}: столько добавить диску не получится")
if is_shut_off(vm_state(name)):
result = run(["qemu-img", "resize", disk["source"], f"{size_gb}G"], timeout=120)
else:
result = run(["virsh", "blockresize", name, target, f"{size_gb}G"], timeout=60)
if not result["ok"]:
return redirect_tab(name, "storage", "disk_error", explain_error(cmd_error(result, "Не удалось увеличить диск"), "Не удалось увеличить диск"))
return redirect_tab(name, "storage", "disk_message", f"Диск {disk['name']} увеличен до {size_gb} ГБ. {GUEST_GROW_HINT}")
# ---------------------------------------------------------------- clone
def suggest_clone_name(name: str, existing: set[str]) -> str:
base = f"{name}-copy"[:60]
candidate, index = base, 2
while candidate in existing:
candidate = f"{base}{index}"
index += 1
return candidate
def clone_targets(new_name: str, disks: list[dict[str, Any]]) -> list[Path]:
"""Файлы дисков копии: DST.qcow2, DST-disk2.qcow2, … в порядке domblklist."""
targets = []
cloneable = [d for d in disks if d["device"] == "disk" and d["source"]]
for index, disk in enumerate(cloneable):
ext = Path(disk["source"]).suffix.lower()
ext = ext if ext in (".qcow2", ".img", ".raw") else ".qcow2"
suffix = "" if index == 0 else f"-disk{index + 1}"
targets.append(Path(core.IMAGES_DIR) / f"{new_name}{suffix}{ext}")
return targets
def clone_context(name: str) -> dict[str, Any]:
state = vm_state(name)
disks = vm_disks(name)
cloneable = [d for d in disks if d["device"] == "disk" and d["source"]]
needed = sum(d["actual_size"] or d["virtual_size"] for d in cloneable)
free = free_space(core.IMAGES_DIR)
existing = set(all_vm_names())
blockers = []
if not is_shut_off(state):
blockers.append("running")
if not cloneable:
blockers.append("no_disks")
if needed > free:
blockers.append("space")
if any(op.get("source_vm") == name for op in running_operations("vm_clone")):
blockers.append("busy")
return {
"state": state, "is_shut_off": is_shut_off(state), "disks": cloneable, "needed_text": presenters.format_bytes(needed),
"free_text": presenters.format_bytes(free), "suggested": suggest_clone_name(name, existing), "blockers": blockers,
"vcpus": 0, "memory_mb": 0,
}
def _allocated_bytes(path: Path) -> int:
try:
stat = path.stat()
except OSError:
return 0
blocks = getattr(stat, "st_blocks", None)
return blocks * 512 if blocks is not None else stat.st_size
def _watch_clone_progress(operation: dict[str, Any], targets: list[Path], expected: int, stop: threading.Event) -> None:
"""Пока virt-clone копирует, следим за ростом файлов копии и обновляем прогресс."""
while not stop.wait(1.0):
copied = sum(_allocated_bytes(target) for target in targets)
if expected > 0:
pct = min(95, max(3, int(copied * 95 / expected)))
update_operation(operation, progress=pct, message=f"Копируем диски: {presenters.format_bytes(copied)} из {presenters.format_bytes(expected)}")
def clone_worker(operation: dict[str, Any]) -> None:
source, new_name = operation["source_vm"], operation["vm_name"]
targets = [Path(item) for item in operation.get("targets", [])]
cmd = ["virt-clone", "--original", source, "--name", new_name]
for target in targets:
cmd += ["--file", str(target)]
update_operation(operation, progress=3, message=f"Копируем диски машины {source}…", cmd=" ".join(cmd))
append_operation_log(operation["id"], " ".join(cmd))
stop = threading.Event()
watcher = threading.Thread(target=_watch_clone_progress, args=(operation, targets, int(operation.get("expected_bytes") or 0), stop), daemon=True)
watcher.start()
try:
result = run(cmd, timeout=CLONE_TIMEOUT)
finally:
stop.set()
watcher.join(timeout=5)
for stream in ("stdout", "stderr"):
if result.get(stream):
append_operation_log(operation["id"], result[stream])
if not result["ok"]:
for target in targets: # virt-clone обычно сам убирает недокопированные файлы, но подстрахуемся
try:
if target.exists() and not run(["virsh", "dominfo", new_name], timeout=8)["ok"]:
target.unlink()
except OSError:
pass
finish_operation(operation, False, explain_error(cmd_error(result, "virt-clone завершился с ошибкой"), "Не удалось клонировать машину"))
return
update_operation(operation, progress=97, message="Обновляем список дисков…")
refresh = run(["virsh", "pool-refresh", IMAGES_POOL], timeout=60)
if not refresh["ok"]:
append_operation_log(operation["id"], f"pool-refresh: {cmd_error(refresh, 'не удалось обновить хранилище (не страшно)')}")
finish_operation(operation, True, f"Машина {new_name} создана — это копия {source}. Запустите её и смените имя компьютера внутри системы.")
@router.get("/vm/{name}/clone")
def clone_page(request: Request, name: str):
denied = _guard(request, name)
if denied:
return denied
context = clone_context(name)
context.update(vm_name=name, error=request.query_params.get("clone_error", ""), new_name=request.query_params.get("new_name", "") or context["suggested"])
return render(request, "vm_clone.html", context)
@router.post("/vm/{name}/clone/start")
def clone_vm(request: Request, name: str, new_name: str = Form("")):
denied = _guard(request, name)
if denied:
return denied
new_name = (new_name or "").strip()
def fail(message: str):
return RedirectResponse(url=f"/vm/{name}/clone?clone_error={quote(message)}&new_name={quote(new_name)}", status_code=303)
if not valid_vm_name(new_name):
return fail("Имя копии: от 2 до 63 символов — латинские буквы, цифры, точка, дефис и подчёркивание, первый символ — буква или цифра")
if new_name.lower() == name.lower() or new_name in set(all_vm_names()) or vm_exists(new_name):
return fail(f"Машина с именем {new_name} уже есть — выберите другое имя")
context = clone_context(name)
if "running" in context["blockers"]:
return fail("Сначала выключите машину: копировать диски можно только у выключенной машины")
if "no_disks" in context["blockers"]:
return fail("У машины нет дисков, которые можно скопировать")
if "busy" in context["blockers"]:
return fail("Эта машина уже клонируется — дождитесь окончания в разделе «Задачи»")
if any(op.get("vm_name") == new_name for op in running_operations("vm_clone")):
return fail(f"Копия с именем {new_name} уже создаётся — выберите другое имя")
if "space" in context["blockers"]:
return fail(f"Не хватает места: копии нужно {context['needed_text']}, свободно {context['free_text']}")
targets = clone_targets(new_name, context["disks"])
for target in targets:
if target.exists():
return fail(f"Файл {target.name} уже есть в хранилище — выберите другое имя")
needed = sum(d["actual_size"] or d["virtual_size"] for d in context["disks"])
operation = new_operation("vm_clone", f"Клонирование {name} → {new_name}", vm_name=new_name, source_vm=name, targets=[str(t) for t in targets], expected_bytes=needed)
run_operation(operation, clone_worker)
return RedirectResponse(url=f"/operations/{operation['id']}", status_code=303)
# ---------------------------------------------------------------- pause / resume / hibernate
def hibernate_worker(operation: dict[str, Any]) -> None:
name = operation["vm_name"]
update_operation(operation, progress=10, message="Сохраняем память машины на диск сервера…")
result = run(["virsh", "managedsave", name], timeout=HIBERNATE_TIMEOUT)
for stream in ("stdout", "stderr"):
if result.get(stream):
append_operation_log(operation["id"], result[stream])
if not result["ok"]:
finish_operation(operation, False, explain_error(cmd_error(result, "virsh managedsave завершился с ошибкой"), "Не удалось перевести машину в спящий режим"))
return
finish_operation(operation, True, f"Машина {name} в спящем режиме. При запуске она продолжит работу с того же места.")
def hibernate(name: str, state: str, target: str):
if state not in ACTIVE_STATES + ("paused",):
return redirect_with_message(target, "ops_error", "В спящий режим можно перевести только работающую или приостановленную машину")
if running_operations("vm_hibernate"):
return redirect_with_message(target, "ops_error", "Другая машина сейчас засыпает — подождите минуту")
info = presenters.parse_dominfo(run(["virsh", "dominfo", name], timeout=8).get("stdout", ""))
needed = info["memory_mb"] * 1024 * 1024
if needed and needed > free_space(SAVE_DIR / "x"):
return redirect_with_message(target, "ops_error", f"Для спящего режима нужно {presenters.format_bytes(needed)} свободного места на сервере, а его нет")
operation = new_operation("vm_hibernate", f"Спящий режим: {name}", vm_name=name)
run_operation(operation, hibernate_worker)
return redirect_with_message(target, "ops_message", f"Машина {name} засыпает: состояние сохраняется на диск, это займёт до минуты")
@router.post("/vm/{name}/ops/{action}")
def vm_ops(request: Request, name: str, action: str, next: str = Form("")):
denied = require_auth(request)
if denied:
return denied
if not valid_vm_name(name):
return JSONResponse({"ok": False, "error": "Invalid VM name"}, status_code=400)
if action != "hibernate" and action not in OPS:
return JSONResponse({"ok": False, "error": "Unsupported action"}, status_code=400)
if not vm_exists(name):
return RedirectResponse(url="/", status_code=303)
target = safe_next(next, name)
state = vm_state(name)
if action == "hibernate":
return hibernate(name, state, target)
spec = OPS[action]
if state not in spec["states"]:
return redirect_with_message(target, "ops_error", spec["wrong"])
result = run(spec["cmd"] + [name], timeout=30)
if not result["ok"]:
return redirect_with_message(target, "ops_error", explain_error(cmd_error(result, spec["fail"]), spec["fail"]))
return redirect_with_message(target, "ops_message", spec["done"].format(name=name))
# Шаблоны берут данные напрямую (как update_notice() в base.html), чтобы
# страница машины и обзор в app.py не менялись.
TEMPLATE_GLOBALS = {"vmops_disks": disks_context, "vmops_notes": read_notes, "vmops_titles": vm_titles, "vmops_saved": has_managed_save}
templates.env.globals.update(TEMPLATE_GLOBALS)
# Обзор и страница машины пока рендерятся отдельным окружением шаблонов из app.py —
# подключаем и его. Циклический импорт безопасен: app.py импортирует этот модуль
# последними строками, когда templates уже создан.
import app as _app # noqa: E402
if getattr(_app, "templates", None) is not None and _app.templates is not templates:
_app.templates.env.globals.update(TEMPLATE_GLOBALS)
+25
Просмотреть файл
@@ -249,3 +249,28 @@ def format_bytes(value: int | float) -> str:
return f"{value:.0f} {unit}" if value >= 10 or unit == "Б" else f"{value:.1f} {unit}"
value /= 1024
return f"{value:.0f} ТБ"
def parse_virsh_list_titles(text: str) -> dict[str, str]:
"""`virsh list --all --title` → {имя: название}. Колонки virsh выровнены
пробелами, поэтому название берём по смещению колонки Title из заголовка:
так не ломаются состояния с пробелом («shut off») и названия из нескольких слов."""
lines = (text or "").splitlines()
if not lines:
return {}
header = lines[0]
name_col, title_col = re.search(r"\bName\b", header), re.search(r"\bTitle\b", header)
if not name_col or not title_col:
return {}
titles: dict[str, str] = {}
for line in lines[1:]:
stripped = line.strip()
if not stripped or set(stripped) <= {"-"}:
continue
name_part = line[name_col.start():].split()
if not name_part:
continue
title = line[title_col.start():].strip()
if title:
titles[name_part[0]] = title
return titles
+25
Просмотреть файл
@@ -630,3 +630,28 @@ details.advanced > .advanced-body { padding: 4px 16px 16px; }
.download-error { color: var(--danger); }
.catalog-item .meta code { padding: 1px 5px; border-radius: 5px; background: var(--surface-3); font: 12px/1.4 var(--mono); color: var(--text-2); }
@media (max-width: 640px) { .download-item, .catalog-item { flex-wrap: wrap; } .download-item .grow, .catalog-item .grow { flex-basis: calc(100% - 50px); } }
/* ---------------------------------------------------------- vmops: live load, disks, notes */
.list > * { min-width: 0; } /* длинное сообщение задачи обрезается, а не растягивает карточку на телефоне */
.vm-title { color: var(--text-2); font-weight: 500; }
.vm-meters { display: grid; gap: 3px; margin-top: 6px; max-width: 220px; }
.vm-meter { display: grid; grid-template-columns: 14px minmax(0, 1fr) 46px; align-items: center; gap: 7px; color: var(--text-3); font-size: 11.5px; }
.vm-meter .icon-sm { width: 14px; height: 14px; }
.vm-meter .meter { display: block; margin: 0; height: 5px; }
.vm-meter b { font-weight: 600; color: var(--text-2); text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; }
.load-grid { display: grid; gap: 16px; }
.load-row { display: grid; gap: 8px; }
.load-row .row-between { font-size: 13px; }
.load-row .row-between .subtle { display: inline-flex; align-items: center; gap: 6px; }
.load-row .meter { margin-top: 0; }
.disk-resize { display: flex; align-items: center; gap: 8px; flex: none; }
.disk-resize .input { width: 84px; height: 32px; padding: 0 10px; border-radius: 8px 0 0 8px; font-size: 13px; }
.disk-resize .addon { padding: 0 9px; border-radius: 0 8px 8px 0; font-size: 12px; }
.disk-footer { display: block; }
.disk-footer p { margin: 0; line-height: 1.5; }
textarea.input { height: auto; min-height: 96px; padding: 9px 12px; resize: vertical; line-height: 1.45; }
textarea.input:focus { border-color: var(--brand); outline: none; box-shadow: 0 0 0 4px var(--focus); }
@media (max-width: 640px) {
.list-item.disk-item { flex-wrap: wrap; }
.disk-resize { width: 100%; justify-content: flex-end; }
}
+2
Просмотреть файл
@@ -59,4 +59,6 @@
<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>
<symbol id="i-notebook-pen" viewBox="0 0 24 24"><path d="M13.4 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-7.4" /><path d="M2 6h4" /><path d="M2 10h4" /><path d="M2 14h4" /><path d="M2 18h4" /><path d="M21.378 5.626a1 1 0 1 0-3.004-3.004l-5.01 5.012a2 2 0 0 0-.506.854l-.837 2.87a.5.5 0 0 0 .62.62l2.87-.837a2 2 0 0 0 .854-.506z" /></symbol>
<symbol id="i-expand" viewBox="0 0 24 24"><path d="m15 15 6 6" /><path d="m15 9 6-6" /><path d="M21 16v5h-5" /><path d="M21 8V3h-5" /><path d="M3 16v5h5" /><path d="m3 21 6-6" /><path d="M3 8V3h5" /><path d="M9 9 3 3" /></symbol>
</svg>

До

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

После

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

+36
Просмотреть файл
@@ -198,6 +198,41 @@
setInterval(refreshLive, 5000);
}
/* ---------------------------------------------------- live VM load (CPU / RAM meters) */
const levelTone = (pct) => (pct >= 90 ? 'danger' : pct >= 75 ? 'warning' : 'success');
const formatMb = (mb) => (mb >= 1024 ? `${(mb / 1024).toFixed(mb >= 10240 ? 0 : 1)} ГБ` : `${Math.round(mb)} МБ`);
function applyStatMeter(root, key, pct, label) {
const bar = $(`[data-stat="${key}-bar"]`, root);
const meter = $(`[data-stat="${key}-meter"]`, root);
const text = $(`[data-stat="${key}"]`, root);
if (bar) bar.style.setProperty('--value', `${pct == null ? 0 : Math.round(pct)}%`);
if (meter) meter.className = `meter ${pct == null ? '' : levelTone(pct)}`;
if (text) text.textContent = label;
}
async function refreshStats() {
if (document.hidden) return;
try {
const response = await fetch('/live/stats', { cache: 'no-store', headers: { Accept: 'application/json' } });
if (!response.ok) return;
const payload = await response.json();
if (!payload.ok) return;
$$('[data-live-stats]').forEach((root) => {
const stat = payload.stats[root.dataset.liveStats];
root.hidden = !stat;
if (!stat) return;
const cpu = stat.cpu_pct == null ? null : Math.round(stat.cpu_pct);
const memPct = stat.mem_total_mb ? Math.min(100, Math.round((stat.mem_used_mb / stat.mem_total_mb) * 100)) : null;
applyStatMeter(root, 'cpu', cpu, cpu == null ? '…' : `${cpu}%`);
applyStatMeter(root, 'mem', memPct, stat.mem_total_mb ? (root.classList.contains('vm-meters') ? `${memPct}%` : `${formatMb(stat.mem_used_mb)} из ${formatMb(stat.mem_total_mb)}`) : '—');
});
} catch (_) {}
}
function wireStats() {
if (!$('[data-live-stats]')) return;
refreshStats();
setInterval(refreshStats, 5000);
}
/* ---------------------------------------------------- uploads */
function wireUploader(form) {
const input = $('input[type=file]', form);
@@ -468,6 +503,7 @@
wireTabs();
wireCopy();
wireLive();
wireStats();
$$('form[data-uploader]').forEach(wireUploader);
wireOperation();
wireLogs();
+27
Просмотреть файл
@@ -0,0 +1,27 @@
{#- Карточка «Диски» на вкладке «Диски и загрузка»: размеры и форма увеличения. Ожидает vm из vm_detail.html. -#}
{% from "_ui.html" import icon %}
{% set dk = vmops_disks(vm.name) %}
<article class="card">
<div class="card-header"><div><h2>Диски</h2><p>Диск можно увеличить, уменьшить — нельзя</p></div></div>
<div class="list">
{% for disk in dk.disks %}
<div class="list-item disk-item">
<span class="list-icon">{{ icon('disc-3' if disk.is_cdrom else 'hard-drive') }}</span>
<div class="grow">
<div class="title truncate">{{ disk.name }}</div>
<div class="meta">{{ disk.label }} {{ disk.target }}{% if disk.size_text %} · {{ disk.size_text }}{% if disk.used_text %}, занято {{ disk.used_text }}{% endif %}{% endif %}</div>
</div>
{% if disk.resizable %}
<form method="post" action="/vm/{{ vm.name }}/disks/{{ disk.target }}/resize" class="disk-resize" data-confirm="Диск {{ disk.name }} станет больше. Уменьшить его обратно уже не получится." data-confirm-title="Увеличить диск?" data-confirm-button="Увеличить" data-confirm-tone="brand" data-busy="Увеличиваем…">
<label class="subtle small" for="disk-size-{{ disk.target }}">до</label>
<div class="input-group"><input class="input" id="disk-size-{{ disk.target }}" type="number" name="size_gb" min="{{ disk.min_gb }}" max="{{ dk.max_gb }}" step="1" value="{{ disk.min_gb }}" required aria-label="Новый размер диска {{ disk.name }} в гигабайтах"><span class="addon">ГБ</span></div>
<button class="btn btn-sm" type="submit">{{ icon('expand', 'icon-sm') }}Увеличить</button>
</form>
{% endif %}
</div>
{% else %}
<div class="list-item subtle">Дисков не найдено</div>
{% endfor %}
</div>
<div class="card-footer disk-footer"><p class="subtle small">Свободно на сервере: <strong>{{ dk.free_text }}</strong>. {{ dk.hint }}</p></div>
</article>
+6
Просмотреть файл
@@ -0,0 +1,6 @@
{#- Две маленькие шкалы (процессор, память) в строке машины на обзоре; panel.js заполняет их из /live/stats. -#}
{% from "_ui.html" import icon %}
<div class="vm-meters" data-live-stats="{{ vm.name }}" {% if not s.running %}hidden{% endif %}>
<span class="vm-meter" title="Процессор">{{ icon('cpu', 'icon-sm') }}<span class="meter" data-stat="cpu-meter"><span data-stat="cpu-bar" style="--value:0%"></span></span><b data-stat="cpu">—</b></span>
<span class="vm-meter" title="Память">{{ icon('memory-stick', 'icon-sm') }}<span class="meter" data-stat="mem-meter"><span data-stat="mem-bar" style="--value:0%"></span></span><b data-stat="mem">—</b></span>
</div>
+6
Просмотреть файл
@@ -0,0 +1,6 @@
{#- Сообщения после действий vmops (пауза, диски, заметки): читаются из адреса страницы. -#}
{% from "_ui.html" import alert %}
{% for key in ('ops', 'disk', 'notes') %}
{% if request.query_params.get(key ~ '_message') %}{{ alert('success', request.query_params.get(key ~ '_message')) }}{% endif %}
{% if request.query_params.get(key ~ '_error') %}{{ alert('danger', request.query_params.get(key ~ '_error')) }}{% endif %}
{% endfor %}
+43
Просмотреть файл
@@ -0,0 +1,43 @@
{#- Карточки на обзоре машины: нагрузка сейчас, спящий режим, заметки. Ожидает vm из vm_detail.html. -#}
{% from "_ui.html" import icon %}
{% set notes = vmops_notes(vm.name) %}
{% set ops_s = (vm.info.state or 'shut off')|vm_state %}
{% if ops_s.tone != 'neutral' %}
<article class="card" data-live-stats="{{ vm.name }}">
<div class="card-header"><div><h2>Нагрузка сейчас</h2><p>Обновляется каждые 5 секунд</p></div><span class="stat-icon">{{ icon('activity', 'icon-sm') }}</span></div>
<div class="card-body load-grid">
<div class="load-row">
<div class="row-between"><span class="subtle">{{ icon('cpu', 'icon-sm') }}Процессор</span><strong data-stat="cpu">—</strong></div>
<div class="meter" data-stat="cpu-meter"><span data-stat="cpu-bar" style="--value:0%"></span></div>
</div>
<div class="load-row">
<div class="row-between"><span class="subtle">{{ icon('memory-stick', 'icon-sm') }}Память</span><strong data-stat="mem">—</strong></div>
<div class="meter" data-stat="mem-meter"><span data-stat="mem-bar" style="--value:0%"></span></div>
</div>
</div>
</article>
{% endif %}
{% if vmops_saved(vm.dominfo) %}
<article class="card">
<div class="card-body stack-sm">
<div class="row" style="flex-wrap:nowrap;align-items:flex-start"><span class="list-icon brand">{{ icon('moon') }}</span><div class="grow"><strong>Машина в спящем режиме</strong><div class="subtle small">При запуске она продолжит работу с того же места: открытые программы и окна останутся.</div></div></div>
<form method="post" action="/vm/{{ vm.name }}/ops/forget-save" data-confirm="Сохранённое состояние будет удалено: всё, что было открыто внутри машины и не сохранено, пропадёт. При запуске система загрузится заново." data-confirm-title="Забыть сохранённое состояние?" data-confirm-button="Забыть" data-toast="Сохранённое состояние удалено" data-toast-tone="warning"><button class="btn btn-sm btn-danger-soft">{{ icon('x', 'icon-sm') }}Забыть сохранённое состояние</button></form>
</div>
</article>
{% endif %}
<article class="card">
<div class="card-header"><div><h2>Заметки</h2><p>Название видно в списке машин</p></div><span class="stat-icon">{{ icon('notebook-pen', 'icon-sm') }}</span></div>
<form method="post" action="/vm/{{ vm.name }}/notes/save" data-toast="Заметки сохранены" data-busy="Сохраняем…">
<div class="card-body stack-sm">
<div class="field">
<label for="vm-title">Название</label>
<input class="input" id="vm-title" name="title" maxlength="80" value="{{ notes.title }}" placeholder="Например: Сайт компании" autocomplete="off">
</div>
<div class="field">
<label for="vm-description">Описание</label>
<textarea class="input" id="vm-description" name="description" rows="4" maxlength="2000" placeholder="Что стоит внутри, кто отвечает, когда делать резервные копии…">{{ notes.description }}</textarea>
</div>
</div>
<div class="card-footer"><button class="btn btn-sm btn-primary" type="submit">Сохранить заметки</button></div>
</form>
</article>
+15
Просмотреть файл
@@ -0,0 +1,15 @@
{#- Пункты меню машины: пауза, продолжение, спящий режим, клонирование.
Перед include задайте ops_name (имя), ops_state (состояние virsh),
ops_next (куда вернуться после действия) и ops_hints (подсказки под пунктами). -#}
{% from "_ui.html" import icon %}
{%- set ops = ops_state|vm_state -%}
{%- macro item(label, hint, hints) -%}<span>{{ label }}{% if hints %}<small>{{ hint }}</small>{% endif %}</span>{%- endmacro -%}
{% if ops.running %}
<form method="post" action="/vm/{{ ops_name }}/ops/pause" data-toast="Машина {{ ops_name }} приостановлена" data-toast-tone="warning"><input type="hidden" name="next" value="{{ ops_next }}"><button class="menu-item">{{ icon('pause') }}{{ item('Приостановить', 'Заморозить, оставив в памяти', ops_hints) }}</button></form>
<form method="post" action="/vm/{{ ops_name }}/ops/hibernate" data-confirm="Память машины сохранится на диск сервера, а сама машина выключится. При следующем запуске она продолжит работу с этого же места." data-confirm-title="Перевести {{ ops_name }} в спящий режим?" data-confirm-button="Усыпить" data-confirm-tone="brand" data-toast="Машина {{ ops_name }} засыпает"><input type="hidden" name="next" value="{{ ops_next }}"><button class="menu-item">{{ icon('moon') }}{{ item('Спящий режим', 'Сохранить на диск и выключить', ops_hints) }}</button></form>
{% elif ops.raw == 'paused' %}
<form method="post" action="/vm/{{ ops_name }}/ops/resume" data-toast="Машина {{ ops_name }} продолжает работу"><input type="hidden" name="next" value="{{ ops_next }}"><button class="menu-item">{{ icon('play') }}{{ item('Продолжить', 'Снять с паузы', ops_hints) }}</button></form>
<form method="post" action="/vm/{{ ops_name }}/ops/hibernate" data-confirm="Память машины сохранится на диск сервера, а сама машина выключится. При следующем запуске она продолжит работу с этого же места." data-confirm-title="Перевести {{ ops_name }} в спящий режим?" data-confirm-button="Усыпить" data-confirm-tone="brand" data-toast="Машина {{ ops_name }} засыпает"><input type="hidden" name="next" value="{{ ops_next }}"><button class="menu-item">{{ icon('moon') }}{{ item('Спящий режим', 'Сохранить на диск и выключить', ops_hints) }}</button></form>
{% endif %}
<a class="menu-item" href="/vm/{{ ops_name }}/clone">{{ icon('copy') }}{{ item('Клонировать', 'Точная копия с новым именем', ops_hints) }}</a>
<div class="menu-sep"></div>
+8 -2
Просмотреть файл
@@ -2,6 +2,7 @@
{% from "_ui.html" import icon, badge, vm_badge, op_badge, meter, alert %}
{% set page_title = 'Обзор' %}
{% set running = vms|selectattr('state', 'in', ['running', 'idle', 'blocked'])|list|length %}
{% set titles = vmops_titles() if vms else {} %}
{% block page_header %}
<div class="page-header">
@@ -26,6 +27,7 @@
{% if profile.virtualization_warning %}
{{ alert('warning', 'Машины будут работать медленно. Включите аппаратную виртуализацию (Intel VT-x / AMD-V) в настройках BIOS сервера.', 'Аппаратное ускорение недоступно') }}
{% endif %}
{% include "_vm_ops_alerts.html" %}
{% endblock %}
{% block content %}
@@ -69,17 +71,20 @@
<span class="vm-avatar {{ 'on' if s.running else '' }}">{{ icon('monitor') }}<span class="status-dot {{ s.tone }}" data-live="dot"></span></span>
<span style="min-width:0">
<span class="vm-name truncate" style="display:block">{{ vm.name }}</span>
<span class="vm-sub"><span data-live="ip">{{ vm.ip if vm.ip and vm.ip != '—' else ('IP появится после запуска' if not s.running else 'Получает адрес…') }}</span></span>
<span class="vm-sub">{% if titles.get(vm.name) %}<span class="vm-title">{{ titles[vm.name] }}</span> · {% endif %}<span data-live="ip">{{ vm.ip if vm.ip and vm.ip != '—' else ('IP появится после запуска' if not s.running else 'Получает адрес…') }}</span></span>
</span>
</a>
<div class="vm-col">
<div class="vm-cell-label">Ресурсы</div>
<div class="vm-cell-value">{{ vm.vcpus or '—' }} {{ plural(vm.vcpus or 0, 'ядро', 'ядра', 'ядер') }} · {{ vm.memory_mb|format_mb }}</div>
{% include "_vm_live_meters.html" %}
</div>
<div class="vm-col">{{ vm_badge(vm.state) }}</div>
<div class="vm-actions">
{% if s.running %}
<a class="btn btn-sm" href="/vm/{{ vm.name }}/console" target="_blank" rel="noopener">{{ icon('monitor', 'icon-sm') }}<span class="btn-label">Экран</span></a>
{% elif vm.state == 'paused' %}
<form method="post" action="/vm/{{ vm.name }}/ops/resume" class="inline-form" data-toast="Машина {{ vm.name }} продолжает работу"><input type="hidden" name="next" value="/"><button class="btn btn-sm">{{ icon('play', 'icon-sm') }}<span class="btn-label">Продолжить</span></button></form>
{% else %}
<form method="post" action="/vm/{{ vm.name }}/start" class="inline-form" data-toast="Машина {{ vm.name }} запускается"><button class="btn btn-sm">{{ icon('play', 'icon-sm') }}<span class="btn-label">Запустить</span></button></form>
{% endif %}
@@ -87,13 +92,14 @@
<summary class="btn btn-sm btn-icon btn-ghost" aria-label="Действия">{{ icon('ellipsis') }}</summary>
<div class="menu-list">
<a class="menu-item" href="/vm/{{ vm.name }}">{{ icon('settings') }}Открыть настройки</a>
{% set ops_name, ops_state, ops_next, ops_hints = vm.name, vm.state, '/', false %}{% include "_vm_ops_menu.html" %}
{% if s.running %}
<a class="menu-item" href="/vm/{{ vm.name }}/console" target="_blank" rel="noopener">{{ icon('monitor') }}Открыть экран</a>
<form method="post" action="/vm/{{ vm.name }}/reboot" data-toast="Машина {{ vm.name }} перезагружается"><button class="menu-item">{{ icon('rotate-cw') }}Перезагрузить</button></form>
<form method="post" action="/vm/{{ vm.name }}/shutdown" data-toast="Машина {{ vm.name }} выключается"><button class="menu-item">{{ icon('power') }}Выключить</button></form>
<div class="menu-sep"></div>
<form method="post" action="/vm/{{ vm.name }}/destroy" data-confirm="Машина будет выключена сразу, как при отключении питания. Несохранённые данные внутри неё пропадут." data-confirm-title="Выключить {{ vm.name }} принудительно?" data-confirm-button="Выключить" data-toast="Машина {{ vm.name }} выключена" data-toast-tone="warning"><button class="menu-item danger">{{ icon('zap') }}Выключить принудительно</button></form>
{% else %}
{% elif vm.state != 'paused' %}
<form method="post" action="/vm/{{ vm.name }}/start" data-toast="Машина {{ vm.name }} запускается"><button class="menu-item">{{ icon('play') }}Запустить</button></form>
<div class="menu-sep"></div>
<form method="post" action="/vm/{{ vm.name }}/delete" data-confirm="Машина и все её диски будут удалены без возможности восстановления." data-confirm-title="Удалить {{ vm.name }}?" data-confirm-button="Удалить навсегда" data-toast="Машина {{ vm.name }} удалена" data-toast-tone="warning"><button class="menu-item danger">{{ icon('trash-2') }}Удалить машину</button></form>
+71
Просмотреть файл
@@ -0,0 +1,71 @@
{% extends "base.html" %}
{% from "_ui.html" import icon, vm_badge, alert %}
{% set page_title = 'Клонировать ' ~ vm_name %}
{% set st = state|vm_state %}
{% block page_header %}
<div class="page-header">
<div>
<div class="eyebrow"><a href="/vm/{{ vm_name }}">{{ icon('arrow-left', 'icon-sm') }} {{ vm_name }}</a></div>
<div class="title-row"><h1>Клонировать {{ vm_name }}</h1><span data-live-vm="{{ vm_name }}" data-running="{{ '1' if st.running else '0' }}" data-reload-on-change>{{ vm_badge(state, 'lg') }}</span></div>
<p class="lead">Точная копия машины: те же диски, процессор и память. У копии будет своё имя и свой сетевой адрес.</p>
</div>
</div>
{% endblock %}
{% block alerts %}
{% if error %}{{ alert('danger', error) }}{% endif %}
{% endblock %}
{% block content %}
<section class="grid grid-main">
<div class="stack">
<article class="card">
<div class="card-header"><div><h2>Новая машина</h2><p>Копия появится в списке машин выключенной</p></div></div>
{% if blockers %}
<div class="card-body stack-sm" style="padding-bottom:0">
{% if 'running' in blockers %}
<div class="alert warning" role="status">{{ icon('triangle-alert') }}<div><strong>Сначала выключите машину</strong>Диски копируются только у выключенной машины, иначе копия может получиться повреждённой.</div>
{% if st.running %}<form method="post" action="/vm/{{ vm_name }}/shutdown" class="alert-action" data-toast="Машина выключается — это может занять до минуты"><button class="btn btn-sm">{{ icon('power', 'icon-sm') }}Выключить</button></form>{% endif %}
</div>
{% endif %}
{% if 'space' in blockers %}{{ alert('danger', 'Копии нужно ' ~ needed_text ~ ', а свободно только ' ~ free_text ~ '. Удалите ненужные машины или образы.', 'Не хватает места на сервере') }}{% endif %}
{% if 'no_disks' in blockers %}{{ alert('danger', 'У машины нет дисков, которые можно скопировать.') }}{% endif %}
{% if 'busy' in blockers %}{{ alert('info', 'Эта машина уже клонируется. Дождитесь окончания в разделе «Задачи».') }}{% endif %}
</div>
{% endif %}
<form method="post" action="/vm/{{ vm_name }}/clone/start" data-busy="Запускаем…">
<div class="card-body form-grid">
<div class="field">
<label for="new_name">Имя копии</label>
<input class="input" id="new_name" name="new_name" value="{{ new_name }}" required pattern="[A-Za-z0-9][A-Za-z0-9_.\-]{1,62}" maxlength="63" autocomplete="off" {% if blockers %}disabled{% endif %}>
<span class="hint">Латинские буквы, цифры, точка, дефис и подчёркивание. Например: {{ suggested }}</span>
</div>
<div class="kv">
<div style="grid-column: span 2"><span>Что скопируется</span><strong>{% for d in disks %}{{ d.name }}{% if d.size_text %} · {{ d.size_text }}{% endif %}{% if not loop.last %}<br>{% endif %}{% else %}—{% endfor %}</strong></div>
<div><span>Нужно места</span><strong>{{ needed_text }}</strong></div>
<div><span>Свободно на сервере</span><strong>{{ free_text }}</strong></div>
</div>
</div>
<div class="card-footer">
<span class="subtle small" style="margin-right:auto">Занимает от минуты до часа — зависит от размера дисков</span>
<a class="btn" href="/vm/{{ vm_name }}">Отмена</a>
<button class="btn btn-primary" type="submit" {% if blockers %}disabled{% endif %}>{{ icon('copy') }}Клонировать</button>
</div>
</form>
</article>
</div>
<aside class="stack">
<article class="card">
<div class="card-header"><h2>Как это работает</h2></div>
<div class="list">
<div class="list-item"><span class="list-icon brand">{{ icon('hard-drive') }}</span><div class="grow"><div class="title">Диски копируются целиком</div><div class="meta">Внутри копии те же система, программы и пароли.</div></div></div>
<div class="list-item"><span class="list-icon">{{ icon('network') }}</span><div class="grow"><div class="title">Свой сетевой адрес</div><div class="meta">Копия получит новый MAC-адрес, а IP появится после запуска.</div></div></div>
<div class="list-item"><span class="list-icon">{{ icon('monitor') }}</span><div class="grow"><div class="title">Смените имя компьютера</div><div class="meta">Внутри системы копия называется как оригинал. Переименуйте её после первого запуска, чтобы не путаться.</div></div></div>
<div class="list-item"><span class="list-icon">{{ icon('layers') }}</span><div class="grow"><div class="title">Снимки не копируются</div><div class="meta">В копию попадает только текущее состояние дисков.</div></div></div>
</div>
</article>
</aside>
</section>
{% endblock %}
+6 -10
Просмотреть файл
@@ -21,12 +21,15 @@
{% if s.running %}
<a class="btn btn-primary" href="/vm/{{ vm.name }}/console" target="_blank" rel="noopener">{{ icon('monitor') }}Открыть экран</a>
<form method="post" action="/vm/{{ vm.name }}/shutdown" class="inline-form" data-toast="Машина выключается — это может занять до минуты"><button class="btn">{{ icon('power') }}Выключить</button></form>
{% elif s.raw == 'paused' %}
<form method="post" action="/vm/{{ vm.name }}/ops/resume" class="inline-form" data-toast="Машина продолжает работу"><button class="btn btn-primary">{{ icon('play') }}Продолжить</button></form>
{% else %}
<form method="post" action="/vm/{{ vm.name }}/start" class="inline-form" data-toast="Машина запускается"><button class="btn btn-primary">{{ icon('play') }}Запустить</button></form>
{% endif %}
<details class="menu">
<summary class="btn btn-icon" aria-label="Другие действия">{{ icon('ellipsis') }}</summary>
<div class="menu-list">
{% set ops_name, ops_state, ops_next, ops_hints = vm.name, s.raw, '/vm/' ~ vm.name, true %}{% include "_vm_ops_menu.html" %}
{% if s.running %}
<form method="post" action="/vm/{{ vm.name }}/reboot" data-toast="Машина перезагружается"><button class="menu-item">{{ icon('rotate-cw') }}<span>Перезагрузить<small>Мягкая перезагрузка системы</small></span></button></form>
<form method="post" action="/vm/{{ vm.name }}/destroy" data-confirm="Машина будет выключена сразу, как при отключении питания. Несохранённые данные внутри неё пропадут." data-confirm-title="Выключить принудительно?" data-confirm-button="Выключить" data-toast="Машина выключена" data-toast-tone="warning"><button class="menu-item danger">{{ icon('zap') }}<span>Выключить принудительно<small>Если система зависла</small></span></button></form>
@@ -48,6 +51,7 @@
{% if msg %}{{ alert('success', msg) }}{% endif %}
{% if err %}{{ alert('danger', err) }}{% endif %}
{% endfor %}
{% include "_vm_ops_alerts.html" %}
{% endblock %}
{% block content %}
@@ -98,6 +102,7 @@
</div>
<div class="stack">
{% include "_vm_ops_cards.html" %}
<article class="card">
<div class="card-body stack-sm">
<form method="post" action="/vm/{{ vm.name }}/{{ 'autostart-disable' if vm.autostart_enabled else 'autostart' }}" class="switch-row" data-no-busy data-toast="{{ 'Автозапуск выключен' if vm.autostart_enabled else 'Машина будет запускаться вместе с сервером' }}">
@@ -191,16 +196,7 @@
</div>
</article>
<article class="card">
<div class="card-header"><h2>Диски</h2></div>
<div class="list">
{% for disk in vm.disk_list %}
<div class="list-item"><span class="list-icon">{{ icon('disc-3' if disk.is_cdrom else 'hard-drive') }}</span><div class="grow"><div class="title truncate">{{ disk.name }}</div><div class="meta">{{ disk.label }} · {{ disk.target }}</div></div></div>
{% else %}
<div class="list-item subtle">Дисков не найдено</div>
{% endfor %}
</div>
</article>
{% include "_vm_disks.html" %}
<article class="card">
<div class="card-header"><h2>Сетевые карты</h2></div>