Повседневные операции с машиной были добавлены: диск, клон, пауза, заметки, нагрузка

Модуль 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
Этот коммит содержится в:
Claude
2026-09-25 15:00:41 +00:00
родитель 7bf7a0f6b7
Коммит 34684634af
19 изменённых файлов: 1410 добавлений и 19 удалений
+32 -5
Просмотреть файл
@@ -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:
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):
joined = " ".join(cmd)
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")
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")
@@ -49,6 +59,20 @@ def fake_run_cmd(cmd, timeout=12, **_kwargs):
return fake_result(DUMPXML)
if cmd[:2] == ["virsh", "domstate"]:
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"]:
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"]:
@@ -75,6 +99,7 @@ def fake_run_cmd(cmd, timeout=12, **_kwargs):
@pytest.fixture()
def data_dirs(tmp_path, monkeypatch):
import app
import core
import host_profile
import network_core
import update_core
@@ -91,10 +116,12 @@ def data_dirs(tmp_path, monkeypatch):
}
for path in dirs.values():
path.mkdir()
monkeypatch.setattr(app, "ISO_DIR", dirs["iso"])
monkeypatch.setattr(app, "IMAGES_DIR", dirs["images"])
monkeypatch.setattr(app, "DISK_IMAGES_DIR", dirs["disk_images"])
monkeypatch.setattr(app, "OPERATIONS_DIR", dirs["operations"])
for module in (app, core):
monkeypatch.setattr(module, "ISO_DIR", dirs["iso"])
monkeypatch.setattr(module, "IMAGES_DIR", dirs["images"])
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, "NETWORK_DIR", dirs["network"])
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, "LOG_FILE", dirs["update"] / "update.log")
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)
return dirs
+71 -2
Просмотреть файл
@@ -6,6 +6,7 @@
import json
import sys
import tempfile
import time
import uuid
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 app # noqa: E402
import core # noqa: E402
import host_profile # noqa: E402
import network_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:
dirs = {name: root / name for name in ("iso", "images", "disk-images", "operations", "network", "config", "nft", "update")}
for path in dirs.values():
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"]
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.PORT_FORWARDS_FILE = dirs["network"] / "port_forwards.json"
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"
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]
for module in (app, network_core, host_profile):
module.run_cmd = conftest.fake_run_cmd
for module in (app, core, network_core, host_profile):
module.run_cmd = demo_run_cmd
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)):
@@ -49,6 +112,12 @@ def seed(root: Path) -> None:
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.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__":
+6
Просмотреть файл
@@ -0,0 +1,6 @@
Id Name State Title
--------------------------------------------
1 web01 running Сайт компании
- db01 shut off
- win11 shut off Бухгалтерия 1С
+437
Просмотреть файл
@@ -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=Диск+увеличен&notes_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"
+4
Просмотреть файл
@@ -2016,3 +2016,7 @@ def api_health(request: Request):
if not get_current_user(request):
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()}
import features.vmops as vmops # noqa: E402
app.include_router(vmops.router)
Просмотреть файл
+590
Просмотреть файл
@@ -0,0 +1,590 @@
"""Повседневные операции с виртуальной машиной, которых ждут от «простого
Proxmox»: увеличение диска, клонирование, пауза и спящий режим, заметки
(название и описание) и живая нагрузка процессора и памяти.
Всё, что занимает больше пары секунд (клонирование, спящий режим), идёт как
фоновая операция с прогрессом и журналом на странице /operations/{id}.
"""
import json
import re
import shutil
import threading
import time
from pathlib import Path
from typing import Any
from urllib.parse import quote
from fastapi import APIRouter, Form, Request
from fastapi.responses import JSONResponse, RedirectResponse
import core
import presenters
from core import (
IMAGES_POOL,
append_operation_log,
cmd_error,
finish_operation,
new_operation,
redirect_with_message,
render,
require_api_auth,
require_auth,
run_operation,
running_operations,
templates,
update_operation,
valid_vm_name,
vm_exists,
vm_state,
)
router = APIRouter()
GIB = 1024 ** 3
MAX_DISK_GB = 16 * 1024 # 16 ТБ — потолок формы, чтобы опечатка не сделала диск на петабайт
TITLE_MAX = 80
DESCRIPTION_MAX = 2000
CLONE_TIMEOUT = 6 * 3600 # секунд: копия терабайтного диска на HDD идёт часами
HIBERNATE_TIMEOUT = 1800
SHUT_OFF_STATES = ("shut off", "shutoff", "shut-off")
ACTIVE_STATES = ("running", "idle", "blocked")
SAVE_DIR = Path("/var/lib/libvirt/qemu/save") # сюда libvirt кладёт память «спящих» машин
RESIZABLE_FORMATS = ("qcow2", "raw")
GUEST_GROW_HINT = "Если система ставилась из облачного образа, место внутри появится само. Иначе расширьте раздел в самой системе: в Linux — growpart или GParted, в Windows — «Управление дисками» → «Расширить том»."
# Известные ошибки virsh/qemu-img/virt-clone → понятное объяснение (подстрока без учёта регистра).
KNOWN_ERRORS = [
("has snapshots", "У диска есть снимки — сначала удалите снимки машины, затем увеличьте диск."),
("no space left", "На диске сервера закончилось место."),
("cannot acquire state change lock", "Машина занята другой операцией. Подождите минуту и попробуйте снова."),
("timed out during operation", "Машина занята другой операцией. Подождите минуту и попробуйте снова."),
("already exists", "Машина или файл с таким именем уже есть."),
("is not running", "Машина выключена, поэтому это действие сейчас недоступно."),
("not paused", "Машина не приостановлена."),
("already active", "Машина уже работает."),
("guest agent", "Внутри машины нет гостевого агента, поэтому это действие недоступно."),
("permission denied", "Нет прав на файл диска. Проверьте, что диск лежит в хранилище Virtuality."),
]
OPS = {
"pause": {
"cmd": ["virsh", "suspend"],
"states": ACTIVE_STATES,
"done": "Машина {name} приостановлена: она не тратит процессор, но остаётся в памяти сервера",
"wrong": "Приостановить можно только работающую машину",
"fail": "Не удалось приостановить машину",
},
"resume": {
"cmd": ["virsh", "resume"],
"states": ("paused",),
"done": "Машина {name} продолжает работу",
"wrong": "Продолжить можно только приостановленную машину",
"fail": "Не удалось продолжить работу машины",
},
"forget-save": {
"cmd": ["virsh", "managedsave-remove"],
"states": SHUT_OFF_STATES,
"done": "Сохранённое состояние удалено: при запуске машина {name} загрузится заново",
"wrong": "Сохранённое состояние есть только у выключенной машины",
"fail": "Не удалось удалить сохранённое состояние",
},
}
def run(cmd: list[str], timeout: int = 12) -> dict[str, Any]:
"""Все команды идут через core.run_cmd — тесты подменяют его целиком."""
return core.run_cmd(cmd, timeout=timeout)
def explain_error(raw: str, fallback: str) -> str:
low = (raw or "").lower()
for needle, text in KNOWN_ERRORS:
if needle in low:
return text
return raw or fallback
def is_shut_off(state: str) -> bool:
return (state or "").strip().lower() in SHUT_OFF_STATES
def redirect_tab(name: str, tab: str, key: str, message: str) -> RedirectResponse:
"""Как redirect_with_message, но открывает нужную вкладку страницы машины."""
return RedirectResponse(url=f"/vm/{name}?{key}={quote(message)}#{tab}", status_code=303)
def safe_next(value: str, name: str) -> str:
"""Куда вернуть пользователя после действия: только обзор или страница машины."""
return value if value in ("/", f"/vm/{name}") else f"/vm/{name}"
def _guard(request: Request, name: str):
"""Общая проверка для всех маршрутов: вход, имя, существование машины."""
denied = require_auth(request)
if denied:
return denied
if not valid_vm_name(name):
return JSONResponse({"ok": False, "error": "Invalid VM name"}, status_code=400)
if not vm_exists(name):
return RedirectResponse(url="/", status_code=303)
return None
# ---------------------------------------------------------------- parsers
def parse_qemu_img_info(text: str) -> dict[str, Any]:
"""`qemu-img info --output=json` → виртуальный и реальный размер, формат."""
try:
data = json.loads(text or "")
except ValueError:
return {}
if not isinstance(data, dict):
return {}
try:
return {"virtual_size": int(data.get("virtual-size") or 0), "actual_size": int(data.get("actual-size") or 0), "format": str(data.get("format") or "")}
except (TypeError, ValueError):
return {}
def parse_domstats(text: str) -> dict[str, dict[str, str]]:
"""`virsh domstats` → {имя: {поле: значение}}. Машины без статистики дают пустой словарь."""
stats: dict[str, dict[str, str]] = {}
current: str | None = None
for raw_line in (text or "").splitlines():
line = raw_line.strip()
match = re.fullmatch(r"Domain:\s*'(.+)'", line)
if match:
current = match.group(1)
stats[current] = {}
continue
if current is None or "=" not in line:
continue
key, value = line.split("=", 1)
stats[current][key.strip()] = value.strip()
return stats
def parse_desc_output(result: dict[str, Any]) -> str:
"""Вывод `virsh desc`: пусто, если названия/описания нет."""
if not result.get("ok"):
return ""
text = (result.get("stdout") or "").strip()
if re.match(r"^No (title|description) for domain", text):
return ""
return text
def has_managed_save(dominfo: str) -> bool:
"""Строка «Managed save: yes» в `virsh dominfo` — машина в спящем режиме."""
match = re.search(r"^Managed save:\s*(\S+)", dominfo or "", re.MULTILINE | re.IGNORECASE)
return bool(match) and match.group(1).lower() in ("yes", "on", "true")
def _int(value: str | None) -> int:
try:
return int(value or 0)
except ValueError:
return 0
def compute_stats(stats: dict[str, dict[str, str]], now: float, samples: dict[str, tuple[float, int]]) -> dict[str, dict[str, Any]]:
"""Проценты процессора считаются по разнице cpu.time с прошлым замером той же машины.
Первый замер даёт cpu_pct=None (нечего сравнивать). Память — balloon.rss
(реально занятая на сервере), если нет — balloon.current.
"""
result: dict[str, dict[str, Any]] = {}
for name, fields in stats.items():
cpu_time = _int(fields.get("cpu.time"))
vcpus = _int(fields.get("vcpu.current")) or _int(fields.get("vcpu.maximum")) or 1
mem_used_kib = _int(fields.get("balloon.rss")) or _int(fields.get("balloon.current"))
mem_total_kib = _int(fields.get("balloon.maximum")) or _int(fields.get("balloon.current"))
if not cpu_time and not mem_total_kib:
samples.pop(name, None)
continue # выключенная машина — статистики нет
cpu_pct = None
previous = samples.get(name)
if previous and now > previous[0] and cpu_time >= previous[1]:
cpu_pct = (cpu_time - previous[1]) / ((now - previous[0]) * 1e9) / vcpus * 100
cpu_pct = round(min(100.0, max(0.0, cpu_pct)), 1)
samples[name] = (now, cpu_time)
result[name] = {"cpu_pct": cpu_pct, "mem_used_mb": mem_used_kib // 1024, "mem_total_mb": mem_total_kib // 1024, "vcpus": vcpus}
for stale in set(samples) - set(stats):
samples.pop(stale, None)
return result
# ---------------------------------------------------------------- live stats
_samples: dict[str, tuple[float, int]] = {}
_samples_lock = threading.Lock()
def vm_stats(names: list[str]) -> dict[str, dict[str, Any]]:
"""Нагрузка нескольких машин одним вызовом `virsh domstats`."""
if not names:
return {}
result = run(["virsh", "domstats", "--cpu-total", "--balloon", "--vcpu", *names], timeout=10)
if not result["ok"]:
return {}
with _samples_lock:
return compute_stats(parse_domstats(result["stdout"]), time.monotonic(), _samples)
def active_vm_names() -> list[str]:
"""Имена работающих и приостановленных машин (`virsh list --name`)."""
result = run(["virsh", "list", "--name"], timeout=8)
if not result["ok"]:
return []
return [line.strip() for line in result["stdout"].splitlines() if valid_vm_name(line.strip())]
def all_vm_names() -> list[str]:
result = run(["virsh", "list", "--all", "--name"], timeout=8)
if not result["ok"]:
return []
return [line.strip() for line in result["stdout"].splitlines() if line.strip()]
@router.get("/live/stats")
def live_stats(request: Request):
denied = require_api_auth(request)
if denied:
return denied
return JSONResponse({"ok": True, "generated_at": core.utc_now(), "stats": vm_stats(active_vm_names())})
# ---------------------------------------------------------------- titles & notes
def vm_titles() -> dict[str, str]:
"""Названия всех машин одним вызовом — для списка на обзоре."""
result = run(["virsh", "list", "--all", "--title"], timeout=10)
return presenters.parse_virsh_list_titles(result["stdout"]) if result["ok"] else {}
def read_notes(name: str) -> dict[str, str]:
"""Название и описание машины. Без флагов virsh отдаёт текущее значение:
у работающей машины — живое, у выключенной — из конфигурации."""
title = run(["virsh", "-q", "desc", name, "--title"], timeout=8)
description = run(["virsh", "-q", "desc", name], timeout=8)
return {"title": parse_desc_output(title), "description": parse_desc_output(description)}
def clean_title(value: str) -> str:
return " ".join((value or "").split())[:TITLE_MAX].strip()
def clean_description(value: str) -> str:
text = (value or "").replace("\r\n", "\n").replace("\r", "\n")
lines = [line.rstrip() for line in text.split("\n")]
return "\n".join(lines).strip()[:DESCRIPTION_MAX].strip()
def write_notes(name: str, title: str, description: str) -> tuple[bool, str]:
"""`virsh desc` в конфигурацию, а у работающей машины — ещё и вживую.
Текст передаётся как --new-desc=ТЕКСТ одним аргументом: так virsh не
примет описание вида «--edit» за свою опцию."""
flags = ["--config"] + (["--live"] if not is_shut_off(vm_state(name)) else [])
for extra, value in ((["--title"], title), ([], description)):
result = run(["virsh", "desc", name, *extra, f"--new-desc={value}", *flags], timeout=10)
if not result["ok"]:
return False, explain_error(cmd_error(result, "virsh desc завершился с ошибкой"), "Не удалось сохранить заметки")
return True, "Заметки сохранены"
@router.post("/vm/{name}/notes/save")
def notes_save(request: Request, name: str, title: str = Form(""), description: str = Form("")):
denied = _guard(request, name)
if denied:
return denied
ok, message = write_notes(name, clean_title(title), clean_description(description))
return redirect_with_message(f"/vm/{name}", "notes_message" if ok else "notes_error", message)
# ---------------------------------------------------------------- disks
def inside_storage(path: str) -> bool:
"""Диск должен лежать в /var/lib/virtuality — чужие файлы панель не трогает."""
try:
resolved = Path(path).resolve()
except (OSError, RuntimeError):
return False
return Path(core.STORAGE_DIR).resolve() in resolved.parents
def free_space(path: str | Path) -> int:
"""Свободное место рядом с файлом диска (или в хранилище образов)."""
for candidate in (Path(path).parent, Path(core.IMAGES_DIR), Path("/")):
try:
return shutil.disk_usage(candidate).free
except OSError:
continue
return 0
def disk_info(path: str) -> dict[str, Any]:
# -U (--force-share): иначе qemu-img не откроет диск работающей машины из-за блокировки.
result = run(["qemu-img", "info", "--output=json", "-U", path], timeout=20)
return parse_qemu_img_info(result["stdout"]) if result["ok"] else {}
def vm_disks(name: str) -> list[dict[str, Any]]:
"""Диски машины с размерами; у подходящих — флаг resizable и минимальный новый размер."""
result = run(["virsh", "domblklist", name, "--details"], timeout=10)
disks = presenters.parse_domblklist(result["stdout"]) if result["ok"] else []
items = []
for disk in disks:
item: dict[str, Any] = dict(disk, virtual_size=0, actual_size=0, format="", size_text="", used_text="", resizable=False, min_gb=0)
if disk["device"] == "disk" and disk["source"] and disk["type"] == "file":
info = disk_info(disk["source"])
virtual, actual = info.get("virtual_size", 0), info.get("actual_size", 0)
item.update(virtual_size=virtual, actual_size=actual, format=info.get("format", ""))
if virtual:
item["size_text"] = presenters.format_bytes(virtual)
item["used_text"] = presenters.format_bytes(actual) if actual else ""
item["min_gb"] = virtual // GIB + 1
item["resizable"] = item["format"] in RESIZABLE_FORMATS and inside_storage(disk["source"])
items.append(item)
return items
def disks_context(name: str) -> dict[str, Any]:
"""Данные для карточки «Диски» на странице машины (вызывается из шаблона)."""
disks = vm_disks(name)
first = next((d["source"] for d in disks if d["source"]), None)
return {"disks": disks, "free_text": presenters.format_bytes(free_space(first or core.IMAGES_DIR)), "max_gb": MAX_DISK_GB, "hint": GUEST_GROW_HINT}
@router.post("/vm/{name}/disks/{target}/resize")
def disk_resize(request: Request, name: str, target: str, size_gb: int = Form(...)):
denied = _guard(request, name)
if denied:
return denied
if not re.fullmatch(r"[a-z0-9]{1,16}", target or ""):
return JSONResponse({"ok": False, "error": "Invalid disk target"}, status_code=400)
disk = next((d for d in vm_disks(name) if d["target"] == target), None)
if not disk:
return redirect_tab(name, "storage", "disk_error", "Такого диска у машины нет")
if not disk["resizable"]:
return redirect_tab(name, "storage", "disk_error", f"Диск {disk['name']} нельзя увеличить из панели: он не в хранилище Virtuality или в необычном формате")
if size_gb < disk["min_gb"]:
return redirect_tab(name, "storage", "disk_error", f"Диск можно только увеличивать: укажите не меньше {disk['min_gb']} ГБ")
if size_gb > MAX_DISK_GB:
return redirect_tab(name, "storage", "disk_error", f"Слишком большой размер: не больше {MAX_DISK_GB // 1024} ТБ")
growth = size_gb * GIB - disk["virtual_size"]
free = free_space(disk["source"])
if growth > free:
return redirect_tab(name, "storage", "disk_error", f"На сервере свободно только {presenters.format_bytes(free)}: столько добавить диску не получится")
if is_shut_off(vm_state(name)):
result = run(["qemu-img", "resize", disk["source"], f"{size_gb}G"], timeout=120)
else:
result = run(["virsh", "blockresize", name, target, f"{size_gb}G"], timeout=60)
if not result["ok"]:
return redirect_tab(name, "storage", "disk_error", explain_error(cmd_error(result, "Не удалось увеличить диск"), "Не удалось увеличить диск"))
return redirect_tab(name, "storage", "disk_message", f"Диск {disk['name']} увеличен до {size_gb} ГБ. {GUEST_GROW_HINT}")
# ---------------------------------------------------------------- clone
def suggest_clone_name(name: str, existing: set[str]) -> str:
base = f"{name}-copy"[:60]
candidate, index = base, 2
while candidate in existing:
candidate = f"{base}{index}"
index += 1
return candidate
def clone_targets(new_name: str, disks: list[dict[str, Any]]) -> list[Path]:
"""Файлы дисков копии: DST.qcow2, DST-disk2.qcow2, … в порядке domblklist."""
targets = []
cloneable = [d for d in disks if d["device"] == "disk" and d["source"]]
for index, disk in enumerate(cloneable):
ext = Path(disk["source"]).suffix.lower()
ext = ext if ext in (".qcow2", ".img", ".raw") else ".qcow2"
suffix = "" if index == 0 else f"-disk{index + 1}"
targets.append(Path(core.IMAGES_DIR) / f"{new_name}{suffix}{ext}")
return targets
def clone_context(name: str) -> dict[str, Any]:
state = vm_state(name)
disks = vm_disks(name)
cloneable = [d for d in disks if d["device"] == "disk" and d["source"]]
needed = sum(d["actual_size"] or d["virtual_size"] for d in cloneable)
free = free_space(core.IMAGES_DIR)
existing = set(all_vm_names())
blockers = []
if not is_shut_off(state):
blockers.append("running")
if not cloneable:
blockers.append("no_disks")
if needed > free:
blockers.append("space")
if any(op.get("source_vm") == name for op in running_operations("vm_clone")):
blockers.append("busy")
return {
"state": state, "is_shut_off": is_shut_off(state), "disks": cloneable, "needed_text": presenters.format_bytes(needed),
"free_text": presenters.format_bytes(free), "suggested": suggest_clone_name(name, existing), "blockers": blockers,
"vcpus": 0, "memory_mb": 0,
}
def _allocated_bytes(path: Path) -> int:
try:
stat = path.stat()
except OSError:
return 0
blocks = getattr(stat, "st_blocks", None)
return blocks * 512 if blocks is not None else stat.st_size
def _watch_clone_progress(operation: dict[str, Any], targets: list[Path], expected: int, stop: threading.Event) -> None:
"""Пока virt-clone копирует, следим за ростом файлов копии и обновляем прогресс."""
while not stop.wait(1.0):
copied = sum(_allocated_bytes(target) for target in targets)
if expected > 0:
pct = min(95, max(3, int(copied * 95 / expected)))
update_operation(operation, progress=pct, message=f"Копируем диски: {presenters.format_bytes(copied)} из {presenters.format_bytes(expected)}")
def clone_worker(operation: dict[str, Any]) -> None:
source, new_name = operation["source_vm"], operation["vm_name"]
targets = [Path(item) for item in operation.get("targets", [])]
cmd = ["virt-clone", "--original", source, "--name", new_name]
for target in targets:
cmd += ["--file", str(target)]
update_operation(operation, progress=3, message=f"Копируем диски машины {source}…", cmd=" ".join(cmd))
append_operation_log(operation["id"], " ".join(cmd))
stop = threading.Event()
watcher = threading.Thread(target=_watch_clone_progress, args=(operation, targets, int(operation.get("expected_bytes") or 0), stop), daemon=True)
watcher.start()
try:
result = run(cmd, timeout=CLONE_TIMEOUT)
finally:
stop.set()
watcher.join(timeout=5)
for stream in ("stdout", "stderr"):
if result.get(stream):
append_operation_log(operation["id"], result[stream])
if not result["ok"]:
for target in targets: # virt-clone обычно сам убирает недокопированные файлы, но подстрахуемся
try:
if target.exists() and not run(["virsh", "dominfo", new_name], timeout=8)["ok"]:
target.unlink()
except OSError:
pass
finish_operation(operation, False, explain_error(cmd_error(result, "virt-clone завершился с ошибкой"), "Не удалось клонировать машину"))
return
update_operation(operation, progress=97, message="Обновляем список дисков…")
refresh = run(["virsh", "pool-refresh", IMAGES_POOL], timeout=60)
if not refresh["ok"]:
append_operation_log(operation["id"], f"pool-refresh: {cmd_error(refresh, 'не удалось обновить хранилище (не страшно)')}")
finish_operation(operation, True, f"Машина {new_name} создана — это копия {source}. Запустите её и смените имя компьютера внутри системы.")
@router.get("/vm/{name}/clone")
def clone_page(request: Request, name: str):
denied = _guard(request, name)
if denied:
return denied
context = clone_context(name)
context.update(vm_name=name, error=request.query_params.get("clone_error", ""), new_name=request.query_params.get("new_name", "") or context["suggested"])
return render(request, "vm_clone.html", context)
@router.post("/vm/{name}/clone/start")
def clone_vm(request: Request, name: str, new_name: str = Form("")):
denied = _guard(request, name)
if denied:
return denied
new_name = (new_name or "").strip()
def fail(message: str):
return RedirectResponse(url=f"/vm/{name}/clone?clone_error={quote(message)}&new_name={quote(new_name)}", status_code=303)
if not valid_vm_name(new_name):
return fail("Имя копии: от 2 до 63 символов — латинские буквы, цифры, точка, дефис и подчёркивание, первый символ — буква или цифра")
if new_name.lower() == name.lower() or new_name in set(all_vm_names()) or vm_exists(new_name):
return fail(f"Машина с именем {new_name} уже есть — выберите другое имя")
context = clone_context(name)
if "running" in context["blockers"]:
return fail("Сначала выключите машину: копировать диски можно только у выключенной машины")
if "no_disks" in context["blockers"]:
return fail("У машины нет дисков, которые можно скопировать")
if "busy" in context["blockers"]:
return fail("Эта машина уже клонируется — дождитесь окончания в разделе «Задачи»")
if any(op.get("vm_name") == new_name for op in running_operations("vm_clone")):
return fail(f"Копия с именем {new_name} уже создаётся — выберите другое имя")
if "space" in context["blockers"]:
return fail(f"Не хватает места: копии нужно {context['needed_text']}, свободно {context['free_text']}")
targets = clone_targets(new_name, context["disks"])
for target in targets:
if target.exists():
return fail(f"Файл {target.name} уже есть в хранилище — выберите другое имя")
needed = sum(d["actual_size"] or d["virtual_size"] for d in context["disks"])
operation = new_operation("vm_clone", f"Клонирование {name} → {new_name}", vm_name=new_name, source_vm=name, targets=[str(t) for t in targets], expected_bytes=needed)
run_operation(operation, clone_worker)
return RedirectResponse(url=f"/operations/{operation['id']}", status_code=303)
# ---------------------------------------------------------------- pause / resume / hibernate
def hibernate_worker(operation: dict[str, Any]) -> None:
name = operation["vm_name"]
update_operation(operation, progress=10, message="Сохраняем память машины на диск сервера…")
result = run(["virsh", "managedsave", name], timeout=HIBERNATE_TIMEOUT)
for stream in ("stdout", "stderr"):
if result.get(stream):
append_operation_log(operation["id"], result[stream])
if not result["ok"]:
finish_operation(operation, False, explain_error(cmd_error(result, "virsh managedsave завершился с ошибкой"), "Не удалось перевести машину в спящий режим"))
return
finish_operation(operation, True, f"Машина {name} в спящем режиме. При запуске она продолжит работу с того же места.")
def hibernate(name: str, state: str, target: str):
if state not in ACTIVE_STATES + ("paused",):
return redirect_with_message(target, "ops_error", "В спящий режим можно перевести только работающую или приостановленную машину")
if running_operations("vm_hibernate"):
return redirect_with_message(target, "ops_error", "Другая машина сейчас засыпает — подождите минуту")
info = presenters.parse_dominfo(run(["virsh", "dominfo", name], timeout=8).get("stdout", ""))
needed = info["memory_mb"] * 1024 * 1024
if needed and needed > free_space(SAVE_DIR / "x"):
return redirect_with_message(target, "ops_error", f"Для спящего режима нужно {presenters.format_bytes(needed)} свободного места на сервере, а его нет")
operation = new_operation("vm_hibernate", f"Спящий режим: {name}", vm_name=name)
run_operation(operation, hibernate_worker)
return redirect_with_message(target, "ops_message", f"Машина {name} засыпает: состояние сохраняется на диск, это займёт до минуты")
@router.post("/vm/{name}/ops/{action}")
def vm_ops(request: Request, name: str, action: str, next: str = Form("")):
denied = require_auth(request)
if denied:
return denied
if not valid_vm_name(name):
return JSONResponse({"ok": False, "error": "Invalid VM name"}, status_code=400)
if action != "hibernate" and action not in OPS:
return JSONResponse({"ok": False, "error": "Unsupported action"}, status_code=400)
if not vm_exists(name):
return RedirectResponse(url="/", status_code=303)
target = safe_next(next, name)
state = vm_state(name)
if action == "hibernate":
return hibernate(name, state, target)
spec = OPS[action]
if state not in spec["states"]:
return redirect_with_message(target, "ops_error", spec["wrong"])
result = run(spec["cmd"] + [name], timeout=30)
if not result["ok"]:
return redirect_with_message(target, "ops_error", explain_error(cmd_error(result, spec["fail"]), spec["fail"]))
return redirect_with_message(target, "ops_message", spec["done"].format(name=name))
# Шаблоны берут данные напрямую (как update_notice() в base.html), чтобы
# страница машины и обзор в app.py не менялись.
TEMPLATE_GLOBALS = {"vmops_disks": disks_context, "vmops_notes": read_notes, "vmops_titles": vm_titles, "vmops_saved": has_managed_save}
templates.env.globals.update(TEMPLATE_GLOBALS)
# Обзор и страница машины пока рендерятся отдельным окружением шаблонов из app.py —
# подключаем и его. Циклический импорт безопасен: app.py импортирует этот модуль
# последними строками, когда templates уже создан.
import app as _app # noqa: E402
if getattr(_app, "templates", None) is not None and _app.templates is not templates:
_app.templates.env.globals.update(TEMPLATE_GLOBALS)
+25
Просмотреть файл
@@ -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}"
value /= 1024
return f"{value:.0f} ТБ"
def parse_virsh_list_titles(text: str) -> dict[str, str]:
"""`virsh list --all --title` → {имя: название}. Колонки virsh выровнены
пробелами, поэтому название берём по смещению колонки Title из заголовка:
так не ломаются состояния с пробелом («shut off») и названия из нескольких слов."""
lines = (text or "").splitlines()
if not lines:
return {}
header = lines[0]
name_col, title_col = re.search(r"\bName\b", header), re.search(r"\bTitle\b", header)
if not name_col or not title_col:
return {}
titles: dict[str, str] = {}
for line in lines[1:]:
stripped = line.strip()
if not stripped or set(stripped) <= {"-"}:
continue
name_part = line[name_col.start():].split()
if not name_part:
continue
title = line[title_col.start():].strip()
if title:
titles[name_part[0]] = title
return titles
+25
Просмотреть файл
@@ -584,3 +584,28 @@ details.advanced > .advanced-body { padding: 4px 16px 16px; }
.hide-sm { display: 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; }
}
+2
Просмотреть файл
@@ -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-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-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>

До

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

После

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

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