Повседневные операции с машиной были добавлены: диск, клон, пауза, заметки, нагрузка
Модуль web/features/vmops.py (router подключён двумя строками в app.py):
- Увеличение диска на вкладке «Диски и загрузка»: размер из qemu-img info,
только рост, проверка свободного места; выключенная машина — qemu-img resize,
работающая — virsh blockresize. Диски вне /var/lib/virtuality не трогаются.
- Клонирование: страница /vm/{name}/clone с проверками (машина выключена,
хватает места, имя свободно), фоновая операция virt-clone с явными --file
в хранилище образов, прогресс по росту файлов копии, pool-refresh после.
- Пауза, продолжение, спящий режим (managedsave как операция) и удаление
сохранённого состояния — /vm/{name}/ops/{action}; пункты меню на обзоре
и странице машины, кнопка «Продолжить» для приостановленной машины.
- Заметки: название и описание через virsh desc (--new-desc=ТЕКСТ одним
аргументом, чтобы текст вида «--edit» не стал опцией); название видно
под именем машины на обзоре (virsh list --all --title, парсер в presenters).
- Живая нагрузка: /live/stats по virsh domstats для всех активных машин
одним вызовом, CPU % по разнице cpu.time между опросами; шкалы в строках
обзора и карточка «Нагрузка сейчас» на странице машины (panel.js, 5 с).
Тесты для всех парсеров и маршрутов (tests/test_vmops.py), демо-данные
в dev_server.py (три машины в разных состояниях, живые шкалы), значки
notebook-pen и expand в спрайте.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg
Этот коммит содержится в:
@@ -35,6 +35,12 @@ DUMPXML = """<domain type='kvm'>
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
FIXTURES = ROOT / "tests" / "fixtures"
|
||||||
|
|
||||||
|
# Заметки машин для `virsh desc`: имя → (название, описание).
|
||||||
|
NOTES = {"web01": ("Сайт компании", "Nginx + база. Бэкап по пятницам.")}
|
||||||
|
|
||||||
|
|
||||||
def fake_result(stdout: str = "", ok: bool = True, stderr: str = "") -> dict:
|
def fake_result(stdout: str = "", ok: bool = True, stderr: str = "") -> dict:
|
||||||
return {"ok": ok, "code": 0 if ok else 1, "stdout": stdout, "stderr": stderr, "cmd": ""}
|
return {"ok": ok, "code": 0 if ok else 1, "stdout": stdout, "stderr": stderr, "cmd": ""}
|
||||||
|
|
||||||
@@ -42,6 +48,10 @@ def fake_result(stdout: str = "", ok: bool = True, stderr: str = "") -> dict:
|
|||||||
def fake_run_cmd(cmd, timeout=12, **_kwargs):
|
def fake_run_cmd(cmd, timeout=12, **_kwargs):
|
||||||
joined = " ".join(cmd)
|
joined = " ".join(cmd)
|
||||||
if cmd[:2] == ["virsh", "list"]:
|
if cmd[:2] == ["virsh", "list"]:
|
||||||
|
if "--title" in cmd:
|
||||||
|
return fake_result((FIXTURES / "list-title.txt").read_text())
|
||||||
|
if "--name" in cmd:
|
||||||
|
return fake_result("web01\ndb01\n" if "--all" in cmd else "web01\n")
|
||||||
return fake_result(" Id Name State\n-----------------------\n 1 web01 running\n - db01 shut off")
|
return fake_result(" Id Name State\n-----------------------\n 1 web01 running\n - db01 shut off")
|
||||||
if cmd[:2] == ["virsh", "dominfo"]:
|
if cmd[:2] == ["virsh", "dominfo"]:
|
||||||
return fake_result(DOMINFO) if cmd[-1] in ("web01", "db01") else fake_result(ok=False, stderr="failed to get domain")
|
return fake_result(DOMINFO) if cmd[-1] in ("web01", "db01") else fake_result(ok=False, stderr="failed to get domain")
|
||||||
@@ -49,6 +59,20 @@ def fake_run_cmd(cmd, timeout=12, **_kwargs):
|
|||||||
return fake_result(DUMPXML)
|
return fake_result(DUMPXML)
|
||||||
if cmd[:2] == ["virsh", "domstate"]:
|
if cmd[:2] == ["virsh", "domstate"]:
|
||||||
return fake_result("running")
|
return fake_result("running")
|
||||||
|
if cmd[:2] == ["virsh", "domblklist"]:
|
||||||
|
import core # диск лежит в подменённом data_dirs хранилище — иначе панель откажется его увеличивать
|
||||||
|
|
||||||
|
return fake_result((FIXTURES / "domblklist-details.txt").read_text().replace("/var/lib/virtuality/images", str(core.IMAGES_DIR)))
|
||||||
|
if cmd[:2] == ["virsh", "domstats"]:
|
||||||
|
return fake_result((FIXTURES / "domstats.txt").read_text())
|
||||||
|
if cmd[:1] == ["virsh"] and "desc" in cmd[:3]:
|
||||||
|
if any(arg.startswith("--new-desc") for arg in cmd):
|
||||||
|
return fake_result("Domain description updated successfully")
|
||||||
|
vm = cmd[cmd.index("desc") + 1]
|
||||||
|
title, description = NOTES.get(vm, ("", ""))
|
||||||
|
return fake_result(title if "--title" in cmd else description)
|
||||||
|
if cmd[:2] == ["qemu-img", "info"]:
|
||||||
|
return fake_result((FIXTURES / "qemu-img-info.json").read_text())
|
||||||
if cmd[:2] == ["virsh", "domifaddr"]:
|
if cmd[:2] == ["virsh", "domifaddr"]:
|
||||||
return fake_result(" Name MAC address Protocol Address\n vnet0 52:54:00:aa:bb:cc ipv4 192.168.100.51/24")
|
return fake_result(" Name MAC address Protocol Address\n vnet0 52:54:00:aa:bb:cc ipv4 192.168.100.51/24")
|
||||||
if cmd[:2] == ["virsh", "domiflist"]:
|
if cmd[:2] == ["virsh", "domiflist"]:
|
||||||
@@ -75,6 +99,7 @@ def fake_run_cmd(cmd, timeout=12, **_kwargs):
|
|||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def data_dirs(tmp_path, monkeypatch):
|
def data_dirs(tmp_path, monkeypatch):
|
||||||
import app
|
import app
|
||||||
|
import core
|
||||||
import host_profile
|
import host_profile
|
||||||
import network_core
|
import network_core
|
||||||
import update_core
|
import update_core
|
||||||
@@ -91,10 +116,12 @@ def data_dirs(tmp_path, monkeypatch):
|
|||||||
}
|
}
|
||||||
for path in dirs.values():
|
for path in dirs.values():
|
||||||
path.mkdir()
|
path.mkdir()
|
||||||
monkeypatch.setattr(app, "ISO_DIR", dirs["iso"])
|
for module in (app, core):
|
||||||
monkeypatch.setattr(app, "IMAGES_DIR", dirs["images"])
|
monkeypatch.setattr(module, "ISO_DIR", dirs["iso"])
|
||||||
monkeypatch.setattr(app, "DISK_IMAGES_DIR", dirs["disk_images"])
|
monkeypatch.setattr(module, "IMAGES_DIR", dirs["images"])
|
||||||
monkeypatch.setattr(app, "OPERATIONS_DIR", dirs["operations"])
|
monkeypatch.setattr(module, "DISK_IMAGES_DIR", dirs["disk_images"])
|
||||||
|
monkeypatch.setattr(module, "OPERATIONS_DIR", dirs["operations"])
|
||||||
|
monkeypatch.setattr(core, "STORAGE_DIR", tmp_path)
|
||||||
monkeypatch.setattr(network_core, "CONFIG_DIR", dirs["config"])
|
monkeypatch.setattr(network_core, "CONFIG_DIR", dirs["config"])
|
||||||
monkeypatch.setattr(network_core, "NETWORK_DIR", dirs["network"])
|
monkeypatch.setattr(network_core, "NETWORK_DIR", dirs["network"])
|
||||||
monkeypatch.setattr(network_core, "NFT_DIR", dirs["nft"])
|
monkeypatch.setattr(network_core, "NFT_DIR", dirs["nft"])
|
||||||
@@ -108,7 +135,7 @@ def data_dirs(tmp_path, monkeypatch):
|
|||||||
monkeypatch.setattr(update_core, "STATE_FILE", dirs["update"] / "state.json")
|
monkeypatch.setattr(update_core, "STATE_FILE", dirs["update"] / "state.json")
|
||||||
monkeypatch.setattr(update_core, "LOG_FILE", dirs["update"] / "update.log")
|
monkeypatch.setattr(update_core, "LOG_FILE", dirs["update"] / "update.log")
|
||||||
monkeypatch.setattr(update_core, "SOURCE_DIR", ROOT)
|
monkeypatch.setattr(update_core, "SOURCE_DIR", ROOT)
|
||||||
for module in (app, network_core, host_profile):
|
for module in (app, core, network_core, host_profile):
|
||||||
monkeypatch.setattr(module, "run_cmd", fake_run_cmd)
|
monkeypatch.setattr(module, "run_cmd", fake_run_cmd)
|
||||||
return dirs
|
return dirs
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -13,16 +14,78 @@ sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|||||||
import conftest # noqa: E402 (sets sys.path and env)
|
import conftest # noqa: E402 (sets sys.path and env)
|
||||||
|
|
||||||
import app # noqa: E402
|
import app # noqa: E402
|
||||||
|
import core # noqa: E402
|
||||||
import host_profile # noqa: E402
|
import host_profile # noqa: E402
|
||||||
import network_core # noqa: E402
|
import network_core # noqa: E402
|
||||||
import update_core # noqa: E402
|
import update_core # noqa: E402
|
||||||
|
|
||||||
|
# Машины демо-сервера: имя → состояние virsh (web01 работает, db01 выключена, win11 на паузе).
|
||||||
|
DEMO_VMS = {"web01": "running", "db01": "shut off", "win11": "paused"}
|
||||||
|
DEMO_NOTES = {"web01": ("Сайт компании", "Nginx + база. Бэкап по пятницам.\nЗа сервер отвечает Иван."), "win11": ("Бухгалтерия 1С", "")}
|
||||||
|
DEMO_DOMSTATS = """Domain: 'web01'
|
||||||
|
cpu.time={cpu_web}
|
||||||
|
balloon.current=2097152
|
||||||
|
balloon.maximum=2097152
|
||||||
|
balloon.rss=1534208
|
||||||
|
vcpu.current=2
|
||||||
|
vcpu.maximum=2
|
||||||
|
|
||||||
|
Domain: 'win11'
|
||||||
|
cpu.time={cpu_win}
|
||||||
|
balloon.current=4194304
|
||||||
|
balloon.maximum=4194304
|
||||||
|
balloon.rss=3102720
|
||||||
|
vcpu.current=4
|
||||||
|
vcpu.maximum=4
|
||||||
|
|
||||||
|
"""
|
||||||
|
_cpu_clock = {"web": 48_123_456_789, "win": 90_000_000_000, "at": time.monotonic()}
|
||||||
|
|
||||||
|
|
||||||
|
def demo_run_cmd(cmd, timeout=12, **kwargs):
|
||||||
|
"""Как conftest.fake_run_cmd, но с тремя машинами в разных состояниях и живой нагрузкой."""
|
||||||
|
if cmd[:2] == ["virsh", "list"]:
|
||||||
|
if "--title" in cmd:
|
||||||
|
rows = "\n".join(f" {'1' if state == 'running' else '2' if state == 'paused' else '-':<4} {name:<7} {state:<10} {DEMO_NOTES.get(name, ('', ''))[0]}" for name, state in DEMO_VMS.items())
|
||||||
|
return conftest.fake_result(" Id Name State Title\n" + "-" * 44 + "\n" + rows + "\n")
|
||||||
|
if "--name" in cmd:
|
||||||
|
names = [name for name, state in DEMO_VMS.items() if "--all" in cmd or state != "shut off"]
|
||||||
|
return conftest.fake_result("\n".join(names) + "\n")
|
||||||
|
rows = "\n".join(f" {'1' if state == 'running' else '2' if state == 'paused' else '-':<4} {name:<7} {state}" for name, state in DEMO_VMS.items())
|
||||||
|
return conftest.fake_result(" Id Name State\n" + "-" * 25 + "\n" + rows)
|
||||||
|
if cmd[:2] in (["virsh", "dominfo"], ["virsh", "domstate"]):
|
||||||
|
name = cmd[-1]
|
||||||
|
if name not in DEMO_VMS:
|
||||||
|
return conftest.fake_result(ok=False, stderr=f"error: failed to get domain '{name}'")
|
||||||
|
if cmd[1] == "domstate":
|
||||||
|
return conftest.fake_result(DEMO_VMS[name])
|
||||||
|
info = conftest.DOMINFO.replace("web01", name).replace("State: running", f"State: {DEMO_VMS[name]}")
|
||||||
|
if name == "db01":
|
||||||
|
info += "Managed save: yes\n"
|
||||||
|
return conftest.fake_result(info)
|
||||||
|
if cmd[:2] == ["virsh", "domstats"]:
|
||||||
|
# Процессорное время растёт вместе с реальным: web01 нагружена на ~37 % (2 ядра), win11 на ~8 % (4 ядра).
|
||||||
|
now = time.monotonic()
|
||||||
|
elapsed_ns = (now - _cpu_clock["at"]) * 1e9
|
||||||
|
_cpu_clock.update(web=_cpu_clock["web"] + int(elapsed_ns * 2 * 0.37), win=_cpu_clock["win"] + int(elapsed_ns * 4 * 0.08), at=now)
|
||||||
|
return conftest.fake_result(DEMO_DOMSTATS.format(cpu_web=_cpu_clock["web"], cpu_win=_cpu_clock["win"]))
|
||||||
|
if cmd[:2] == ["virsh", "domblklist"]:
|
||||||
|
return conftest.fake_result((conftest.FIXTURES / "domblklist-details.txt").read_text().replace("/var/lib/virtuality/images/web01", str(core.IMAGES_DIR / cmd[2])))
|
||||||
|
if cmd[:1] == ["virsh"] and "desc" in cmd[:3] and not any(arg.startswith("--new-desc") for arg in cmd):
|
||||||
|
title, description = DEMO_NOTES.get(cmd[cmd.index("desc") + 1], ("", ""))
|
||||||
|
return conftest.fake_result(title if "--title" in cmd else description)
|
||||||
|
return conftest.fake_run_cmd(cmd, timeout, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
def seed(root: Path) -> None:
|
def seed(root: Path) -> None:
|
||||||
dirs = {name: root / name for name in ("iso", "images", "disk-images", "operations", "network", "config", "nft", "update")}
|
dirs = {name: root / name for name in ("iso", "images", "disk-images", "operations", "network", "config", "nft", "update")}
|
||||||
for path in dirs.values():
|
for path in dirs.values():
|
||||||
path.mkdir(parents=True, exist_ok=True)
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
app.ISO_DIR, app.IMAGES_DIR, app.DISK_IMAGES_DIR, app.OPERATIONS_DIR = dirs["iso"], dirs["images"], dirs["disk-images"], dirs["operations"]
|
app.ISO_DIR, app.IMAGES_DIR, app.DISK_IMAGES_DIR, app.OPERATIONS_DIR = dirs["iso"], dirs["images"], dirs["disk-images"], dirs["operations"]
|
||||||
|
core.ISO_DIR, core.IMAGES_DIR, core.DISK_IMAGES_DIR, core.OPERATIONS_DIR, core.STORAGE_DIR = dirs["iso"], dirs["images"], dirs["disk-images"], dirs["operations"], root
|
||||||
|
for name in DEMO_VMS: # диски машин лежат в хранилище, иначе форма увеличения не появится
|
||||||
|
with (dirs["images"] / f"{name}.qcow2").open("wb") as handle:
|
||||||
|
handle.truncate(4 * 1024 * 1024)
|
||||||
network_core.CONFIG_DIR, network_core.NETWORK_DIR, network_core.NFT_DIR = dirs["config"], dirs["network"], dirs["nft"]
|
network_core.CONFIG_DIR, network_core.NETWORK_DIR, network_core.NFT_DIR = dirs["config"], dirs["network"], dirs["nft"]
|
||||||
network_core.PORT_FORWARDS_FILE = dirs["network"] / "port_forwards.json"
|
network_core.PORT_FORWARDS_FILE = dirs["network"] / "port_forwards.json"
|
||||||
network_core.NAT_XML_FILE = dirs["network"] / "virtuality-nat.xml"
|
network_core.NAT_XML_FILE = dirs["network"] / "virtuality-nat.xml"
|
||||||
@@ -32,8 +95,8 @@ def seed(root: Path) -> None:
|
|||||||
host_profile.PROFILE_FILE = dirs["config"] / "host_profile.json"
|
host_profile.PROFILE_FILE = dirs["config"] / "host_profile.json"
|
||||||
update_core.STATE_DIR, update_core.STATE_FILE, update_core.LOG_FILE = dirs["update"], dirs["update"] / "state.json", dirs["update"] / "update.log"
|
update_core.STATE_DIR, update_core.STATE_FILE, update_core.LOG_FILE = dirs["update"], dirs["update"] / "state.json", dirs["update"] / "update.log"
|
||||||
update_core.SOURCE_DIR = Path(__file__).resolve().parents[1]
|
update_core.SOURCE_DIR = Path(__file__).resolve().parents[1]
|
||||||
for module in (app, network_core, host_profile):
|
for module in (app, core, network_core, host_profile):
|
||||||
module.run_cmd = conftest.fake_run_cmd
|
module.run_cmd = demo_run_cmd
|
||||||
app.auth.verify_password = lambda user, password: True
|
app.auth.verify_password = lambda user, password: True
|
||||||
|
|
||||||
for name, size in (("ubuntu-26.04-live-server-amd64.iso", 3), ("debian-13.7.0-amd64-netinst.iso", 1)):
|
for name, size in (("ubuntu-26.04-live-server-amd64.iso", 3), ("debian-13.7.0-amd64-netinst.iso", 1)):
|
||||||
@@ -49,6 +112,12 @@ def seed(root: Path) -> None:
|
|||||||
op_id = str(uuid.uuid4())
|
op_id = str(uuid.uuid4())
|
||||||
app.write_operation({"id": op_id, "type": "vm_create", "title": title, "status": status, "progress": progress, "message": "Готово" if status == "success" else "qemu-img: 64%" if status == "running" else "virt-install завершился с ошибкой: 1", "created_at": app.utc_now(), "updated_at": app.utc_now(), "created_by": "tester"})
|
app.write_operation({"id": op_id, "type": "vm_create", "title": title, "status": status, "progress": progress, "message": "Готово" if status == "success" else "qemu-img: 64%" if status == "running" else "virt-install завершился с ошибкой: 1", "created_at": app.utc_now(), "updated_at": app.utc_now(), "created_by": "tester"})
|
||||||
app.append_operation_log(op_id, "virt-install --name web01 --memory 2048 ...")
|
app.append_operation_log(op_id, "virt-install --name web01 --memory 2048 ...")
|
||||||
|
# Завершённое клонирование web01 → web01-copy: видно на обзоре и на странице /operations/{id}.
|
||||||
|
clone_op = core.new_operation("vm_clone", "Клонирование web01 → web01-copy", vm_name="web01-copy", source_vm="web01", targets=[str(dirs["images"] / "web01-copy.qcow2")], expected_bytes=6443499520)
|
||||||
|
core.append_operation_log(clone_op["id"], "virt-clone --original web01 --name web01-copy --file /var/lib/virtuality/images/web01-copy.qcow2")
|
||||||
|
core.append_operation_log(clone_op["id"], "Allocating 'web01-copy.qcow2' | 6.0 GB 00:01:12")
|
||||||
|
core.update_operation(clone_op, cmd="virt-clone --original web01 --name web01-copy --file " + str(dirs["images"] / "web01-copy.qcow2"))
|
||||||
|
core.finish_operation(clone_op, True, "Машина web01-copy создана — это копия web01. Запустите её и смените имя компьютера внутри системы.")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
Vendored
@@ -0,0 +1,6 @@
|
|||||||
|
Id Name State Title
|
||||||
|
--------------------------------------------
|
||||||
|
1 web01 running Сайт компании
|
||||||
|
- db01 shut off
|
||||||
|
- win11 shut off Бухгалтерия 1С
|
||||||
|
|
||||||
@@ -0,0 +1,437 @@
|
|||||||
|
"""vmops: увеличение диска, клонирование, пауза и спящий режим, заметки, живая нагрузка."""
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import unquote
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import conftest
|
||||||
|
import core
|
||||||
|
import presenters
|
||||||
|
from features import vmops
|
||||||
|
|
||||||
|
FIXTURES = Path(__file__).resolve().parent / "fixtures"
|
||||||
|
DOMSTATS = (FIXTURES / "domstats.txt").read_text()
|
||||||
|
GIB = 1024 ** 3
|
||||||
|
|
||||||
|
|
||||||
|
class Recorder:
|
||||||
|
"""Записывает команды; отвечает по overrides {(префикс,): результат|функция}, иначе как fake_run_cmd."""
|
||||||
|
|
||||||
|
def __init__(self, overrides=None):
|
||||||
|
self.calls: list[list[str]] = []
|
||||||
|
self.overrides = overrides or {}
|
||||||
|
|
||||||
|
def __call__(self, cmd, timeout=12, **kwargs):
|
||||||
|
self.calls.append(list(cmd))
|
||||||
|
for prefix, result in self.overrides.items():
|
||||||
|
if tuple(cmd[: len(prefix)]) == prefix:
|
||||||
|
return result(cmd) if callable(result) else result
|
||||||
|
return conftest.fake_run_cmd(cmd, timeout, **kwargs)
|
||||||
|
|
||||||
|
def find(self, *prefix):
|
||||||
|
return [call for call in self.calls if tuple(call[: len(prefix)]) == prefix]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def record(data_dirs, monkeypatch):
|
||||||
|
"""Подменяет run_cmd и в core (vmops), и в app (обзор, страница машины)."""
|
||||||
|
import app
|
||||||
|
|
||||||
|
recorder = Recorder()
|
||||||
|
for module in (core, app):
|
||||||
|
monkeypatch.setattr(module, "run_cmd", recorder)
|
||||||
|
monkeypatch.setattr(vmops, "_samples", {})
|
||||||
|
return recorder
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def inline_operations(monkeypatch):
|
||||||
|
"""Фоновые операции выполняются сразу, в этом же потоке."""
|
||||||
|
|
||||||
|
def run_now(operation, worker):
|
||||||
|
core.update_operation(operation, status="running")
|
||||||
|
worker(operation)
|
||||||
|
|
||||||
|
monkeypatch.setattr(vmops, "run_operation", run_now)
|
||||||
|
|
||||||
|
|
||||||
|
def shut_off(record):
|
||||||
|
record.overrides[("virsh", "domstate")] = conftest.fake_result("shut off")
|
||||||
|
|
||||||
|
|
||||||
|
def location(response) -> str:
|
||||||
|
assert response.status_code == 303, response.text[:300]
|
||||||
|
return unquote(response.headers["location"])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- parsers
|
||||||
|
def test_parse_domstats_fixture():
|
||||||
|
stats = vmops.parse_domstats(DOMSTATS)
|
||||||
|
assert list(stats) == ["web01"]
|
||||||
|
assert stats["web01"]["cpu.time"] == "48123456789"
|
||||||
|
assert stats["web01"]["balloon.rss"] == "1534208"
|
||||||
|
assert stats["web01"]["vcpu.current"] == "2"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_domstats_several_domains():
|
||||||
|
text = DOMSTATS + "Domain: 'db01'\n\nDomain: 'win 11'\n cpu.time=5\n vcpu.current=4\n"
|
||||||
|
stats = vmops.parse_domstats(text)
|
||||||
|
assert set(stats) == {"web01", "db01", "win 11"}
|
||||||
|
assert stats["db01"] == {}
|
||||||
|
assert stats["win 11"] == {"cpu.time": "5", "vcpu.current": "4"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_stats_cpu_percent_from_two_samples():
|
||||||
|
samples: dict = {}
|
||||||
|
first = vmops.compute_stats(vmops.parse_domstats(DOMSTATS), 100.0, samples)
|
||||||
|
assert first["web01"]["cpu_pct"] is None # первый замер — сравнивать не с чем
|
||||||
|
assert first["web01"]["mem_used_mb"] == 1498 and first["web01"]["mem_total_mb"] == 2048
|
||||||
|
later = DOMSTATS.replace("cpu.time=48123456789", "cpu.time=49123456789") # +1 с процессорного времени
|
||||||
|
second = vmops.compute_stats(vmops.parse_domstats(later), 101.0, samples)
|
||||||
|
assert second["web01"]["cpu_pct"] == 50.0 # 1 с на 2 ядра за 1 с стены
|
||||||
|
burst = DOMSTATS.replace("cpu.time=48123456789", "cpu.time=99123456789")
|
||||||
|
third = vmops.compute_stats(vmops.parse_domstats(burst), 101.5, samples)
|
||||||
|
assert third["web01"]["cpu_pct"] == 100.0 # не больше 100 %
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_stats_skips_shut_off_and_forgets_stale():
|
||||||
|
samples = {"old": (1.0, 5)}
|
||||||
|
stats = vmops.compute_stats({"db01": {}, "web01": {"cpu.time": "10", "balloon.current": "1024", "vcpu.current": "1"}}, 2.0, samples)
|
||||||
|
assert set(stats) == {"web01"}
|
||||||
|
assert stats["web01"]["mem_used_mb"] == 1 and stats["web01"]["mem_total_mb"] == 1
|
||||||
|
assert set(samples) == {"web01"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_qemu_img_info():
|
||||||
|
info = vmops.parse_qemu_img_info((FIXTURES / "qemu-img-info.json").read_text())
|
||||||
|
assert info == {"virtual_size": 42949672960, "actual_size": 6443499520, "format": "qcow2"}
|
||||||
|
assert vmops.parse_qemu_img_info("not json") == {}
|
||||||
|
assert vmops.parse_qemu_img_info("[1, 2]") == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_virsh_list_titles():
|
||||||
|
titles = presenters.parse_virsh_list_titles((FIXTURES / "list-title.txt").read_text())
|
||||||
|
assert titles == {"web01": "Сайт компании", "win11": "Бухгалтерия 1С"}
|
||||||
|
assert presenters.parse_virsh_list_titles(" Id Name State\n---\n 1 web01 running") == {}
|
||||||
|
assert presenters.parse_virsh_list_titles("") == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_desc_output():
|
||||||
|
assert vmops.parse_desc_output(conftest.fake_result("Сайт компании\n")) == "Сайт компании"
|
||||||
|
assert vmops.parse_desc_output(conftest.fake_result("No title for domain: web01")) == ""
|
||||||
|
assert vmops.parse_desc_output(conftest.fake_result(ok=False, stderr="error: failed to get domain")) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_has_managed_save():
|
||||||
|
assert vmops.has_managed_save(conftest.DOMINFO + "Managed save: yes\n")
|
||||||
|
assert not vmops.has_managed_save(conftest.DOMINFO + "Managed save: no\n")
|
||||||
|
assert not vmops.has_managed_save(conftest.DOMINFO)
|
||||||
|
|
||||||
|
|
||||||
|
def test_clean_notes():
|
||||||
|
assert vmops.clean_title(" Сайт \n компании ") == "Сайт компании"
|
||||||
|
assert len(vmops.clean_title("x" * 200)) == vmops.TITLE_MAX
|
||||||
|
assert vmops.clean_description("строка 1\r\nстрока 2 \r\n\r\n") == "строка 1\nстрока 2"
|
||||||
|
|
||||||
|
|
||||||
|
def test_clone_names_and_targets(data_dirs):
|
||||||
|
assert vmops.suggest_clone_name("web01", {"web01"}) == "web01-copy"
|
||||||
|
assert vmops.suggest_clone_name("web01", {"web01", "web01-copy", "web01-copy2"}) == "web01-copy3"
|
||||||
|
disks = [
|
||||||
|
{"device": "disk", "source": "/var/lib/virtuality/images/web01.qcow2"},
|
||||||
|
{"device": "cdrom", "source": ""},
|
||||||
|
{"device": "disk", "source": "/var/lib/virtuality/images/data.img"},
|
||||||
|
]
|
||||||
|
targets = vmops.clone_targets("web02", disks)
|
||||||
|
assert targets == [data_dirs["images"] / "web02.qcow2", data_dirs["images"] / "web02-disk2.img"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_explain_error():
|
||||||
|
assert "снимки" in vmops.explain_error("qemu-img: Can't resize an image which has snapshots", "x")
|
||||||
|
assert vmops.explain_error("", "запасной текст") == "запасной текст"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- live stats
|
||||||
|
def test_live_stats_requires_auth(client):
|
||||||
|
assert client.get("/live/stats").status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_live_stats_json(logged_in, record):
|
||||||
|
first = logged_in.get("/live/stats").json()
|
||||||
|
assert first["ok"] is True
|
||||||
|
assert first["stats"]["web01"]["cpu_pct"] is None
|
||||||
|
assert first["stats"]["web01"]["mem_used_mb"] == 1498 and first["stats"]["web01"]["mem_total_mb"] == 2048
|
||||||
|
second = logged_in.get("/live/stats").json()
|
||||||
|
assert second["stats"]["web01"]["cpu_pct"] == 0.0 # cpu.time в подделке не растёт
|
||||||
|
assert record.find("virsh", "domstats") == [["virsh", "domstats", "--cpu-total", "--balloon", "--vcpu", "web01"]] * 2 # только активные машины, один вызов на запрос
|
||||||
|
|
||||||
|
|
||||||
|
def test_live_stats_without_active_vms(logged_in, record):
|
||||||
|
record.overrides[("virsh", "list")] = conftest.fake_result("")
|
||||||
|
assert logged_in.get("/live/stats").json()["stats"] == {}
|
||||||
|
assert record.find("virsh", "domstats") == []
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- pages
|
||||||
|
def test_dashboard_shows_title_and_meters(logged_in, record):
|
||||||
|
html = logged_in.get("/").text
|
||||||
|
assert "Сайт компании" in html
|
||||||
|
assert 'data-live-stats="web01"' in html and 'data-live-stats="db01"' in html
|
||||||
|
assert "/vm/web01/ops/pause" in html and "/vm/web01/ops/hibernate" in html
|
||||||
|
assert "/vm/web01/clone" in html and "/vm/db01/clone" in html
|
||||||
|
assert record.find("virsh", "list", "--all", "--title")
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_paused_vm_offers_resume(logged_in, record):
|
||||||
|
record.overrides[("virsh", "list")] = conftest.fake_result(" Id Name State\n-----------------------\n 2 web01 paused\n - db01 shut off")
|
||||||
|
html = logged_in.get("/").text
|
||||||
|
assert 'action="/vm/web01/ops/resume"' in html
|
||||||
|
assert 'action="/vm/web01/start"' not in html
|
||||||
|
assert 'action="/vm/db01/start"' in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_vm_page_shows_disks_notes_and_ops(logged_in, record):
|
||||||
|
html = logged_in.get("/vm/web01").text
|
||||||
|
assert "40 ГБ" in html and "занято 6.0 ГБ" in html
|
||||||
|
assert 'action="/vm/web01/disks/vda/resize"' in html and 'min="41"' in html
|
||||||
|
assert 'action="/vm/web01/disks/sda/resize"' not in html # привод не увеличивают
|
||||||
|
assert 'value="Сайт компании"' in html and "Nginx + база. Бэкап по пятницам." in html
|
||||||
|
assert "Приостановить" in html and "Спящий режим" in html and "Клонировать" in html
|
||||||
|
assert 'data-live-stats="web01"' in html # карточка «Нагрузка сейчас»
|
||||||
|
assert record.find("qemu-img", "info") == [["qemu-img", "info", "--output=json", "-U", str(core.IMAGES_DIR / "web01.qcow2")]]
|
||||||
|
|
||||||
|
|
||||||
|
def test_vm_page_paused_and_saved_states(logged_in, record):
|
||||||
|
record.overrides[("virsh", "dominfo")] = conftest.fake_result(conftest.DOMINFO.replace("State: running", "State: paused"))
|
||||||
|
html = logged_in.get("/vm/web01").text
|
||||||
|
assert 'action="/vm/web01/ops/resume"' in html and "Продолжить" in html
|
||||||
|
record.overrides[("virsh", "dominfo")] = conftest.fake_result(conftest.DOMINFO.replace("State: running", "State: shut off") + "Managed save: yes\n")
|
||||||
|
html = logged_in.get("/vm/web01").text
|
||||||
|
assert "Машина в спящем режиме" in html and 'action="/vm/web01/ops/forget-save"' in html
|
||||||
|
assert 'data-live-stats="web01"' not in html # выключенная машина — нагрузки нет
|
||||||
|
|
||||||
|
|
||||||
|
def test_vm_page_messages(logged_in, record):
|
||||||
|
html = logged_in.get("/vm/web01?disk_message=Диск+увеличен¬es_error=Не+вышло").text
|
||||||
|
assert "Диск увеличен" in html and "Не вышло" in html
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- disk resize
|
||||||
|
def test_disk_resize_shut_off_uses_qemu_img(logged_in, record):
|
||||||
|
shut_off(record)
|
||||||
|
response = logged_in.post("/vm/web01/disks/vda/resize", data={"size_gb": "50"}, follow_redirects=False)
|
||||||
|
assert location(response).startswith("/vm/web01?disk_message=Диск web01.qcow2 увеличен до 50 ГБ")
|
||||||
|
assert location(response).endswith("#storage")
|
||||||
|
assert record.find("qemu-img", "resize") == [["qemu-img", "resize", str(core.IMAGES_DIR / "web01.qcow2"), "50G"]]
|
||||||
|
assert record.find("virsh", "blockresize") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_disk_resize_running_uses_blockresize(logged_in, record):
|
||||||
|
response = logged_in.post("/vm/web01/disks/vda/resize", data={"size_gb": "41"}, follow_redirects=False)
|
||||||
|
assert "disk_message" in location(response)
|
||||||
|
assert record.find("virsh", "blockresize") == [["virsh", "blockresize", "web01", "vda", "41G"]]
|
||||||
|
assert record.find("qemu-img", "resize") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_disk_resize_only_grows(logged_in, record):
|
||||||
|
response = logged_in.post("/vm/web01/disks/vda/resize", data={"size_gb": "40"}, follow_redirects=False)
|
||||||
|
assert "не меньше 41 ГБ" in location(response)
|
||||||
|
response = logged_in.post("/vm/web01/disks/vda/resize", data={"size_gb": "999999"}, follow_redirects=False)
|
||||||
|
assert "Слишком большой" in location(response)
|
||||||
|
assert record.find("virsh", "blockresize") == [] and record.find("qemu-img", "resize") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_disk_resize_rejects_unknown_or_cdrom(logged_in, record):
|
||||||
|
assert "Такого диска" in location(logged_in.post("/vm/web01/disks/vdz/resize", data={"size_gb": "50"}, follow_redirects=False))
|
||||||
|
assert "нельзя увеличить" in location(logged_in.post("/vm/web01/disks/sda/resize", data={"size_gb": "50"}, follow_redirects=False))
|
||||||
|
assert logged_in.post("/vm/web01/disks/../etc/resize", data={"size_gb": "50"}, follow_redirects=False).status_code in (400, 404)
|
||||||
|
assert logged_in.post("/vm/-bad/disks/vda/resize", data={"size_gb": "50"}, follow_redirects=False).status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_disk_resize_outside_storage_refused(logged_in, record):
|
||||||
|
record.overrides[("virsh", "domblklist")] = conftest.fake_result((FIXTURES / "domblklist-details.txt").read_text().replace("/var/lib/virtuality/images", "/root"))
|
||||||
|
assert "нельзя увеличить" in location(logged_in.post("/vm/web01/disks/vda/resize", data={"size_gb": "50"}, follow_redirects=False))
|
||||||
|
|
||||||
|
|
||||||
|
def test_disk_resize_checks_free_space(logged_in, record, monkeypatch):
|
||||||
|
monkeypatch.setattr(vmops, "free_space", lambda path: 2 * GIB)
|
||||||
|
response = logged_in.post("/vm/web01/disks/vda/resize", data={"size_gb": "50"}, follow_redirects=False)
|
||||||
|
assert "свободно только 2.0 ГБ" in location(response)
|
||||||
|
assert record.find("virsh", "blockresize") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_disk_resize_reports_tool_error(logged_in, record):
|
||||||
|
record.overrides[("virsh", "blockresize")] = conftest.fake_result(ok=False, stderr="error: internal error: Can't resize an image which has snapshots")
|
||||||
|
assert "удалите снимки" in location(logged_in.post("/vm/web01/disks/vda/resize", data={"size_gb": "50"}, follow_redirects=False))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- pause / resume / hibernate
|
||||||
|
def test_ops_pause_and_resume(logged_in, record):
|
||||||
|
response = logged_in.post("/vm/web01/ops/pause", data={"next": "/"}, follow_redirects=False)
|
||||||
|
assert location(response).startswith("/?ops_message=Машина web01 приостановлена")
|
||||||
|
assert record.find("virsh", "suspend") == [["virsh", "suspend", "web01"]]
|
||||||
|
response = logged_in.post("/vm/web01/ops/resume", follow_redirects=False)
|
||||||
|
assert "ops_error=Продолжить можно только приостановленную" in location(response)
|
||||||
|
record.overrides[("virsh", "domstate")] = conftest.fake_result("paused")
|
||||||
|
response = logged_in.post("/vm/web01/ops/resume", data={"next": "http://evil.example/"}, follow_redirects=False)
|
||||||
|
assert location(response).startswith("/vm/web01?ops_message=") # чужой next не используется
|
||||||
|
assert record.find("virsh", "resume") == [["virsh", "resume", "web01"]]
|
||||||
|
|
||||||
|
|
||||||
|
def test_ops_validation(logged_in, record):
|
||||||
|
assert logged_in.post("/vm/web01/ops/explode", follow_redirects=False).status_code == 400
|
||||||
|
assert logged_in.post("/vm/-bad/ops/pause", follow_redirects=False).status_code == 400
|
||||||
|
assert location(logged_in.post("/vm/ghost/ops/pause", follow_redirects=False)) == "/"
|
||||||
|
shut_off(record)
|
||||||
|
assert "ops_error=Приостановить можно только работающую" in location(logged_in.post("/vm/web01/ops/pause", follow_redirects=False))
|
||||||
|
record.overrides[("virsh", "suspend")] = conftest.fake_result(ok=False, stderr="error: Timed out during operation: cannot acquire state change lock")
|
||||||
|
record.overrides[("virsh", "domstate")] = conftest.fake_result("running")
|
||||||
|
assert "занята другой операцией" in location(logged_in.post("/vm/web01/ops/pause", follow_redirects=False))
|
||||||
|
|
||||||
|
|
||||||
|
def test_ops_requires_login(client):
|
||||||
|
assert client.post("/vm/web01/ops/pause", follow_redirects=False).headers["location"] == "/login"
|
||||||
|
|
||||||
|
|
||||||
|
def test_hibernate_runs_operation(logged_in, record, inline_operations, data_dirs):
|
||||||
|
response = logged_in.post("/vm/web01/ops/hibernate", data={"next": "/"}, follow_redirects=False)
|
||||||
|
assert location(response).startswith("/?ops_message=Машина web01 засыпает")
|
||||||
|
assert record.find("virsh", "managedsave") == [["virsh", "managedsave", "web01"]]
|
||||||
|
operations = core.list_operations(5)
|
||||||
|
assert operations[0]["type"] == "vm_hibernate" and operations[0]["status"] == "success" and operations[0]["vm_name"] == "web01"
|
||||||
|
|
||||||
|
|
||||||
|
def test_hibernate_blocked(logged_in, record, monkeypatch):
|
||||||
|
shut_off(record)
|
||||||
|
assert "ops_error=В спящий режим можно" in location(logged_in.post("/vm/web01/ops/hibernate", follow_redirects=False))
|
||||||
|
record.overrides[("virsh", "domstate")] = conftest.fake_result("running")
|
||||||
|
monkeypatch.setattr(vmops, "free_space", lambda path: 0)
|
||||||
|
assert "свободного места" in location(logged_in.post("/vm/web01/ops/hibernate", follow_redirects=False))
|
||||||
|
assert record.find("virsh", "managedsave") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_hibernate_failure_is_reported(logged_in, record, inline_operations):
|
||||||
|
record.overrides[("virsh", "managedsave")] = conftest.fake_result(ok=False, stderr="error: Requested operation is not valid: domain is not running")
|
||||||
|
logged_in.post("/vm/web01/ops/hibernate", follow_redirects=False)
|
||||||
|
operation = core.list_operations(1)[0]
|
||||||
|
assert operation["status"] == "error" and "выключена" in operation["message"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_forget_saved_state(logged_in, record):
|
||||||
|
shut_off(record)
|
||||||
|
response = logged_in.post("/vm/web01/ops/forget-save", follow_redirects=False)
|
||||||
|
assert "ops_message=Сохранённое состояние удалено" in location(response)
|
||||||
|
assert record.find("virsh", "managedsave-remove") == [["virsh", "managedsave-remove", "web01"]]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- notes
|
||||||
|
def test_notes_save_running(logged_in, record):
|
||||||
|
response = logged_in.post("/vm/web01/notes/save", data={"title": " Сайт компании ", "description": "строка 1\r\nстрока 2"}, follow_redirects=False)
|
||||||
|
assert location(response) == "/vm/web01?notes_message=Заметки сохранены"
|
||||||
|
assert record.find("virsh", "desc") == [
|
||||||
|
["virsh", "desc", "web01", "--title", "--new-desc=Сайт компании", "--config", "--live"],
|
||||||
|
["virsh", "desc", "web01", "--new-desc=строка 1\nстрока 2", "--config", "--live"],
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_notes_save_shut_off_and_dashes(logged_in, record):
|
||||||
|
shut_off(record)
|
||||||
|
logged_in.post("/vm/web01/notes/save", data={"title": "--edit", "description": ""}, follow_redirects=False)
|
||||||
|
calls = record.find("virsh", "desc")
|
||||||
|
assert calls[0] == ["virsh", "desc", "web01", "--title", "--new-desc=--edit", "--config"] # текст одним аргументом, без --live
|
||||||
|
assert calls[1] == ["virsh", "desc", "web01", "--new-desc=", "--config"] # пустое описание очищает
|
||||||
|
|
||||||
|
|
||||||
|
def test_notes_save_error(logged_in, record):
|
||||||
|
record.overrides[("virsh", "desc")] = conftest.fake_result(ok=False, stderr="error: failed to get domain 'web01'")
|
||||||
|
assert "notes_error=" in location(logged_in.post("/vm/web01/notes/save", data={"title": "x"}, follow_redirects=False))
|
||||||
|
assert logged_in.post("/vm/-bad/notes/save", data={"title": "x"}, follow_redirects=False).status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- clone
|
||||||
|
def test_clone_page_blocked_while_running(logged_in, record):
|
||||||
|
response = logged_in.get("/vm/web01/clone")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Сначала выключите машину" in response.text
|
||||||
|
assert 'action="/vm/web01/shutdown"' in response.text
|
||||||
|
assert "disabled" in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_clone_page_ready(logged_in, record):
|
||||||
|
shut_off(record)
|
||||||
|
html = logged_in.get("/vm/web01/clone").text
|
||||||
|
assert "Сначала выключите" not in html
|
||||||
|
assert 'value="web01-copy"' in html
|
||||||
|
assert "6.0 ГБ" in html # нужно места — реальный размер диска
|
||||||
|
assert "web01.qcow2" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_clone_validation(logged_in, record):
|
||||||
|
shut_off(record)
|
||||||
|
assert "Имя копии" in location(logged_in.post("/vm/web01/clone/start", data={"new_name": "bad name!"}, follow_redirects=False))
|
||||||
|
assert "уже есть" in location(logged_in.post("/vm/web01/clone/start", data={"new_name": "db01"}, follow_redirects=False))
|
||||||
|
assert "уже есть" in location(logged_in.post("/vm/web01/clone/start", data={"new_name": "WEB01"}, follow_redirects=False))
|
||||||
|
record.overrides[("virsh", "domstate")] = conftest.fake_result("running")
|
||||||
|
assert "Сначала выключите" in location(logged_in.post("/vm/web01/clone/start", data={"new_name": "web02"}, follow_redirects=False))
|
||||||
|
assert record.find("virt-clone") == []
|
||||||
|
assert logged_in.post("/vm/-bad/clone/start", data={"new_name": "web02"}, follow_redirects=False).status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_clone_checks_space_and_existing_file(logged_in, record, data_dirs, monkeypatch):
|
||||||
|
shut_off(record)
|
||||||
|
(data_dirs["images"] / "web02.qcow2").write_bytes(b"x")
|
||||||
|
assert "уже есть в хранилище" in location(logged_in.post("/vm/web01/clone/start", data={"new_name": "web02"}, follow_redirects=False))
|
||||||
|
monkeypatch.setattr(vmops, "free_space", lambda path: GIB)
|
||||||
|
assert "Не хватает места" in location(logged_in.post("/vm/web01/clone/start", data={"new_name": "web03"}, follow_redirects=False))
|
||||||
|
|
||||||
|
|
||||||
|
def test_clone_starts_operation(logged_in, record, inline_operations, data_dirs):
|
||||||
|
shut_off(record)
|
||||||
|
response = logged_in.post("/vm/web01/clone/start", data={"new_name": "web02"}, follow_redirects=False)
|
||||||
|
assert location(response).startswith("/operations/")
|
||||||
|
operation = core.read_operation(location(response).split("/")[-1])
|
||||||
|
assert operation["status"] == "success" and operation["type"] == "vm_clone"
|
||||||
|
assert operation["vm_name"] == "web02" and operation["source_vm"] == "web01"
|
||||||
|
assert "web02 создана" in operation["message"]
|
||||||
|
assert record.find("virt-clone") == [["virt-clone", "--original", "web01", "--name", "web02", "--file", str(data_dirs["images"] / "web02.qcow2")]]
|
||||||
|
assert record.find("virsh", "pool-refresh") == [["virsh", "pool-refresh", "virtuality-images"]]
|
||||||
|
assert "virt-clone --original web01" in operation["log_tail"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_clone_failure_is_reported(logged_in, record, inline_operations):
|
||||||
|
shut_off(record)
|
||||||
|
record.overrides[("virt-clone",)] = conftest.fake_result(ok=False, stderr="ERROR Storage volume already exists")
|
||||||
|
response = logged_in.post("/vm/web01/clone/start", data={"new_name": "web02"}, follow_redirects=False)
|
||||||
|
operation = core.read_operation(location(response).split("/")[-1])
|
||||||
|
assert operation["status"] == "error" and "уже есть" in operation["message"]
|
||||||
|
assert record.find("virsh", "pool-refresh") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_clone_progress_watcher(data_dirs):
|
||||||
|
target = data_dirs["images"] / "copy.qcow2"
|
||||||
|
target.write_bytes(b"\0" * (512 * 1024))
|
||||||
|
operation = core.new_operation("vm_clone", "test", vm_name="copy", source_vm="web01")
|
||||||
|
|
||||||
|
class OneTick:
|
||||||
|
def __init__(self):
|
||||||
|
self.ticks = 0
|
||||||
|
|
||||||
|
def wait(self, timeout):
|
||||||
|
self.ticks += 1
|
||||||
|
return self.ticks > 1
|
||||||
|
|
||||||
|
vmops._watch_clone_progress(operation, [target], 1024 * 1024, OneTick())
|
||||||
|
fresh = core.read_operation(operation["id"])
|
||||||
|
assert 3 <= fresh["progress"] <= 95 and "Копируем диски" in fresh["message"]
|
||||||
|
assert vmops._allocated_bytes(data_dirs["images"] / "missing.qcow2") == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_clone_worker_stops_watcher(data_dirs, monkeypatch):
|
||||||
|
"""Даже если virt-clone упал, поток прогресса останавливается."""
|
||||||
|
monkeypatch.setattr(core, "run_cmd", lambda cmd, timeout=12: conftest.fake_result(ok=False, stderr="boom"))
|
||||||
|
operation = core.new_operation("vm_clone", "test", vm_name="web02", source_vm="web01", targets=[str(data_dirs["images"] / "web02.qcow2")], expected_bytes=10)
|
||||||
|
before = threading.active_count()
|
||||||
|
vmops.clone_worker(operation)
|
||||||
|
assert threading.active_count() <= before
|
||||||
|
assert core.read_operation(operation["id"])["status"] == "error"
|
||||||
@@ -2016,3 +2016,7 @@ def api_health(request: Request):
|
|||||||
if not get_current_user(request):
|
if not get_current_user(request):
|
||||||
return JSONResponse({"ok": False, "error": "Unauthorized"}, status_code=401)
|
return JSONResponse({"ok": False, "error": "Unauthorized"}, status_code=401)
|
||||||
return {"system": system_summary(), "host_profile": host_profile.load_host_profile(), "services": {"libvirtd": service_state("libvirtd.service"), "virtlogd": service_state("virtlogd.service"), "cockpit": service_state("cockpit.socket"), "dashboard": service_state("virtuality-console-dashboard.service"), "web": service_state("virtuality-web.service")}, "vms": parse_virsh_list(), "pools": parse_pool_list(), "network": network_summary(), "virtuality_nat": network_core.network_context()}
|
return {"system": system_summary(), "host_profile": host_profile.load_host_profile(), "services": {"libvirtd": service_state("libvirtd.service"), "virtlogd": service_state("virtlogd.service"), "cockpit": service_state("cockpit.socket"), "dashboard": service_state("virtuality-console-dashboard.service"), "web": service_state("virtuality-web.service")}, "vms": parse_virsh_list(), "pools": parse_pool_list(), "network": network_summary(), "virtuality_nat": network_core.network_context()}
|
||||||
|
|
||||||
|
|
||||||
|
import features.vmops as vmops # noqa: E402
|
||||||
|
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)
|
||||||
@@ -251,3 +251,28 @@ def format_bytes(value: int | float) -> str:
|
|||||||
return f"{value:.0f} {unit}" if value >= 10 or unit == "Б" else f"{value:.1f} {unit}"
|
return f"{value:.0f} {unit}" if value >= 10 or unit == "Б" else f"{value:.1f} {unit}"
|
||||||
value /= 1024
|
value /= 1024
|
||||||
return f"{value:.0f} ТБ"
|
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
|
||||||
|
|||||||
@@ -584,3 +584,28 @@ details.advanced > .advanced-body { padding: 4px 16px 16px; }
|
|||||||
.hide-sm { display: none !important; }
|
.hide-sm { display: none !important; }
|
||||||
}
|
}
|
||||||
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation: none !important; transition: none !important; } }
|
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation: none !important; transition: none !important; } }
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------- 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; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -57,4 +57,6 @@
|
|||||||
<symbol id="i-wrench" viewBox="0 0 24 24"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.106-3.105c.32-.322.863-.22.983.218a6 6 0 0 1-8.259 7.057l-7.91 7.91a1 1 0 0 1-2.999-3l7.91-7.91a6 6 0 0 1 7.057-8.259c.438.12.54.662.219.984z" /></symbol>
|
<symbol id="i-wrench" viewBox="0 0 24 24"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.106-3.105c.32-.322.863-.22.983.218a6 6 0 0 1-8.259 7.057l-7.91 7.91a1 1 0 0 1-2.999-3l7.91-7.91a6 6 0 0 1 7.057-8.259c.438.12.54.662.219.984z" /></symbol>
|
||||||
<symbol id="i-circle-dot" viewBox="0 0 24 24"><circle cx="12" cy="12" r="1" /><circle cx="12" cy="12" r="10" /></symbol>
|
<symbol id="i-circle-dot" viewBox="0 0 24 24"><circle cx="12" cy="12" r="1" /><circle cx="12" cy="12" r="10" /></symbol>
|
||||||
<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-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-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>
|
</svg>
|
||||||
|
|||||||
|
До Ширина: | Высота: | Размер: 10 KiB После Ширина: | Высота: | Размер: 11 KiB |
@@ -196,6 +196,41 @@
|
|||||||
setInterval(refreshLive, 5000);
|
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 */
|
/* ---------------------------------------------------- uploads */
|
||||||
function wireUploader(form) {
|
function wireUploader(form) {
|
||||||
const input = $('input[type=file]', form);
|
const input = $('input[type=file]', form);
|
||||||
@@ -466,6 +501,7 @@
|
|||||||
wireTabs();
|
wireTabs();
|
||||||
wireCopy();
|
wireCopy();
|
||||||
wireLive();
|
wireLive();
|
||||||
|
wireStats();
|
||||||
$$('form[data-uploader]').forEach(wireUploader);
|
$$('form[data-uploader]').forEach(wireUploader);
|
||||||
wireOperation();
|
wireOperation();
|
||||||
wireLogs();
|
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 %}
|
{% from "_ui.html" import icon, badge, vm_badge, op_badge, meter, alert %}
|
||||||
{% set page_title = 'Обзор' %}
|
{% set page_title = 'Обзор' %}
|
||||||
{% set running = vms|selectattr('state', 'in', ['running', 'idle', 'blocked'])|list|length %}
|
{% set running = vms|selectattr('state', 'in', ['running', 'idle', 'blocked'])|list|length %}
|
||||||
|
{% set titles = vmops_titles() if vms else {} %}
|
||||||
|
|
||||||
{% block page_header %}
|
{% block page_header %}
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
@@ -26,6 +27,7 @@
|
|||||||
{% if profile.virtualization_warning %}
|
{% if profile.virtualization_warning %}
|
||||||
{{ alert('warning', 'Машины будут работать медленно. Включите аппаратную виртуализацию (Intel VT-x / AMD-V) в настройках BIOS сервера.', 'Аппаратное ускорение недоступно') }}
|
{{ alert('warning', 'Машины будут работать медленно. Включите аппаратную виртуализацию (Intel VT-x / AMD-V) в настройках BIOS сервера.', 'Аппаратное ускорение недоступно') }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% include "_vm_ops_alerts.html" %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% 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 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 style="min-width:0">
|
||||||
<span class="vm-name truncate" style="display:block">{{ vm.name }}</span>
|
<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>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
<div class="vm-col">
|
<div class="vm-col">
|
||||||
<div class="vm-cell-label">Ресурсы</div>
|
<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>
|
<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>
|
||||||
<div class="vm-col">{{ vm_badge(vm.state) }}</div>
|
<div class="vm-col">{{ vm_badge(vm.state) }}</div>
|
||||||
<div class="vm-actions">
|
<div class="vm-actions">
|
||||||
{% if s.running %}
|
{% 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>
|
<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 %}
|
{% 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>
|
<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 %}
|
{% endif %}
|
||||||
@@ -87,13 +92,14 @@
|
|||||||
<summary class="btn btn-sm btn-icon btn-ghost" aria-label="Действия">{{ icon('ellipsis') }}</summary>
|
<summary class="btn btn-sm btn-icon btn-ghost" aria-label="Действия">{{ icon('ellipsis') }}</summary>
|
||||||
<div class="menu-list">
|
<div class="menu-list">
|
||||||
<a class="menu-item" href="/vm/{{ vm.name }}">{{ icon('settings') }}Открыть настройки</a>
|
<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 %}
|
{% if s.running %}
|
||||||
<a class="menu-item" href="/vm/{{ vm.name }}/console" target="_blank" rel="noopener">{{ icon('monitor') }}Открыть экран</a>
|
<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 }}/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>
|
<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>
|
<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>
|
<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>
|
<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>
|
<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>
|
<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 %}
|
{% if s.running %}
|
||||||
<a class="btn btn-primary" href="/vm/{{ vm.name }}/console" target="_blank" rel="noopener">{{ icon('monitor') }}Открыть экран</a>
|
<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>
|
<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 %}
|
{% else %}
|
||||||
<form method="post" action="/vm/{{ vm.name }}/start" class="inline-form" data-toast="Машина запускается"><button class="btn btn-primary">{{ icon('play') }}Запустить</button></form>
|
<form method="post" action="/vm/{{ vm.name }}/start" class="inline-form" data-toast="Машина запускается"><button class="btn btn-primary">{{ icon('play') }}Запустить</button></form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<details class="menu">
|
<details class="menu">
|
||||||
<summary class="btn btn-icon" aria-label="Другие действия">{{ icon('ellipsis') }}</summary>
|
<summary class="btn btn-icon" aria-label="Другие действия">{{ icon('ellipsis') }}</summary>
|
||||||
<div class="menu-list">
|
<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 %}
|
{% 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 }}/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>
|
<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>
|
||||||
@@ -46,6 +49,7 @@
|
|||||||
{% if msg %}{{ alert('success', msg) }}{% endif %}
|
{% if msg %}{{ alert('success', msg) }}{% endif %}
|
||||||
{% if err %}{{ alert('danger', err) }}{% endif %}
|
{% if err %}{{ alert('danger', err) }}{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
{% include "_vm_ops_alerts.html" %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
@@ -94,6 +98,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="stack">
|
<div class="stack">
|
||||||
|
{% include "_vm_ops_cards.html" %}
|
||||||
<article class="card">
|
<article class="card">
|
||||||
<div class="card-body stack-sm">
|
<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 'Машина будет запускаться вместе с сервером' }}">
|
<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 'Машина будет запускаться вместе с сервером' }}">
|
||||||
@@ -198,16 +203,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article class="card">
|
{% include "_vm_disks.html" %}
|
||||||
<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>
|
|
||||||
|
|
||||||
<article class="card">
|
<article class="card">
|
||||||
<div class="card-header"><h2>Сетевые карты</h2></div>
|
<div class="card-header"><h2>Сетевые карты</h2></div>
|
||||||
|
|||||||
Ссылка в новой задаче
Block a user