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
Этот коммит содержится в:
+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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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 |
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
|
||||
Ссылка в новой задаче
Block a user